Resolve contained symlinks in import directory prefixes

This commit is contained in:
2026-10-20 01:04:31 +00:00
parent 4bcefbc9d4
commit 0d841d9d73
3 changed files with 61 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
# Import Integration Correctness Audit # Import Integration Correctness Audit
Status: open; three findings require correction. Status: in progress; finding 1 complete, findings 2 and 3 remain.
Reviewed tree: `91db99b` (including the six completed fixes through `5747ec4`). Reviewed tree: `91db99b` (including the six completed fixes through `5747ec4`).
Scope: import discovery/defaults and shared dependency-ready scheduling, including Scope: import discovery/defaults and shared dependency-ready scheduling, including
@@ -10,11 +10,11 @@ resource contexts, and maintained consumer examples. The completed design and
implementation plan remain available at `5747ec4:docs/roadmap/imports.md` and implementation plan remain available at `5747ec4:docs/roadmap/imports.md` and
`5747ec4:docs/roadmap/implementation.md`. `5747ec4:docs/roadmap/implementation.md`.
This review changes no production code or existing contracts. Preserve the The audit made no production code or contract changes. Preserve the
accepted syntax, defaults, scheduling, containment, and namespace decisions when accepted syntax, defaults, scheduling, containment, and namespace decisions when
addressing these findings. addressing these findings.
## 1. [ ] P2 — Accept contained absolute symlinks in directory-root prefixes ## 1. [x] P2 — Accept contained absolute symlinks in directory-root prefixes
Owners: [`importEntryTarget`](../../internal/engine/filesystem_links.go), Owners: [`importEntryTarget`](../../internal/engine/filesystem_links.go),
especially the initial host `a.root.Lstat(relative)` around line 30, and especially the initial host `a.root.Lstat(relative)` around line 30, and
@@ -53,6 +53,16 @@ solve this by prohibiting absolute symlinks or relaxing root containment. Add a
focused public regression for a directory root below an absolute contained link; focused public regression for a directory root below an absolute contained link;
reuse existing escaping-link and cycle coverage. reuse existing escaping-link and cycle coverage.
Completed: host entry inspection resolves intermediate parent components through
the existing containment-aware resolver before inspecting the final entry. Native
handling and the final entry's `follow` policy are unchanged. Added a public
regression for directory roots below an absolute contained link with both
`follow` settings, including canonical source identity. Confirmed that the test
fails before the fix and passes afterward. Focused facade and engine directory,
glob, symlink, and cycle tests passed, as did `./scripts/check.sh`,
`go test -race -count=1 ./...`, and `git diff --check`. The original public
reproduction now succeeds for all selectors.
## 2. [ ] P2 — Describe scheduled provenance and history order consistently ## 2. [ ] P2 — Describe scheduled provenance and history order consistently
Owner: [configuration contract](../config.md), especially layered metadata Owner: [configuration contract](../config.md), especially layered metadata

View File

@@ -2,6 +2,7 @@ package configkit_test
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -9,6 +10,43 @@ import (
configkit "gitea.maximumdirect.net/eric/ConfigKit" configkit "gitea.maximumdirect.net/eric/ConfigKit"
) )
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
parts := filepath.Join(root, "real", "parts")
if err := os.MkdirAll(parts, 0o700); err != nil {
t.Fatal(err)
}
partPath := filepath.Join(parts, "part.yml")
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for _, follow := range []bool{true, false} {
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
mainPath := filepath.Join(root, "main.yml")
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
if err != nil {
t.Fatal(err)
}
if result.Config["value"] != "included" {
t.Fatalf("configuration = %#v", result.Config)
}
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
t.Fatalf("canonical imported source = %#v", sources)
}
})
}
}
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) { func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
root := t.TempDir() root := t.TempDir()
parts := filepath.Join(root, "nested/root/parts") parts := filepath.Join(root, "nested/root/parts")

View File

@@ -27,6 +27,16 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
if err != nil { if err != nil {
return importEntryTarget{}, err return importEntryTarget{}, err
} }
// Root.Lstat cannot follow absolute links in intermediate components,
// even when contained. Resolve parents first, leaving the final entry
// unexpanded so its discovered-link follow policy still applies.
if parent := filepath.Dir(relative); parent != "." {
resolvedParent, err := resolveHostImportPath(a, parent)
if err != nil {
return importEntryTarget{}, err
}
relative = filepath.Join(filepath.FromSlash(resolvedParent), filepath.Base(relative))
}
info, err := a.root.Lstat(relative) info, err := a.root.Lstat(relative)
if err != nil { if err != nil {
return importEntryTarget{}, err return importEntryTarget{}, err