From d653bf1b90f9e68c8dff8ba63c551ba590e1cd7e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 01:47:44 +0000 Subject: [PATCH] Share immutable in-memory filesystems --- internal/framework/llm/asset_registry.go | 163 +-------------- internal/framework/promptfs/prompt_fs.go | 172 ++-------------- internal/framework/promptfs/prompt_fs_test.go | 64 ++++++ internal/framework/readonlyfs/readonly_fs.go | 193 ++++++++++++++++++ .../framework/readonlyfs/readonly_fs_test.go | 153 ++++++++++++++ 5 files changed, 434 insertions(+), 311 deletions(-) create mode 100644 internal/framework/readonlyfs/readonly_fs.go create mode 100644 internal/framework/readonlyfs/readonly_fs_test.go diff --git a/internal/framework/llm/asset_registry.go b/internal/framework/llm/asset_registry.go index 792f0f7..c7ff0c0 100644 --- a/internal/framework/llm/asset_registry.go +++ b/internal/framework/llm/asset_registry.go @@ -1,17 +1,15 @@ package llm import ( - "bytes" "crypto/sha256" "encoding/hex" "fmt" "io" "io/fs" "path" - "sort" "strings" - "time" + "gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs" "gitea.maximumdirect.net/eric/promptkit" ) @@ -223,7 +221,7 @@ func newAssetSource(fsys fs.FS, root string) (AssetSource, error) { } func flattenAssetSources(sources []AssetSource) (fs.FS, error) { - out := assetMapFS{} + out := make(map[string][]byte) for _, source := range sources { if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { @@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) { if err != nil { return err } - out[rel] = append([]byte(nil), data...) + out[rel] = data return nil }); err != nil { return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err) } } - return out, nil + return readonlyfs.New(out) } func cleanAssetRoot(root string) (string, error) { @@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) { } return cleaned, nil } - -type assetMapFS map[string][]byte - -func (m assetMapFS) Open(name string) (fs.File, error) { - cleaned, err := cleanOpenPath(name) - if err != nil { - return nil, &fs.PathError{Op: "open", Path: name, Err: err} - } - if data, ok := m[cleaned]; ok { - return &assetFile{ - reader: bytes.NewReader(data), - info: assetFileInfo{name: path.Base(cleaned), size: int64(len(data))}, - }, nil - } - entries := m.dirEntries(cleaned) - if entries != nil { - return &assetDir{name: path.Base(cleaned), entries: entries}, nil - } - return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} -} - -func (m assetMapFS) ReadFile(name string) ([]byte, error) { - cleaned, err := cleanOpenPath(name) - if err != nil { - return nil, &fs.PathError{Op: "readfile", Path: name, Err: err} - } - data, ok := m[cleaned] - if !ok { - return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist} - } - return append([]byte(nil), data...), nil -} - -func (m assetMapFS) ReadDir(name string) ([]fs.DirEntry, error) { - cleaned, err := cleanOpenPath(name) - if err != nil { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} - } - entries := m.dirEntries(cleaned) - if entries == nil { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} - } - return entries, nil -} - -func (m assetMapFS) dirEntries(dir string) []fs.DirEntry { - children := map[string]assetDirEntry{} - prefix := "" - if dir != "." { - prefix = dir + "/" - } - for name, data := range m { - if !strings.HasPrefix(name, prefix) { - continue - } - rest := strings.TrimPrefix(name, prefix) - if rest == "" { - continue - } - childName, _, hasSlash := strings.Cut(rest, "/") - entry := assetDirEntry{name: childName, dir: hasSlash} - if !hasSlash { - entry.size = int64(len(data)) - } - children[childName] = entry - } - if len(children) == 0 { - if dir == "." { - return []fs.DirEntry{} - } - return nil - } - names := make([]string, 0, len(children)) - for name := range children { - names = append(names, name) - } - sort.Strings(names) - entries := make([]fs.DirEntry, 0, len(names)) - for _, name := range names { - entries = append(entries, children[name]) - } - return entries -} - -func cleanOpenPath(name string) (string, error) { - if name == "." { - return ".", nil - } - return cleanAssetPath(name) -} - -type assetFile struct { - reader *bytes.Reader - info assetFileInfo -} - -func (f *assetFile) Stat() (fs.FileInfo, error) { return f.info, nil } -func (f *assetFile) Read(p []byte) (int, error) { return f.reader.Read(p) } -func (f *assetFile) Close() error { return nil } - -type assetDir struct { - name string - offset int - entries []fs.DirEntry -} - -func (d *assetDir) Stat() (fs.FileInfo, error) { return assetFileInfo{name: d.name, dir: true}, nil } -func (d *assetDir) Read([]byte) (int, error) { return 0, fmt.Errorf("cannot read directory") } -func (d *assetDir) Close() error { return nil } -func (d *assetDir) ReadDir(n int) ([]fs.DirEntry, error) { - if d.offset >= len(d.entries) { - return nil, io.EOF - } - end := len(d.entries) - if n > 0 && d.offset+n < end { - end = d.offset + n - } - out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) - d.offset = end - return out, nil -} - -type assetDirEntry struct { - name string - dir bool - size int64 -} - -func (e assetDirEntry) Name() string { return e.name } -func (e assetDirEntry) IsDir() bool { return e.dir } -func (e assetDirEntry) Type() fs.FileMode { return e.InfoMode().Type() } -func (e assetDirEntry) Info() (fs.FileInfo, error) { - return assetFileInfo{name: e.name, dir: e.dir, size: e.size}, nil -} -func (e assetDirEntry) InfoMode() fs.FileMode { - if e.dir { - return fs.ModeDir | 0o555 - } - return 0o444 -} - -type assetFileInfo struct { - name string - dir bool - size int64 -} - -func (i assetFileInfo) Name() string { return i.name } -func (i assetFileInfo) Size() int64 { return i.size } -func (i assetFileInfo) Mode() fs.FileMode { return assetDirEntry{dir: i.dir}.InfoMode() } -func (i assetFileInfo) ModTime() time.Time { return time.Time{} } -func (i assetFileInfo) IsDir() bool { return i.dir } -func (i assetFileInfo) Sys() any { return nil } diff --git a/internal/framework/promptfs/prompt_fs.go b/internal/framework/promptfs/prompt_fs.go index 021413d..6c0d38e 100644 --- a/internal/framework/promptfs/prompt_fs.go +++ b/internal/framework/promptfs/prompt_fs.go @@ -1,14 +1,12 @@ package promptfs import ( - "bytes" "fmt" - "io" "io/fs" "path" - "sort" "strings" - "time" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs" ) // ModulePromptFile maps a module-owned embedded prompt file into the registered @@ -37,7 +35,8 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if moduleFS == nil { return nil, fmt.Errorf("module prompt filesystem must not be nil") } - assets := make(promptMapFS, len(files)+len(sharedFiles)) + assets := make(map[string][]byte, len(files)+len(sharedFiles)) + moduleDestinations := make(map[string]struct{}, len(files)) for _, file := range files { name, err := cleanPromptPath(file.Name) if err != nil { @@ -46,6 +45,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if strings.Contains(name, "/") { return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.Name) } + destination := "assets/prompts/" + cleanModuleDir + "/" + name + if _, exists := moduleDestinations[destination]; exists { + return nil, fmt.Errorf("duplicate module prompt destination %q", destination) + } + moduleDestinations[destination] = struct{}{} filePath, err := cleanPromptPath(file.Path) if err != nil { return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err) @@ -54,9 +58,10 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if err != nil { return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err) } - assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...) + assets[destination] = data } + sharedDestinations := make(map[string]struct{}, len(sharedFiles)) for _, file := range sharedFiles { name, err := cleanPromptPath(file.Name) if err != nil { @@ -65,6 +70,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if strings.Contains(name, "/") { return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.Name) } + destination := "assets/prompts/" + cleanModuleDir + "/sharedassets/" + name + if _, exists := sharedDestinations[destination]; exists { + return nil, fmt.Errorf("duplicate sharedassets prompt destination %q", destination) + } + sharedDestinations[destination] = struct{}{} if file.FS == nil { return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name) } @@ -76,77 +86,13 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, if err != nil { return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err) } - assets["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...) + assets[destination] = data } - return assets, nil -} - -type promptMapFS map[string][]byte - -func (m promptMapFS) Open(name string) (fs.File, error) { - cleaned, err := cleanPromptFSPath(name) + fsys, err := readonlyfs.New(assets) if err != nil { - return nil, &fs.PathError{Op: "open", Path: name, Err: err} + return nil, fmt.Errorf("construct prompt filesystem: %w", err) } - if data, ok := m[cleaned]; ok { - return &promptFile{ - reader: bytes.NewReader(data), - info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))}, - }, nil - } - entries := m.dirEntries(cleaned) - if entries != nil { - return &promptDir{name: path.Base(cleaned), entries: entries}, nil - } - return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} -} - -func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) { - cleaned, err := cleanPromptFSPath(name) - if err != nil { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} - } - entries := m.dirEntries(cleaned) - if entries == nil { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} - } - return entries, nil -} - -func (m promptMapFS) dirEntries(dir string) []fs.DirEntry { - children := map[string]promptDirEntry{} - prefix := "" - if dir != "." { - prefix = dir + "/" - } - for name, data := range m { - if !strings.HasPrefix(name, prefix) { - continue - } - rest := strings.TrimPrefix(name, prefix) - if rest == "" { - continue - } - childName, _, hasSlash := strings.Cut(rest, "/") - entry := promptDirEntry{name: childName, dir: hasSlash} - if !hasSlash { - entry.size = int64(len(data)) - } - children[childName] = entry - } - if len(children) == 0 { - return nil - } - names := make([]string, 0, len(children)) - for name := range children { - names = append(names, name) - } - sort.Strings(names) - entries := make([]fs.DirEntry, 0, len(names)) - for _, name := range names { - entries = append(entries, children[name]) - } - return entries + return fsys, nil } func cleanPromptPath(name string) (string, error) { @@ -160,81 +106,3 @@ func cleanPromptPath(name string) (string, error) { } return cleaned, nil } - -func cleanPromptFSPath(name string) (string, error) { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return "", fmt.Errorf("path must not be empty") - } - cleaned := path.Clean(strings.TrimPrefix(trimmed, "/")) - if cleaned == "." { - return cleaned, nil - } - if !fs.ValidPath(cleaned) { - return "", fmt.Errorf("invalid path %q", name) - } - return cleaned, nil -} - -type promptFile struct { - reader *bytes.Reader - info promptFileInfo -} - -func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil } -func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) } -func (f *promptFile) Close() error { return nil } - -type promptDir struct { - name string - offset int - entries []fs.DirEntry -} - -func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil } -func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF } -func (d *promptDir) Close() error { return nil } -func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) { - if d.offset >= len(d.entries) { - return nil, io.EOF - } - end := len(d.entries) - if n > 0 && d.offset+n < end { - end = d.offset + n - } - out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) - d.offset = end - return out, nil -} - -type promptDirEntry struct { - name string - dir bool - size int64 -} - -func (e promptDirEntry) Name() string { return e.name } -func (e promptDirEntry) IsDir() bool { return e.dir } -func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() } -func (e promptDirEntry) Info() (fs.FileInfo, error) { - return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil -} -func (e promptDirEntry) fileInfoMode() fs.FileMode { - if e.dir { - return fs.ModeDir | 0o555 - } - return 0o444 -} - -type promptFileInfo struct { - name string - dir bool - size int64 -} - -func (i promptFileInfo) Name() string { return i.name } -func (i promptFileInfo) Size() int64 { return i.size } -func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() } -func (i promptFileInfo) ModTime() time.Time { return time.Time{} } -func (i promptFileInfo) IsDir() bool { return i.dir } -func (i promptFileInfo) Sys() any { return nil } diff --git a/internal/framework/promptfs/prompt_fs_test.go b/internal/framework/promptfs/prompt_fs_test.go index 38577a9..845e20a 100644 --- a/internal/framework/promptfs/prompt_fs_test.go +++ b/internal/framework/promptfs/prompt_fs_test.go @@ -103,6 +103,70 @@ func TestModulePromptFSRejectsNestedSharedFileName(t *testing.T) { } } +func TestModulePromptFSRejectsDuplicateNormalizedDestinations(t *testing.T) { + for _, test := range []struct { + name string + call func() error + want string + }{ + { + name: "module", + call: func() error { + _, err := ModulePromptFS("module.test", fstest.MapFS{ + "assets/prompts/first.md": {Data: []byte("first")}, + "assets/prompts/second.md": {Data: []byte("second")}, + }, []ModulePromptFile{ + {Name: "task.md", Path: "assets/prompts/first.md"}, + {Name: " task.md ", Path: "assets/prompts/second.md"}, + }) + return err + }, + want: `duplicate module prompt destination "assets/prompts/module.test/task.md"`, + }, + { + name: "sharedassets", + call: func() error { + shared := fstest.MapFS{ + "shared/first.md": {Data: []byte("first")}, + "shared/second.md": {Data: []byte("second")}, + } + _, err := ModulePromptFS("module.test", fstest.MapFS{}, nil, + SharedPromptFile{Name: "task.md", FS: shared, Path: "shared/first.md"}, + SharedPromptFile{Name: " task.md ", FS: shared, Path: "shared/second.md"}, + ) + return err + }, + want: `duplicate sharedassets prompt destination "assets/prompts/module.test/sharedassets/task.md"`, + }, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err == nil || err.Error() != test.want { + t.Fatalf("ModulePromptFS() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestModulePromptFSAllowsMatchingModuleAndSharedBasenames(t *testing.T) { + fsys, err := ModulePromptFS("module.test", fstest.MapFS{ + "assets/prompts/system.md": {Data: []byte("module")}, + }, []ModulePromptFile{{Name: "system.md", Path: "assets/prompts/system.md"}}, + SharedPromptFile{Name: "system.md", FS: fstest.MapFS{"shared/system.md": {Data: []byte("shared")}}, Path: "shared/system.md"}, + ) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{ + "assets/prompts/module.test/system.md": "module", + "assets/prompts/module.test/sharedassets/system.md": "shared", + } { + data, err := fs.ReadFile(fsys, name) + if err != nil || string(data) != want { + t.Fatalf("ReadFile(%q) = %q, %v; want %q", name, data, err, want) + } + } +} + func TestModulePromptFSRejectsInvalidModuleDir(t *testing.T) { _, err := ModulePromptFS(".", fstest.MapFS{}, nil) if err == nil || !strings.Contains(err.Error(), "module prompt directory") { diff --git a/internal/framework/readonlyfs/readonly_fs.go b/internal/framework/readonlyfs/readonly_fs.go new file mode 100644 index 0000000..052bb37 --- /dev/null +++ b/internal/framework/readonlyfs/readonly_fs.go @@ -0,0 +1,193 @@ +// Package readonlyfs provides an immutable filesystem backed by owned bytes. +package readonlyfs + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "path" + "sort" + "time" +) + +// FS is an immutable filesystem constructed from a map of file contents. +type FS struct { + files map[string][]byte + dirs map[string][]fs.DirEntry +} + +var _ fs.FS = (*FS)(nil) +var _ fs.ReadFileFS = (*FS)(nil) +var _ fs.ReadDirFS = (*FS)(nil) + +// New constructs an immutable filesystem. File names must be valid io/fs +// paths and the root path may not be used as a file. +func New(files map[string][]byte) (*FS, error) { + owned := make(map[string][]byte, len(files)) + directories := map[string]struct{}{".": {}} + for name, data := range files { + if name == "." || !fs.ValidPath(name) { + return nil, fmt.Errorf("invalid file path %q", name) + } + owned[name] = append([]byte(nil), data...) + for directory := path.Dir(name); directory != "."; directory = path.Dir(directory) { + directories[directory] = struct{}{} + } + } + for directory := range directories { + if _, exists := owned[directory]; exists { + return nil, fmt.Errorf("file path %q conflicts with an inferred directory", directory) + } + } + + dirs := make(map[string][]fs.DirEntry, len(directories)) + for directory := range directories { + dirs[directory] = make([]fs.DirEntry, 0) + } + for directory := range directories { + if directory == "." { + continue + } + parent := path.Dir(directory) + dirs[parent] = append(dirs[parent], dirEntry{name: path.Base(directory), dir: true}) + } + for name, data := range owned { + parent := path.Dir(name) + dirs[parent] = append(dirs[parent], dirEntry{name: path.Base(name), size: int64(len(data))}) + } + for directory := range dirs { + sort.Slice(dirs[directory], func(left, right int) bool { + return dirs[directory][left].Name() < dirs[directory][right].Name() + }) + } + return &FS{files: owned, dirs: dirs}, nil +} + +func (f *FS) Open(name string) (fs.File, error) { + if err := validOpenPath(name); err != nil { + return nil, pathError("open", name, err) + } + if data, ok := f.files[name]; ok { + return &file{reader: bytes.NewReader(data), info: fileInfo{name: path.Base(name), size: int64(len(data))}}, nil + } + if entries, ok := f.dirs[name]; ok { + return &dir{name: path.Base(name), entries: entries}, nil + } + return nil, pathError("open", name, fs.ErrNotExist) +} + +func (f *FS) ReadFile(name string) ([]byte, error) { + if err := validOpenPath(name); err != nil { + return nil, pathError("readfile", name, err) + } + data, ok := f.files[name] + if !ok { + if _, isDirectory := f.dirs[name]; isDirectory { + return nil, pathError("readfile", name, errIsDirectory) + } + return nil, pathError("readfile", name, fs.ErrNotExist) + } + return append([]byte(nil), data...), nil +} + +func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) { + if err := validOpenPath(name); err != nil { + return nil, pathError("readdir", name, err) + } + entries, ok := f.dirs[name] + if !ok { + if _, isFile := f.files[name]; isFile { + return nil, pathError("readdir", name, errNotDirectory) + } + return nil, pathError("readdir", name, fs.ErrNotExist) + } + return append([]fs.DirEntry(nil), entries...), nil +} + +func validOpenPath(name string) error { + if name == "." || fs.ValidPath(name) { + return nil + } + return fs.ErrInvalid +} + +func pathError(operation, name string, err error) error { + return &fs.PathError{Op: operation, Path: name, Err: err} +} + +var ( + errIsDirectory = errors.New("is a directory") + errNotDirectory = errors.New("not a directory") +) + +type file struct { + reader *bytes.Reader + info fileInfo +} + +func (f *file) Stat() (fs.FileInfo, error) { return f.info, nil } +func (f *file) Read(data []byte) (int, error) { + return f.reader.Read(data) +} +func (f *file) Close() error { return nil } + +type dir struct { + name string + offset int + entries []fs.DirEntry +} + +func (d *dir) Stat() (fs.FileInfo, error) { return fileInfo{name: d.name, dir: true}, nil } +func (d *dir) Read([]byte) (int, error) { return 0, errIsDirectory } +func (d *dir) Close() error { return nil } +func (d *dir) ReadDir(count int) ([]fs.DirEntry, error) { + if count <= 0 { + entries := append([]fs.DirEntry(nil), d.entries[d.offset:]...) + d.offset = len(d.entries) + return entries, nil + } + if d.offset >= len(d.entries) { + return nil, io.EOF + } + end := len(d.entries) + if d.offset+count < end { + end = d.offset + count + } + entries := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) + d.offset = end + return entries, nil +} + +type dirEntry struct { + name string + dir bool + size int64 +} + +func (e dirEntry) Name() string { return e.name } +func (e dirEntry) IsDir() bool { return e.dir } +func (e dirEntry) Type() fs.FileMode { return e.mode().Type() } +func (e dirEntry) Info() (fs.FileInfo, error) { + return fileInfo{name: e.name, dir: e.dir, size: e.size}, nil +} +func (e dirEntry) mode() fs.FileMode { + if e.dir { + return fs.ModeDir | 0o555 + } + return 0o444 +} + +type fileInfo struct { + name string + dir bool + size int64 +} + +func (i fileInfo) Name() string { return i.name } +func (i fileInfo) Size() int64 { return i.size } +func (i fileInfo) Mode() fs.FileMode { return dirEntry{dir: i.dir}.mode() } +func (i fileInfo) ModTime() time.Time { return time.Time{} } +func (i fileInfo) IsDir() bool { return i.dir } +func (i fileInfo) Sys() any { return nil } diff --git a/internal/framework/readonlyfs/readonly_fs_test.go b/internal/framework/readonlyfs/readonly_fs_test.go new file mode 100644 index 0000000..7ebf475 --- /dev/null +++ b/internal/framework/readonlyfs/readonly_fs_test.go @@ -0,0 +1,153 @@ +package readonlyfs + +import ( + "errors" + "io" + "io/fs" + "testing" + "testing/fstest" +) + +func TestNewPassesFSTest(t *testing.T) { + for _, test := range []struct { + name string + files map[string][]byte + paths []string + }{ + {name: "empty", files: map[string][]byte{}, paths: nil}, + {name: "single file", files: map[string][]byte{"message.txt": []byte("hello")}, paths: []string{"message.txt"}}, + {name: "nested tree", files: map[string][]byte{"a/one.txt": []byte("one"), "a/b/two.txt": []byte("two")}, paths: []string{"a/one.txt", "a/b/two.txt"}}, + } { + t.Run(test.name, func(t *testing.T) { + fsys, err := New(test.files) + if err != nil { + t.Fatal(err) + } + if err := fstest.TestFS(fsys, test.paths...); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestNewOwnsConstructionAndReadBytes(t *testing.T) { + input := []byte("value") + fsys, err := New(map[string][]byte{"value.txt": input}) + if err != nil { + t.Fatal(err) + } + input[0] = 'x' + first, err := fsys.ReadFile("value.txt") + if err != nil { + t.Fatal(err) + } + first[0] = 'y' + second, err := fsys.ReadFile("value.txt") + if err != nil { + t.Fatal(err) + } + if string(second) != "value" { + t.Fatalf("ReadFile() = %q, want owned bytes", second) + } +} + +func TestFilesystemStandardBehavior(t *testing.T) { + fsys, err := New(map[string][]byte{ + "z.txt": []byte("z"), + "nested/a.md": []byte("abc"), + "nested/b.md": []byte("b"), + }) + if err != nil { + t.Fatal(err) + } + entries, err := fsys.ReadDir("nested") + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 || entries[0].Name() != "a.md" || entries[1].Name() != "b.md" { + t.Fatalf("ReadDir(nested) = %#v, want sorted entries", entries) + } + info, err := entries[0].Info() + if err != nil || info.Mode().Perm() != 0o444 || info.Size() != 3 { + t.Fatalf("file info = %#v, %v", info, err) + } + root, err := fsys.Open(".") + if err != nil { + t.Fatal(err) + } + rootInfo, err := root.Stat() + if err != nil || !rootInfo.IsDir() || rootInfo.Mode() != fs.ModeDir|0o555 { + t.Fatalf("root info = %#v, %v", rootInfo, err) + } + directory := root.(fs.ReadDirFile) + first, err := directory.ReadDir(1) + if err != nil || len(first) != 1 || first[0].Name() != "nested" { + t.Fatalf("first directory page = %#v, %v", first, err) + } + second, err := directory.ReadDir(1) + if err != nil || len(second) != 1 || second[0].Name() != "z.txt" { + t.Fatalf("second directory page = %#v, %v", second, err) + } + if _, err := directory.ReadDir(1); !errors.Is(err, io.EOF) { + t.Fatalf("final directory page error = %v, want EOF", err) + } + secondRoot, err := fsys.Open(".") + if err != nil { + t.Fatal(err) + } + secondDirectory := secondRoot.(fs.ReadDirFile) + firstAgain, err := secondDirectory.ReadDir(1) + if err != nil || len(firstAgain) != 1 || firstAgain[0].Name() != "nested" { + t.Fatalf("independent directory handle page = %#v, %v", firstAgain, err) + } + + left, err := fsys.Open("nested/a.md") + if err != nil { + t.Fatal(err) + } + right, err := fsys.Open("nested/a.md") + if err != nil { + t.Fatal(err) + } + leftByte := make([]byte, 1) + rightByte := make([]byte, 1) + if _, err := left.Read(leftByte); err != nil { + t.Fatal(err) + } + if _, err := right.Read(rightByte); err != nil { + t.Fatal(err) + } + if string(leftByte) != "a" || string(rightByte) != "a" { + t.Fatalf("independent file handles read %q and %q", leftByte, rightByte) + } + for _, test := range []struct { + name string + op func() error + want error + }{ + {name: "invalid open path", op: func() error { _, err := fsys.Open("../value"); return err }, want: fs.ErrInvalid}, + {name: "missing file", op: func() error { _, err := fsys.Open("missing"); return err }, want: fs.ErrNotExist}, + {name: "read directory", op: func() error { _, err := fsys.ReadFile("nested"); return err }, want: errIsDirectory}, + {name: "read directory from file", op: func() error { _, err := fsys.ReadDir("z.txt"); return err }, want: errNotDirectory}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.op(); !errors.Is(err, test.want) { + t.Fatalf("operation error = %v, want %v", err, test.want) + } + }) + } +} + +func TestNewRejectsInvalidAndConflictingPaths(t *testing.T) { + for _, files := range []map[string][]byte{ + {".": []byte("root")}, + {"": []byte("empty")}, + {"../outside": []byte("outside")}, + {"nested//file": []byte("invalid")}, + {"file": []byte("file"), "file/nested": []byte("nested")}, + } { + if _, err := New(files); err == nil { + t.Fatalf("New(%#v) error = nil, want rejection", files) + } + } +}