Contain prompt content paths within source roots
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
254
engine_test.go
254
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
98
internal/promptdef/content_source.go
Normal file
98
internal/promptdef/content_source.go
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user