Contain prompt content paths within source roots

This commit is contained in:
2026-08-11 22:03:32 +00:00
parent 58ac3ce298
commit a718762da1
9 changed files with 358 additions and 163 deletions

View File

@@ -86,9 +86,16 @@ Each message has a non-empty `role` and exactly one of:
- `content`, containing an inline Go template; or
- `content_file`, naming a file whose contents are the Go template.
For directory and `fs.FS` prompt sources, `content_file` resolves relative to
the prompt file and remains within the source root. `WithPromptFile` also
resolves it relative to that file.
`content_file` must be a relative path. It resolves from the directory that
contains the prompt file and must remain within the configured prompt source
root; parent components are allowed only when the resolved target remains
inside that root. Absolute paths and paths that escape the root are rejected.
Operating-system directory and single-file sources also reject symlink targets
outside the root, while injected `fs.FS` sources apply containment in that
filesystem's relative path namespace. For `WithPromptFile`, the source root is
the directory containing the selected prompt file. Promptkit uses the parsed
path text exactly after checking separately that it is not blank, so leading
and trailing whitespace can name real filesystem entries.
Request variables are the template data, so a variable named `audience` is
referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body

View File

@@ -14,7 +14,13 @@ validation modes, built-in catalog, and source precedence.
`internal/promptdef` discovers YAML deterministically, decodes and validates
definitions, selects an ID and optional version, and resolves file-backed
message content within the selected operating-system or `fs.FS` source.
message content through an explicit source-root abstraction. Operating-system
sources enforce containment against canonical roots and targets so symlinks
cannot escape. Injected `fs.FS` sources enforce containment in their clean
relative path namespace. A single-file source uses the selected prompt file's
containing directory as its root. Every content path must be relative and is
opened from its exact parsed text after a separate blank check; contained
parent components and whitespace-bearing names remain valid.
Exact prompt inspection performs one point-in-time lookup through that same
repository and validates referenced message content before returning declared

View File

@@ -219,7 +219,7 @@ func WithPromptFile(path string) Option {
if err != nil {
return err
}
options.promptDefs = promptdef.NewFSRepository(fsys, root)
options.promptDefs = promptdef.NewFileRepository(fsys, root, filepath.Dir(path))
options.promptSource = true
return nil
})

View File

