Share immutable in-memory filesystems
This commit is contained in:
@@ -1,17 +1,15 @@
|
|||||||
package llm
|
package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"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) {
|
func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
||||||
out := assetMapFS{}
|
out := make(map[string][]byte)
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
|
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
@@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out[rel] = append([]byte(nil), data...)
|
out[rel] = data
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err)
|
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) {
|
func cleanAssetRoot(root string) (string, error) {
|
||||||
@@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) {
|
|||||||
}
|
}
|
||||||
return cleaned, nil
|
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 }
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package promptfs
|
package promptfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModulePromptFile maps a module-owned embedded prompt file into the registered
|
// 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 {
|
if moduleFS == nil {
|
||||||
return nil, fmt.Errorf("module prompt filesystem must not be 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 {
|
for _, file := range files {
|
||||||
name, err := cleanPromptPath(file.Name)
|
name, err := cleanPromptPath(file.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,6 +45,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
|||||||
if strings.Contains(name, "/") {
|
if strings.Contains(name, "/") {
|
||||||
return nil, fmt.Errorf("module prompt file name %q must not contain path separators", file.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)
|
filePath, err := cleanPromptPath(file.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("module prompt file path %q: %w", file.Path, err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read module prompt asset %s: %w", filePath, err)
|
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 {
|
for _, file := range sharedFiles {
|
||||||
name, err := cleanPromptPath(file.Name)
|
name, err := cleanPromptPath(file.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,6 +70,11 @@ func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
|||||||
if strings.Contains(name, "/") {
|
if strings.Contains(name, "/") {
|
||||||
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.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 {
|
if file.FS == nil {
|
||||||
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err)
|
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
|
fsys, err := readonlyfs.New(assets)
|
||||||
}
|
|
||||||
|
|
||||||
type promptMapFS map[string][]byte
|
|
||||||
|
|
||||||
func (m promptMapFS) Open(name string) (fs.File, error) {
|
|
||||||
cleaned, err := cleanPromptFSPath(name)
|
|
||||||
if err != nil {
|
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 fsys, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanPromptPath(name string) (string, error) {
|
func cleanPromptPath(name string) (string, error) {
|
||||||
@@ -160,81 +106,3 @@ func cleanPromptPath(name string) (string, error) {
|
|||||||
}
|
}
|
||||||
return cleaned, nil
|
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 }
|
|
||||||
|
|||||||
@@ -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) {
|
func TestModulePromptFSRejectsInvalidModuleDir(t *testing.T) {
|
||||||
_, err := ModulePromptFS(".", fstest.MapFS{}, nil)
|
_, err := ModulePromptFS(".", fstest.MapFS{}, nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "module prompt directory") {
|
if err == nil || !strings.Contains(err.Error(), "module prompt directory") {
|
||||||
|
|||||||
193
internal/framework/readonlyfs/readonly_fs.go
Normal file
193
internal/framework/readonlyfs/readonly_fs.go
Normal file
@@ -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 }
|
||||||
153
internal/framework/readonlyfs/readonly_fs_test.go
Normal file
153
internal/framework/readonlyfs/readonly_fs_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user