Skip to content

Commit 591ad54

Browse files
authored
Add symlink support to vfstest package (microsoft#396)
1 parent 2d4c0be commit 591ad54

File tree

3 files changed

+334
-39
lines changed

3 files changed

+334
-39
lines changed

internal/execute/testsys_test.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package execute_test
22

33
import (
4+
"errors"
45
"fmt"
56
"io"
7+
"io/fs"
68
"maps"
79
"slices"
810
"strings"
@@ -90,21 +92,25 @@ func (s *testSys) baselineFSwithDiff(baseline io.Writer) {
9092
snap := map[string]string{}
9193

9294
err := s.FS().WalkDir(s.GetCurrentDirectory(), func(path string, d vfs.DirEntry, e error) error {
93-
if !s.FS().FileExists(path) {
95+
if e != nil {
96+
return e
97+
}
98+
99+
if !d.Type().IsRegular() {
94100
return nil
95101
}
96102

97103
newContents, ok := s.FS().ReadFile(path)
98104
if !ok {
99-
return e
105+
return nil
100106
}
101107
snap[path] = newContents
102108
reportFSEntryDiff(baseline, s.serializedDiff[path], newContents, path)
103109

104110
return nil
105111
})
106-
if err != nil {
107-
panic("walkdir error during diff")
112+
if err != nil && !errors.Is(err, fs.ErrNotExist) {
113+
panic("walkdir error during diff: " + err.Error())
108114
}
109115
for path, oldDirContents := range s.serializedDiff {
110116
if s.FS().FileExists(path) {

internal/vfs/vfstest/vfstest.go

Lines changed: 165 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package vfstest
22

33
import (
4+
"errors"
45
"fmt"
56
"io/fs"
67
"maps"
@@ -23,11 +24,14 @@ type mapFS struct {
2324
m fstest.MapFS
2425

2526
useCaseSensitiveFileNames bool
27+
28+
symlinks map[canonicalPath]canonicalPath
2629
}
2730

2831
var (
2932
_ iovfs.RealpathFS = (*mapFS)(nil)
3033
_ iovfs.WritableFS = (*mapFS)(nil)
34+
_ fs.ReadFileFS = (*mapFS)(nil)
3135
)
3236

3337
type sys struct {
@@ -45,7 +49,7 @@ func FromMap[File any](m map[string]File, useCaseSensitiveFileNames bool) vfs.FS
4549
posix := false
4650
windows := false
4751

48-
for p := range m {
52+
checkPath := func(p string) {
4953
if !tspath.IsRootedDiskPath(p) {
5054
panic(fmt.Sprintf("non-rooted path %q", p))
5155
}
@@ -61,12 +65,10 @@ func FromMap[File any](m map[string]File, useCaseSensitiveFileNames bool) vfs.FS
6165
}
6266
}
6367

64-
if posix && windows {
65-
panic("mixed posix and windows paths")
66-
}
67-
6868
mfs := make(fstest.MapFS, len(m))
6969
for p, f := range m {
70+
checkPath(p)
71+
7072
var file *fstest.MapFile
7173
switch f := any(f).(type) {
7274
case string:
@@ -79,10 +81,24 @@ func FromMap[File any](m map[string]File, useCaseSensitiveFileNames bool) vfs.FS
7981
panic(fmt.Sprintf("invalid file type %T", f))
8082
}
8183

84+
if file.Mode&fs.ModeSymlink != 0 {
85+
target := string(file.Data)
86+
checkPath(target)
87+
88+
target, _ = strings.CutPrefix(target, "/")
89+
fileCopy := *file
90+
fileCopy.Data = []byte(target)
91+
file = &fileCopy
92+
}
93+
8294
p, _ = strings.CutPrefix(p, "/")
8395
mfs[p] = file
8496
}
8597

98+
if posix && windows {
99+
panic("mixed posix and windows paths")
100+
}
101+
86102
return iovfs.From(convertMapFS(mfs, useCaseSensitiveFileNames), useCaseSensitiveFileNames)
87103
}
88104

@@ -115,8 +131,10 @@ func convertMapFS(input fstest.MapFS, useCaseSensitiveFileNames bool) *mapFS {
115131
// Create all missing intermediate directories so we can attach the realpath to each of them.
116132
// fstest.MapFS doesn't require this as it synthesizes directories on the fly, but it's a lot
117133
// harder to reapply a realpath onto those when we're deep in some FileInfo method.
118-
if err := m.mkdirAll(dirName(p), 0o777); err != nil {
119-
panic(fmt.Sprintf("failed to create intermediate directories for %q: %v", p, err))
134+
if dir := dirName(p); dir != "" {
135+
if err := m.mkdirAll(dir, 0o777); err != nil {
136+
panic(fmt.Sprintf("failed to create intermediate directories for %q: %v", p, err))
137+
}
120138
}
121139
m.setEntry(p, m.getCanonicalPath(p), *file)
122140
}
@@ -151,44 +169,137 @@ func (m *mapFS) open(p canonicalPath) (fs.File, error) {
151169
return m.m.Open(string(p))
152170
}
153171

154-
func (m *mapFS) get(p canonicalPath) (*fstest.MapFile, bool) {
155-
file, ok := m.m[string(p)]
156-
return file, ok
172+
func Symlink(target string) *fstest.MapFile {
173+
return &fstest.MapFile{
174+
Data: []byte(target),
175+
Mode: fs.ModeSymlink,
176+
}
177+
}
178+
179+
func (m *mapFS) getFollowingSymlinks(p canonicalPath) (*fstest.MapFile, canonicalPath, error) {
180+
return m.getFollowingSymlinksWorker(p, "", "")
181+
}
182+
183+
type brokenSymlinkError struct {
184+
from, to canonicalPath
185+
}
186+
187+
func (e *brokenSymlinkError) Error() string {
188+
return fmt.Sprintf("broken symlink %q -> %q", e.from, e.to)
189+
}
190+
191+
func (m *mapFS) getFollowingSymlinksWorker(p canonicalPath, symlinkFrom, symlinkTo canonicalPath) (*fstest.MapFile, canonicalPath, error) {
192+
if file, ok := m.m[string(p)]; ok && file.Mode&fs.ModeSymlink == 0 {
193+
return file, p, nil
194+
}
195+
196+
if target, ok := m.symlinks[p]; ok {
197+
return m.getFollowingSymlinksWorker(target, p, target)
198+
}
199+
200+
// This could be a path underneath a symlinked directory.
201+
for other, target := range m.symlinks {
202+
if len(other) < len(p) && other == p[:len(other)] && p[len(other)] == '/' {
203+
return m.getFollowingSymlinksWorker(target+p[len(other):], other, target)
204+
}
205+
}
206+
207+
err := fs.ErrNotExist
208+
if symlinkFrom != "" {
209+
err = &brokenSymlinkError{symlinkFrom, symlinkTo}
210+
}
211+
return nil, p, err
157212
}
158213

159214
func (m *mapFS) set(p canonicalPath, file *fstest.MapFile) {
160215
m.m[string(p)] = file
161216
}
162217

163218
func (m *mapFS) setEntry(realpath string, canonical canonicalPath, file fstest.MapFile) {
219+
if realpath == "" || canonical == "" {
220+
panic("empty path")
221+
}
222+
164223
file.Sys = &sys{
165224
original: file.Sys,
166225
realpath: realpath,
167226
}
168227
m.set(canonical, &file)
228+
229+
if file.Mode&fs.ModeSymlink != 0 {
230+
if m.symlinks == nil {
231+
m.symlinks = make(map[canonicalPath]canonicalPath)
232+
}
233+
m.symlinks[canonical] = m.getCanonicalPath(string(file.Data))
234+
}
169235
}
170236

171-
func dirName(p string) string {
172-
dir := path.Dir(p)
173-
if dir == "." {
174-
return ""
237+
func splitPath(s string, offset int) (before, after string) {
238+
idx := strings.IndexByte(s[offset:], '/')
239+
if idx < 0 {
240+
return s, ""
175241
}
176-
return dir
242+
return s[:idx+offset], s[idx+1+offset:]
243+
}
244+
245+
func dirName(p string) string {
246+
dir, _ := path.Split(p)
247+
return strings.TrimSuffix(dir, "/")
248+
}
249+
250+
func baseName(p string) string {
251+
_, file := path.Split(p)
252+
return file
177253
}
178254

179255
func (m *mapFS) mkdirAll(p string, perm fs.FileMode) error {
180-
for ; p != ""; p = dirName(p) {
181-
canonical := m.getCanonicalPath(p)
182-
if other, ok := m.get(canonical); ok {
183-
if other.Mode.IsDir() {
184-
break
185-
}
256+
if p == "" {
257+
panic("empty path")
258+
}
259+
260+
// Fast path; already exists.
261+
if other, _, err := m.getFollowingSymlinks(m.getCanonicalPath(p)); err == nil {
262+
if !other.Mode.IsDir() {
186263
return fmt.Errorf("mkdir %q: path exists but is not a directory", p)
187264
}
188-
m.setEntry(p, canonical, fstest.MapFile{
265+
return nil
266+
}
267+
268+
var toCreate []string
269+
offset := 0
270+
for {
271+
dir, rest := splitPath(p, offset)
272+
canonical := m.getCanonicalPath(dir)
273+
other, otherPath, err := m.getFollowingSymlinks(canonical)
274+
if err != nil {
275+
if !errors.Is(err, fs.ErrNotExist) {
276+
return err
277+
}
278+
toCreate = append(toCreate, dir)
279+
} else {
280+
if !other.Mode.IsDir() {
281+
return fmt.Errorf("mkdir %q: path exists but is not a directory", otherPath)
282+
}
283+
if canonical != otherPath {
284+
// We have a symlinked parent, reset and start again.
285+
p = other.Sys.(*sys).realpath + "/" + rest
286+
toCreate = toCreate[:0]
287+
offset = 0
288+
continue
289+
}
290+
}
291+
if rest == "" {
292+
break
293+
}
294+
offset = len(dir) + 1
295+
}
296+
297+
for _, dir := range toCreate {
298+
m.setEntry(dir, m.getCanonicalPath(dir), fstest.MapFile{
189299
Mode: fs.ModeDir | perm&^umask,
190300
})
191301
}
302+
192303
return nil
193304
}
194305

@@ -199,7 +310,7 @@ type fileInfo struct {
199310
}
200311

201312
func (fi *fileInfo) Name() string {
202-
return path.Base(fi.realpath)
313+
return baseName(fi.realpath)
203314
}
204315

205316
func (fi *fileInfo) Sys() any {
@@ -284,15 +395,24 @@ func (m *mapFS) Open(name string) (fs.File, error) {
284395
}, nil
285396
}
286397

398+
func (m *mapFS) ReadFile(name string) ([]byte, error) {
399+
m.mu.RLock()
400+
defer m.mu.RUnlock()
401+
402+
file, _, err := m.getFollowingSymlinks(m.getCanonicalPath(name))
403+
if err != nil {
404+
return nil, err
405+
}
406+
return file.Data, nil
407+
}
408+
287409
func (m *mapFS) Realpath(name string) (string, error) {
288410
m.mu.RLock()
289411
defer m.mu.RUnlock()
290412

291-
// TODO: handle symlinks after https://go.dev/cl/385534 is available
292-
// Don't bother going through fs.Stat.
293-
file, ok := m.get(m.getCanonicalPath(name))
294-
if !ok {
295-
return "", fs.ErrNotExist
413+
file, _, err := m.getFollowingSymlinks(m.getCanonicalPath(name))
414+
if err != nil {
415+
return "", err
296416
}
297417
return file.Sys.(*sys).realpath, nil
298418
}
@@ -324,16 +444,29 @@ func (m *mapFS) WriteFile(path string, data []byte, perm fs.FileMode) error {
324444

325445
if parent := dirName(path); parent != "" {
326446
canonical := m.getCanonicalPath(parent)
327-
parentFile, ok := m.get(canonical)
328-
if !ok {
329-
return fmt.Errorf("write %q: parent directory does not exist", path)
447+
parentFile, _, err := m.getFollowingSymlinks(canonical)
448+
if err != nil {
449+
return fmt.Errorf("write %q: %w", path, err)
330450
}
331451
if !parentFile.Mode.IsDir() {
332452
return fmt.Errorf("write %q: parent path exists but is not a directory", path)
333453
}
334454
}
335455

336-
m.setEntry(path, m.getCanonicalPath(path), fstest.MapFile{
456+
file, cp, err := m.getFollowingSymlinks(m.getCanonicalPath(path))
457+
if err != nil {
458+
var brokenSymlinkError *brokenSymlinkError
459+
if !errors.Is(err, fs.ErrNotExist) && !errors.As(err, &brokenSymlinkError) {
460+
// No other errors are possible.
461+
panic(err)
462+
}
463+
} else {
464+
if !file.Mode.IsRegular() {
465+
return fmt.Errorf("write %q: path exists but is not a regular file", path)
466+
}
467+
}
468+
469+
m.setEntry(path, cp, fstest.MapFile{
337470
Data: data,
338471
ModTime: time.Now(),
339472
Mode: perm &^ umask,

0 commit comments

Comments
 (0)