@@ -1538,127 +1538,189 @@ unexpected: true
}
}
func TestPrepareWorksWithPromptFSAndRelativeContentFile(t *testing.T) {
promptFS := fstest.MapFS{
"assets/prompts/fs-summary.yaml": &fstest.MapFile{Data: []byte(`
id: fs.summary
func TestPromptContentFilePathsAcrossSources(t *testing.T) {
type pathCase struct {
name string
directoryPath string
singleFilePath string
directoryTarget string
singleTarget string
absolute bool
symlink bool
wantErr string
}
tests := []pathCase{
{name: "ordinary sibling", directoryPath: "sibling.tmpl", singleFilePath: "sibling.tmpl", directoryTarget: "nested/sibling.tmpl", singleTarget: "sibling.tmpl"},
{name: "parent remains inside root", directoryPath: "../shared.tmpl", singleFilePath: "nested/../shared.tmpl", directoryTarget: "shared.tmpl", singleTarget: "shared.tmpl"},
{name: "parent escapes root", directoryPath: "../../outside.tmpl", singleFilePath: "../outside.tmpl", wantErr: "escapes source root"},
{name: "absolute path", absolute: true, wantErr: "must be relative"},
{name: "symlink escapes root", directoryPath: "escape.tmpl", singleFilePath: "escape.tmpl", symlink: true, wantErr: "escapes source root"},
{name: "leading whitespace preserved", directoryPath: " body.tmpl", singleFilePath: " body.tmpl", directoryTarget: "nested/ body.tmpl", singleTarget: " body.tmpl"},
{name: "trailing whitespace preserved", directoryPath: "body.tmpl ", singleFilePath: "body.tmpl ", directoryTarget: "nested/body.tmpl ", singleTarget: "body.tmpl "},
}
sources := []struct {
name string
singleFile bool
injectedFS bool
supportsSymlink bool
}{
{name: "operating system directory", supportsSymlink: true},
{name: "injected filesystem", injectedFS: true},
{name: "single file", singleFile: true, supportsSymlink: true},
}
profile := promptkit.Profile{ID: "content-profile", Endpoint: "http://example.test/v1", Model: "content-model"}
const promptID = "content-path-prompt"
const body = "Exact content body."
for _, source := range sources {
for _, tc := range tests {
t.Run(source.name+"/"+tc.name, func(t *testing.T) {
if tc.symlink && !source.supportsSymlink {
t.Skip("source does not expose operating-system symlink semantics")
}
workspace := t.TempDir()
sourceRoot := filepath.Join(workspace, "prompts")
outsidePath := filepath.Join(workspace, "outside.tmpl")
if err := os.MkdirAll(filepath.Join(sourceRoot, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outsidePath, []byte("Outside root."), 0o644); err != nil {
t.Fatal(err)
}
contentFile := tc.directoryPath
target := tc.directoryTarget
promptRelativePath := "nested/prompt.yaml"
if source.singleFile {
contentFile = tc.singleFilePath
target = tc.singleTarget
promptRelativePath = "prompt.yaml"
}
if tc.absolute {
contentFile = outsidePath
if source.injectedFS {
contentFile = "/outside.tmpl"
}
}
promptDocument := []byte(fmt.Sprintf(`
id: %s
version: "1.0.0"
default_profile: contract-fast
inputs:
- name: transcript
required: true
default_profile: content-profile
messages:
- role: user
content_file: ./messages/summary.tmpl
content_file: %q
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
"assets/prompts/messages/summary.tmpl": &fstest.MapFile{Data: []byte(`Summarize {{input "transcript"}} from prompt fs.`)},
}
`, promptID, contentFile))
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: t.TempDir(),
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
var engine *promptkit.Engine
var err error
if source.injectedFS {
promptFS := fstest.MapFS{
"prompts/nested/prompt.yaml": &fstest.MapFile{Data: promptDocument},
"outside.tmpl": &fstest.MapFile{Data: []byte("Outside root.")},
}
if target != "" {
promptFS["prompts/"+filepath.ToSlash(target)] = &fstest.MapFile{Data: []byte(body)}
}
engine, err = promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFS(promptFS, "prompts"), promptkit.WithProfiles(profile))
} else {
promptPath := filepath.Join(sourceRoot, filepath.FromSlash(promptRelativePath))
if err := os.WriteFile(promptPath, promptDocument, 0o644); err != nil {
t.Fatal(err)
}
if target != "" {
targetPath := filepath.Join(sourceRoot, filepath.FromSlash(target))
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(targetPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if tc.symlink {
linkPath := filepath.Join(filepath.Dir(promptPath), "escape.tmpl")
if err := os.Symlink(outsidePath, linkPath); err != nil {
t.Skipf("symlinks are not supported: %v", err)
}
}
if source.singleFile {
engine, err = promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFile(promptPath), promptkit.WithProfiles(profile))
} else {
engine, err = promptkit.NewEngine(promptkit.Config{PromptDir: sourceRoot}, promptkit.WithProfiles(profile))
}
}
if err != nil {
t.Fatalf("construct engine: %v", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: "fs.summary",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline("Rin opens the gate."),
},
})
if err != nil {
t.Fatalf("expected prepare to succeed, got %v", err)
}
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "prompt fs") {
t.Fatalf("expected content_file body from prompt fs, got %+v", prepared.Messages)
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: promptID})
if tc.wantErr != "" {
if !errors.Is(err, promptkit.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("prepare: %v", err)
}
if len(prepared.Messages) != 1 || prepared.Messages[0].Content != body {
t.Fatalf("expected exact content body, got %+v", prepared.Messages)
}
})
}
}
}
func TestPrepareWithPromptFSRejectsEscapedContentFile(t *testing.T) {
func TestPromptContentFileFailuresPreservePublicError(t *testing.T) {
promptFS := fstest.MapFS{
"assets/prompts/fs-escape.yaml": &fstest.MapFile{Data: []byte(`
id: fs.escape
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: rejected-content-path
version: "1.0.0"
default_profile: contract-fast
messages:
- role: user
content_file: ../outside.tmpl
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
"assets/outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
"outside.tmpl": &fstest.MapFile{Data: []byte("Outside root.")},
}
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: t.TempDir(),
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
engine, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFS(promptFS, "prompts"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
t.Fatalf("construct engine: %v", err)
}
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "fs.escape"})
if !errors.Is(err, promptkit.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
operations := []struct {
name string
run func() error
}{
{name: "inspect prompt", run: func() error {
_, err := engine.InspectPrompt(context.Background(), "rejected-content-path", "")
return err
}},
{name: "prepare execution", run: func() error {
_, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "rejected-content-path"})
return err
}},
{name: "run", run: func() error {
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "rejected-content-path"})
return err
}},
}
}
func TestPrepareWorksWithPromptFile(t *testing.T) {
promptDir := t.TempDir()
promptPath := filepath.Join(promptDir, "single.yaml")
if err := os.WriteFile(promptPath, []byte(`
id: single.file.prompt
version: "1.0.0"
default_profile: contract-fast
inputs:
- name: transcript
required: true
messages:
- role: user
content_file: ./single.tmpl
output:
format: text
validation_mode: none
repair_attempts: 0
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(promptDir, "single.tmpl"), []byte(`Summarize {{input "transcript"}} from file.`), 0o644); err != nil {
t.Fatal(err)
}
engine, err := promptkit.NewEngine(promptkit.Config{
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFile(promptPath))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: "single.file.prompt",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline("Rin opens the gate."),
},
})
if err != nil {
t.Fatalf("expected prepare to succeed, got %v", err)
}
if prepared.PromptID != "single.file.prompt" {
t.Fatalf("unexpected prompt id: %q", prepared.PromptID)
}
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "from file") {
t.Fatalf("expected content_file body from prompt file, got %+v", prepared.Messages)
for _, operation := range operations {
t.Run(operation.name, func(t *testing.T) {
if err := operation.run(); !errors.Is(err, promptkit.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
})
}
}

View File

@@ -102,22 +102,21 @@ func DisplayPath(root string, name string) string {
// ResolveFSPath resolves userPath from baseDir and keeps it inside root.
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
cleanRoot := CleanFSRoot(root)
cleanBase := path.Clean(strings.TrimSpace(baseDir))
if cleanBase == "" {
cleanBase := path.Clean(baseDir)
if strings.TrimSpace(baseDir) == "" {
cleanBase = cleanRoot
}
if !containsFSPath(cleanRoot, cleanBase) {
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
}
cleanUserPath := strings.TrimSpace(userPath)
if cleanUserPath == "" {
if strings.TrimSpace(userPath) == "" {
return "", "", fmt.Errorf("path is required")
}
cleanUserPath = path.Clean(cleanUserPath)
if path.IsAbs(cleanUserPath) {
if path.IsAbs(userPath) {
return "", "", fmt.Errorf("path %q must be relative", userPath)
}
cleanUserPath := path.Clean(userPath)
resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
if !containsFSPath(cleanRoot, resolved) {

View File

@@ -161,6 +161,22 @@ func TestResolveFSPath(t *testing.T) {
wantPath: "prompts/shared/user.tmpl",
wantDisplay: "shared/user.tmpl",
},
{
name: "leading whitespace preserved",
root: "prompts",
baseDir: "prompts/nested",
userPath: " user.tmpl",
wantPath: "prompts/nested/ user.tmpl",
wantDisplay: "nested/ user.tmpl",
},
{
name: "trailing whitespace preserved",
root: "prompts",
baseDir: "prompts/nested",
userPath: "user.tmpl ",
wantPath: "prompts/nested/user.tmpl ",
wantDisplay: "nested/user.tmpl ",
},
{
name: "escape rejected",
root: "prompts",

View File

@@ -0,0 +1,98 @@
package promptdef
import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
)
type contentSourceRoot interface {
readContentFile(sourcePath string, contentFile string) (string, string, error)
}
type osContentSourceRoot struct {
root string
sourcePathsRelative bool
}
func (r osContentSourceRoot) readContentFile(sourcePath string, contentFile string) (string, string, error) {
if strings.TrimSpace(contentFile) == "" {
return "", "", fmt.Errorf("path is required")
}
if filepath.IsAbs(contentFile) {
return "", "", fmt.Errorf("path %q must be relative", contentFile)
}
root, err := filepath.Abs(r.root)
if err != nil {
return "", "", fmt.Errorf("resolve source root %q: %w", r.root, err)
}
canonicalRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return "", "", fmt.Errorf("resolve source root %q: %w", r.root, err)
}
promptPath := sourcePath
if r.sourcePathsRelative && !filepath.IsAbs(promptPath) {
promptPath = filepath.Join(root, filepath.FromSlash(promptPath))
} else {
promptPath, err = filepath.Abs(promptPath)
if err != nil {
return "", "", fmt.Errorf("resolve prompt source %q: %w", sourcePath, err)
}
}
resolvedPath := filepath.Clean(filepath.Join(filepath.Dir(promptPath), contentFile))
if !containsOSPath(root, resolvedPath) {
return "", "", fmt.Errorf("path %q escapes source root %q", contentFile, r.root)
}
canonicalPath, err := filepath.EvalSymlinks(resolvedPath)
if err != nil {
return "", "", err
}
if !containsOSPath(canonicalRoot, canonicalPath) {
return "", "", fmt.Errorf("path %q escapes source root %q", contentFile, r.root)
}
body, err := os.ReadFile(canonicalPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
}
type fsContentSourceRoot struct {
fsys fs.FS
root string
}
func (r fsContentSourceRoot) readContentFile(sourcePath string, contentFile string) (string, string, error) {
root := filecatalog.CleanFSRoot(r.root)
cleanSourcePath := path.Clean(sourcePath)
if cleanSourcePath == root {
root = path.Dir(root)
}
resolvedPath, _, err := filecatalog.ResolveFSPath(root, path.Dir(cleanSourcePath), contentFile)
if err != nil {
return "", "", err
}
body, err := fs.ReadFile(r.fsys, resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
}
func containsOSPath(root string, name string) bool {
relative, err := filepath.Rel(root, name)
if err != nil || filepath.IsAbs(relative) {
return false
}
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}

View File

@@ -23,12 +23,14 @@ var (
)
type filesystemRepository struct {
dir string
dir string
sourceRoot contentSourceRoot
}
type fsRepository struct {
fsys fs.FS
root string
fsys fs.FS
root string
sourceRoot contentSourceRoot
}
type promptDefinitionFile struct {
@@ -69,11 +71,30 @@ type promptOutputContractFile struct {
}
func NewFilesystemRepository(dir string) Repository {
return &filesystemRepository{dir: dir}
return &filesystemRepository{
dir: dir,
sourceRoot: osContentSourceRoot{root: dir},
}
}
func NewFSRepository(fsys fs.FS, root string) Repository {
return &fsRepository{fsys: fsys, root: root}
return &fsRepository{
fsys: fsys,
root: root,
sourceRoot: fsContentSourceRoot{fsys: fsys, root: root},
}
}
// NewFileRepository constructs a repository for one operating-system prompt file.
func NewFileRepository(fsys fs.FS, file string, sourceDir string) Repository {
return &fsRepository{
fsys: fsys,
root: file,
sourceRoot: osContentSourceRoot{
root: sourceDir,
sourcePathsRelative: true,
},
}
}
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
@@ -105,7 +126,7 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
continue
}
def, err := normalizePromptDefinition(raw, fullPath)
def, err := normalizePromptDefinition(raw, r.sourceRoot, fullPath)
if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
@@ -144,7 +165,7 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
}
func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
return loadPromptDefinition(ctx, r.fsys, r.root, id, version)
return loadPromptDefinition(ctx, r.fsys, r.root, r.sourceRoot, id, version)
}
type promptDefinitionMatch struct {
@@ -181,7 +202,7 @@ func promptDefinitionFileHasID(path string, id string) bool {
return strings.TrimSpace(raw.ID) == id
}
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id string, version string) (*domain.PromptDefinition, error) {
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, sourceRoot contentSourceRoot, id string, version string) (*domain.PromptDefinition, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
}
@@ -193,12 +214,6 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
}
cleanRoot := filecatalog.CleanFSRoot(root)
rootInfo, err := fs.Stat(fsys, cleanRoot)
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
}
var matches []promptDefinitionMatch
for _, fullPath := range files {
select {
@@ -225,7 +240,7 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
continue
}
def, err := normalizePromptDefinitionFromFS(raw, fsys, root, fullPath, rootInfo.IsDir())
def, err := normalizePromptDefinition(raw, sourceRoot, fullPath)
if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
@@ -283,46 +298,9 @@ func promptDefinitionDataHasID(data []byte, id string) bool {
return strings.TrimSpace(raw.ID) == id
}
func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) {
promptDir := filepath.Dir(sourcePath)
func normalizePromptDefinition(raw *promptDefinitionFile, sourceRoot contentSourceRoot, sourcePath string) (*domain.PromptDefinition, error) {
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
resolvedPath := strings.TrimSpace(contentFile)
if !filepath.IsAbs(resolvedPath) {
resolvedPath = filepath.Join(promptDir, resolvedPath)
}
resolvedPath = filepath.Clean(resolvedPath)
body, err := os.ReadFile(resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
})
}
func normalizePromptDefinitionFromFS(raw *promptDefinitionFile, fsys fs.FS, root string, sourcePath string, rootIsDir bool) (*domain.PromptDefinition, error) {
promptDir := path.Dir(sourcePath)
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
var resolvedPath string
if rootIsDir {
var err error
resolvedPath, _, err = filecatalog.ResolveFSPath(root, promptDir, contentFile)
if err != nil {
return "", "", err
}
} else {
resolvedPath = strings.TrimSpace(contentFile)
if !path.IsAbs(resolvedPath) {
resolvedPath = path.Join(promptDir, resolvedPath)
}
resolvedPath = strings.TrimPrefix(path.Clean(resolvedPath), "/")
}
body, err := fs.ReadFile(fsys, resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
return sourceRoot.readContentFile(sourcePath, contentFile)
})
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
@@ -396,7 +397,7 @@ output:
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
fsys := &recordingFS{FS: fstest.MapFS{
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: fs-escaped-prompt
version: "1.0.0"
@@ -409,7 +410,8 @@ output:
repair_attempts: 0
`)},
"outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
}, "prompts")
}}
repo := NewFSRepository(fsys, "prompts")
_, err := repo.GetPromptDefinition(context.Background(), "fs-escaped-prompt", "")
if !errors.Is(err, ErrInvalidPromptDefinition) {
@@ -418,10 +420,37 @@ output:
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
}
if fsys.wasOpened("outside.tmpl") {
t.Fatal("rejected content path opened the outside file")
}
})
}
}
type recordingFS struct {
fs.FS
mu sync.Mutex
opened []string
}
func (f *recordingFS) Open(name string) (fs.File, error) {
f.mu.Lock()
f.opened = append(f.opened, name)
f.mu.Unlock()
return f.FS.Open(name)
}
func (f *recordingFS) wasOpened(name string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, opened := range f.opened {
if opened == name {
return true
}
}
return false
}
func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{
"one.yaml": &fstest.MapFile{Data: []byte(`