From a718762da1697d6fb2a0ce7131a0faa8e2c655a1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 11 Aug 2026 22:03:32 +0000 Subject: [PATCH] Contain prompt content paths within source roots --- docs/formats.md | 13 +- docs/internal/sources.md | 8 +- engine.go | 2 +- engine_test.go | 254 ++++++++++++-------- internal/filecatalog/catalog.go | 11 +- internal/filecatalog/catalog_test.go | 16 ++ internal/promptdef/content_source.go | 98 ++++++++ internal/promptdef/filesystem_repository.go | 86 +++---- internal/promptdef/repository_test.go | 33 ++- 9 files changed, 358 insertions(+), 163 deletions(-) create mode 100644 internal/promptdef/content_source.go diff --git a/docs/formats.md b/docs/formats.md index 61c4bbd..dd6746a 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -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 diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 1b8a049..ff2da90 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -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 diff --git a/engine.go b/engine.go index 6ee959f..b1fb3b5 100644 --- a/engine.go +++ b/engine.go @@ -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 }) diff --git a/engine_test.go b/engine_test.go index 2038929..0618829 100644 --- a/engine_test.go +++ b/engine_test.go @@ -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) + } + }) } } diff --git a/internal/filecatalog/catalog.go b/internal/filecatalog/catalog.go index 904e393..162afdc 100644 --- a/internal/filecatalog/catalog.go +++ b/internal/filecatalog/catalog.go @@ -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) { diff --git a/internal/filecatalog/catalog_test.go b/internal/filecatalog/catalog_test.go index d3c72ce..90f9a6a 100644 --- a/internal/filecatalog/catalog_test.go +++ b/internal/filecatalog/catalog_test.go @@ -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", diff --git a/internal/promptdef/content_source.go b/internal/promptdef/content_source.go new file mode 100644 index 0000000..37fda81 --- /dev/null +++ b/internal/promptdef/content_source.go @@ -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)) +} diff --git a/internal/promptdef/filesystem_repository.go b/internal/promptdef/filesystem_repository.go index df473e3..e0f3a50 100644 --- a/internal/promptdef/filesystem_repository.go +++ b/internal/promptdef/filesystem_repository.go @@ -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) }) } diff --git a/internal/promptdef/repository_test.go b/internal/promptdef/repository_test.go index 1d2c0a5..1ac640f 100644 --- a/internal/promptdef/repository_test.go +++ b/internal/promptdef/repository_test.go @@ -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(`