Preserve directory traversal failure phases

This commit is contained in:
2026-10-25 21:33:36 +00:00
parent 877c497325
commit 8aba43da38
6 changed files with 287 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
# Audit Remediation Implementation Plan # Audit Remediation Implementation Plan
Status: ready for implementation; all stages pending. Status: in progress; completion is recorded per stage below.
Prepared: 2026-10-25. Prepared: 2026-10-25.
Planning baseline: `4a5316b7fc2da57dcced33190cb6499c9a1beb5c`. Planning baseline: `4a5316b7fc2da57dcced33190cb6499c9a1beb5c`.
Audience: a Luna coding agent implementing the stages sequentially. Audience: a Luna coding agent implementing the stages sequentially.
@@ -229,6 +229,12 @@ Finding: **C01**, including its integrated test improvements.
Owners: `internal/engine/filesystem_access.go`, `import_selection.go`, `glob.go`, Owners: `internal/engine/filesystem_access.go`, `import_selection.go`, `glob.go`,
`native_filesystem_test.go`, and `resource_context_test.go`. `native_filesystem_test.go`, and `resource_context_test.go`.
Status: **complete** (C01). Prior completed commits: `2736ddf`, `deb9aa5`,
`ae3408a`, `ea27f17`, `08a8c25`, `877c497`. Validation passed:
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Directory|Glob|Filesystem|Resource' -count=1`,
`env GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`,
and `git diff --check`.
1. Return private directory failure information distinguishing unsuccessful 1. Return private directory failure information distinguishing unsuccessful
initial open from enumeration/close failures. Preserve error unwrapping for initial open from enumeration/close failures. Preserve error unwrapping for
classification; do not expose backing error text. classification; do not expose backing error text.

View File

@@ -141,40 +141,59 @@ func (a *filesystemAccess) open(name string) (fs.File, error) {
return a.root.Open(relative) return a.root.Open(relative)
} }
type directoryReadError struct {
opened bool
err error
}
func (e *directoryReadError) Error() string { return "directory could not be read" }
func (e *directoryReadError) Unwrap() error { return e.err }
func missingDirectoryOpen(err error) bool {
var failure *directoryReadError
return errors.As(err, &failure) && !failure.opened && errors.Is(err, fs.ErrNotExist)
}
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error { func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
file, err := a.open(name) file, err := a.open(name)
if err != nil { if err != nil {
return err return &directoryReadError{err: err}
}
failure := func(err error) error {
if err == nil {
return nil
}
return &directoryReadError{opened: true, err: err}
} }
if reader, ok := file.(fs.ReadDirFile); ok { if reader, ok := file.(fs.ReadDirFile); ok {
for { for {
entries, readErr := reader.ReadDir(globReadBatch) entries, readErr := reader.ReadDir(globReadBatch)
for _, entry := range entries { for _, entry := range entries {
if !visit(entry) { if !visit(entry) {
return file.Close() return failure(file.Close())
} }
} }
if errors.Is(readErr, io.EOF) { if errors.Is(readErr, io.EOF) {
return file.Close() return failure(file.Close())
} }
if readErr != nil { if readErr != nil {
_ = file.Close() _ = file.Close()
return readErr return failure(readErr)
} }
if len(entries) == 0 { if len(entries) == 0 {
_ = file.Close() _ = file.Close()
return io.ErrNoProgress return failure(io.ErrNoProgress)
} }
} }
} }
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
return err return failure(err)
} }
// fs.ReadDir provides the standard fallback for implementations whose opened // fs.ReadDir provides the standard fallback for implementations whose opened
// files do not implement fs.ReadDirFile. It may allocate all entries itself. // files do not implement fs.ReadDirFile. It may allocate all entries itself.
entries, err := fs.ReadDir(a.native, name) entries, err := fs.ReadDir(a.native, name)
if err != nil { if err != nil {
return err return failure(err)
} }
for _, entry := range entries { for _, entry := range entries {
if !visit(entry) { if !visit(entry) {

View File

@@ -191,7 +191,7 @@ func readGlobEntries(access *filesystemAccess, directory string, location Locati
if errors.Is(err, errImportDirectoryEntryLimit) { if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)} return nil, false, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
} }
if errors.Is(err, fs.ErrNotExist) { if missingDirectoryOpen(err) {
return nil, true, nil return nil, true, nil
} }
if err != nil { if err != nil {

View File

@@ -299,7 +299,7 @@ func importDirectoryEntries(access *filesystemAccess, directory string, site imp
if errors.Is(err, errImportDirectoryEntryLimit) { if errors.Is(err, errImportDirectoryEntryLimit) {
return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit") return nil, false, importSelectionDiagnostic(site, CodeImportLimitExceeded, "directory search exceeds the directory-entry visit limit")
} }
if errors.Is(err, fs.ErrNotExist) { if missingDirectoryOpen(err) {
return nil, true, nil return nil, true, nil
} }
if err != nil { if err != nil {

View File

@@ -246,22 +246,36 @@ func TestNativeFilesystemDetectsRepeatedAndCyclicImports(t *testing.T) {
} }
type observedFilesystem struct { type observedFilesystem struct {
files fstest.MapFS files fstest.MapFS
opened int opened int
closed int closed int
fallback bool fallback bool
failRead string failOpen string
failStat string failRead string
failDir string failStat string
failDir string
failReadDir string
readDirErr error
failNoProgress string
failClose string
closeErr error
failFallbackDir string
fallbackErr error
fallbackBeforeClose bool
handles []*observedFile
} }
func (f *observedFilesystem) Open(name string) (fs.File, error) { func (f *observedFilesystem) Open(name string) (fs.File, error) {
if name == f.failOpen {
return nil, fs.ErrNotExist
}
inner, err := f.files.Open(name) inner, err := f.files.Open(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
f.opened++ f.opened++
file := &observedFile{File: inner, owner: f, name: name} file := &observedFile{File: inner, owner: f, name: name}
f.handles = append(f.handles, file)
if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback { if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback {
return &observedDirectoryFile{observedFile: file, directory: directory}, nil return &observedDirectoryFile{observedFile: file, directory: directory}, nil
} }
@@ -272,20 +286,36 @@ func (f *observedFilesystem) ReadDir(name string) ([]fs.DirEntry, error) {
if name == f.failDir { if name == f.failDir {
return nil, errors.New("tracking failure canary") return nil, errors.New("tracking failure canary")
} }
return fs.ReadDir(f.files, name) entries, err := fs.ReadDir(f.files, name)
if name == f.failFallbackDir {
for _, handle := range f.handles {
if handle.name == name && !handle.closed {
f.fallbackBeforeClose = true
}
}
return entries, f.fallbackErr
}
return entries, err
} }
func (f *observedFilesystem) assertClosed(t *testing.T) { func (f *observedFilesystem) assertClosed(t *testing.T) {
t.Helper() t.Helper()
if f.opened == 0 || f.opened != f.closed { if f.opened != f.closed || len(f.handles) != f.opened {
t.Fatalf("filesystem opens=%d closes=%d", f.opened, f.closed) t.Fatalf("filesystem opens=%d closes=%d handles=%d", f.opened, f.closed, len(f.handles))
}
for _, handle := range f.handles {
if !handle.closed || handle.closeCalls != 1 {
t.Errorf("filesystem handle %q closed=%t close calls=%d", handle.name, handle.closed, handle.closeCalls)
}
} }
} }
type observedFile struct { type observedFile struct {
fs.File fs.File
owner *observedFilesystem owner *observedFilesystem
name string name string
closed bool
closeCalls int
} }
func (f *observedFile) Read(data []byte) (int, error) { func (f *observedFile) Read(data []byte) (int, error) {
@@ -304,7 +334,16 @@ func (f *observedFile) Stat() (fs.FileInfo, error) {
func (f *observedFile) Close() error { func (f *observedFile) Close() error {
f.owner.closed++ f.owner.closed++
return f.File.Close() f.closeCalls++
f.closed = true
err := f.File.Close()
if f.name == f.owner.failClose {
if f.owner.closeErr != nil {
return f.owner.closeErr
}
return fs.ErrNotExist
}
return err
} }
type observedDirectoryFile struct { type observedDirectoryFile struct {
@@ -316,5 +355,15 @@ func (f *observedDirectoryFile) ReadDir(count int) ([]fs.DirEntry, error) {
if f.name == f.owner.failDir { if f.name == f.owner.failDir {
return nil, errors.New("tracking failure canary") return nil, errors.New("tracking failure canary")
} }
return f.directory.ReadDir(count) if f.name == f.owner.failNoProgress {
return nil, nil
}
entries, err := f.directory.ReadDir(count)
if f.name == f.owner.failReadDir {
if f.owner.readDirErr != nil {
return entries, f.owner.readDirErr
}
return entries, fs.ErrNotExist
}
return entries, err
} }

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
) )
@@ -181,3 +182,191 @@ func TestLoadingClosesNativeFilesAcrossPipelineOutcomes(t *testing.T) {
}) })
} }
} }
func TestDirectorySelectorsFailOnPostOpenNotExistErrors(t *testing.T) {
const canary = "DIRECTORY_FAILURE_CANARY"
readFailure := errors.Join(fs.ErrNotExist, errors.New(canary))
closeFailure := errors.Join(fs.ErrNotExist, errors.New(canary))
for _, test := range []struct {
name string
selector string
fallback bool
readError bool
noProgress bool
closeError bool
fallbackErr bool
}{
{name: "directory batched read with entries", selector: "directory", readError: true},
{name: "directory close", selector: "directory", closeError: true},
{name: "directory fallback with entries", selector: "directory", fallback: true, fallbackErr: true},
{name: "directory no progress", selector: "directory", noProgress: true},
{name: "glob batched read with entries", selector: "glob", readError: true},
{name: "glob close", selector: "glob", closeError: true},
{name: "glob fallback with entries", selector: "glob", fallback: true, fallbackErr: true},
{name: "glob no progress", selector: "glob", noProgress: true},
} {
t.Run(test.name, func(t *testing.T) {
main := "configkit:\n imports:\n"
if test.selector == "directory" {
main += " - dir: [broken, good]\n pattern: '*.yml'\n"
} else {
main += " - glob: 'broken/*.yml'\n - glob: 'good/*.yml'\n"
}
filesystem := &observedFilesystem{
files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte(main)},
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"broken/bad.yml": &fstest.MapFile{Data: []byte("value: bad\n")},
"good": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good/good.yml": &fstest.MapFile{Data: []byte("value: good\n")},
},
fallback: test.fallback,
failReadDir: "broken",
readDirErr: readFailure,
failNoProgress: "broken",
failClose: "broken",
closeErr: closeFailure,
failFallbackDir: "broken",
fallbackErr: readFailure,
}
if !test.readError {
filesystem.failReadDir = ""
}
if !test.noProgress {
filesystem.failNoProgress = ""
}
if !test.closeError {
filesystem.failClose = ""
}
if !test.fallbackErr {
filesystem.failFallbackDir = ""
}
result, err := testLoad[map[string]any](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[map[string]any]{Imports: true})
if result != nil {
t.Fatalf("load returned a result after directory failure: %#v", result)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("load error = %#v, want structured LoadError", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one directory failure", diagnostics)
}
diagnostic := diagnostics[0]
wantCode := CodeImportInvalid
if test.selector == "glob" {
wantCode = CodeImportRead
}
if diagnostic.Code != wantCode || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" {
t.Fatalf("directory diagnostic = %#v, want %q at source-1", diagnostic, wantCode)
}
var rendered strings.Builder
if err := WriteDiagnostics(&rendered, diagnostics); err != nil {
t.Fatal(err)
}
if strings.Contains(err.Error()+diagnostic.Message+rendered.String(), canary) {
t.Fatalf("backing error canary escaped: error=%q diagnostic=%#v rendered=%q", err, diagnostic, rendered.String())
}
if filesystem.fallbackBeforeClose {
t.Fatal("fallback directory enumeration began before the opened handle was closed")
}
filesystem.assertClosed(t)
})
}
}
func TestReadDirectoryPreservesReadErrorOverCloseError(t *testing.T) {
readFailure := errors.New("read failure")
closeFailure := errors.New("close failure")
filesystem := &observedFilesystem{
files: fstest.MapFS{
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"broken/entry": &fstest.MapFile{Data: []byte("entry")},
},
failReadDir: "broken",
readDirErr: readFailure,
failClose: "broken",
closeErr: closeFailure,
}
access := newNativeAccess(filesystem, ".", &namespaceToken{})
visited := 0
err := access.readDirectory("broken", func(fs.DirEntry) bool {
visited++
return true
})
if !errors.Is(err, readFailure) || errors.Is(err, closeFailure) || visited != 1 {
t.Fatalf("directory read = %v, visited %d; want entries plus primary read failure", err, visited)
}
if missingDirectoryOpen(err) {
t.Fatal("post-open read failure was classified as missing-on-open")
}
filesystem.assertClosed(t)
}
func TestReadDirectoryVisitStopCloseNotExistIsPostOpen(t *testing.T) {
filesystem := &observedFilesystem{
files: fstest.MapFS{
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"broken/entry": &fstest.MapFile{Data: []byte("entry")},
},
failClose: "broken",
}
access := newNativeAccess(filesystem, ".", &namespaceToken{})
err := access.readDirectory("broken", func(fs.DirEntry) bool { return false })
if !errors.Is(err, fs.ErrNotExist) || missingDirectoryOpen(err) {
t.Fatalf("visit-stop close error = %v, missing-on-open=%t", err, missingDirectoryOpen(err))
}
filesystem.assertClosed(t)
}
func TestDirectorySelectorsKeepMissingRootsOptional(t *testing.T) {
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
main := "configkit:\n imports:\n"
if selector == "directory" {
main += " - dir: [missing, good]\n pattern: '*.yml'\n required: true\n"
} else {
main += " - glob: 'missing/*.yml'\n - glob: 'good/*.yml'\n required: true\n"
}
filesystem := &observedFilesystem{files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte(main)},
"good": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good/good.yml": &fstest.MapFile{Data: []byte("value: good\n")},
}}
result, err := testLoad[map[string]any](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[map[string]any]{Imports: true})
if err != nil || result == nil {
t.Fatalf("load with missing search root = %#v, %v", result, err)
}
filesystem.assertClosed(t)
})
}
}
func TestDirectorySelectorsTreatMissingOnOpenAsOptional(t *testing.T) {
for _, selector := range []string{"directory", "glob"} {
t.Run(selector, func(t *testing.T) {
main := "configkit:\n imports:\n"
if selector == "directory" {
main += " - dir: [broken, good]\n pattern: '*.yml'\n required: true\n"
} else {
main += " - glob: 'broken/*.yml'\n - glob: 'good/*.yml'\n required: true\n"
}
filesystem := &observedFilesystem{
files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte(main)},
"broken": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good": &fstest.MapFile{Mode: fs.ModeDir | 0o700},
"good/good.yml": &fstest.MapFile{Data: []byte("value: good\n")},
},
failOpen: "broken",
}
result, err := testLoad[map[string]any](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[map[string]any]{Imports: true})
if err != nil || result == nil {
t.Fatalf("load after missing-on-open root = %#v, %v", result, err)
}
filesystem.assertClosed(t)
})
}
}