Track active symlink expansions precisely
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Import Discovery and Scheduling Review Findings
|
# Import Discovery and Scheduling Review Findings
|
||||||
|
|
||||||
Status: in progress; issues 1–3 complete, three findings remain.
|
Status: in progress; issues 1–4 complete, two findings remain.
|
||||||
|
|
||||||
Reviewed implementation: `aa93aed..15943e2`, ending at
|
Reviewed implementation: `aa93aed..15943e2`, ending at
|
||||||
`15943e233ca3cd1286c03c0e7085871db344cd4b`, against the accepted
|
`15943e233ca3cd1286c03c0e7085871db344cd4b`, against the accepted
|
||||||
@@ -120,7 +120,7 @@ undergo their existing containment checks. Public directory/glob regressions
|
|||||||
cover ordinary and aliased roots plus escaping links. All four valid cases
|
cover ordinary and aliased roots plus escaping links. All four valid cases
|
||||||
failed before the fix. The full everyday check passed.
|
failed before the fix. The full everyday check passed.
|
||||||
|
|
||||||
## 4. [ ] P2 — Distinguish finite symlink reuse from actual cycles
|
## 4. [x] P2 — Distinguish finite symlink reuse from actual cycles
|
||||||
|
|
||||||
Owners: [resolveHostImportPath and resolveNativeImportPath](../../internal/engine/filesystem_links.go),
|
Owners: [resolveHostImportPath and resolveNativeImportPath](../../internal/engine/filesystem_links.go),
|
||||||
especially the `activeLinks` sets around lines 115–134 and 172–194.
|
especially the `activeLinks` sets around lines 115–134 and 172–194.
|
||||||
@@ -143,6 +143,13 @@ separate active-path directory-cycle rule. Add finite-reuse and real-loop
|
|||||||
regressions for host and link-aware native access; do not replace this defect
|
regressions for host and link-aware native access; do not replace this defect
|
||||||
with an arbitrary new link or path-depth restriction.
|
with an arbitrary new link or path-depth restriction.
|
||||||
|
|
||||||
|
Completed: host and native resolvers now release active links when each target
|
||||||
|
expansion completes, using explicit frames rather than retaining a whole-path
|
||||||
|
visited set. Added resolver regressions for finite reuse, self loops, and mutual
|
||||||
|
loops, plus public host/native directory/glob loading coverage. Finite cases
|
||||||
|
failed before the change; all pass now alongside existing traversal-cycle and
|
||||||
|
containment tests. The full everyday check passed; no new limit was added.
|
||||||
|
|
||||||
## 5. [ ] P2 — Validate the complete depth token before saturating its value
|
## 5. [ ] P2 — Validate the complete depth token before saturating its value
|
||||||
|
|
||||||
Owner: [parseImportDepth](../../internal/engine/import_spec.go), around lines
|
Owner: [parseImportDepth](../../internal/engine/import_spec.go), around lines
|
||||||
|
|||||||
@@ -50,3 +50,32 @@ func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
|
||||||
|
for _, native := range []bool{false, true} {
|
||||||
|
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
|
||||||
|
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||||
|
if native {
|
||||||
|
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != int64(1) {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,13 +109,26 @@ func (a *filesystemAccess) importEntryTarget(name string, entry fs.DirEntry, fol
|
|||||||
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
return importEntryTarget{symlink: symlink, dir: info.IsDir(), identity: name}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A link stays active until its target has resolved, not until the entire
|
||||||
|
// original pathname has resolved. Remaining segments may reuse a finished link.
|
||||||
|
type importLinkFrame struct {
|
||||||
|
pending []string
|
||||||
|
link string
|
||||||
|
}
|
||||||
|
|
||||||
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
func resolveHostImportPath(access *filesystemAccess, name string) (string, error) {
|
||||||
pending := strings.Split(filepath.ToSlash(name), "/")
|
frames := []importLinkFrame{{pending: strings.Split(filepath.ToSlash(name), "/")}}
|
||||||
resolved := ""
|
resolved := ""
|
||||||
activeLinks := make(map[string]struct{})
|
activeLinks := make(map[string]struct{})
|
||||||
for len(pending) > 0 {
|
for len(frames) > 0 {
|
||||||
segment := pending[0]
|
frame := &frames[len(frames)-1]
|
||||||
pending = pending[1:]
|
if len(frame.pending) == 0 {
|
||||||
|
delete(activeLinks, frame.link)
|
||||||
|
frames = frames[:len(frames)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segment := frame.pending[0]
|
||||||
|
frame.pending = frame.pending[1:]
|
||||||
candidate := path.Join(resolved, segment)
|
candidate := path.Join(resolved, segment)
|
||||||
if candidate == "" {
|
if candidate == "" {
|
||||||
candidate = "."
|
candidate = "."
|
||||||
@@ -149,8 +162,7 @@ func resolveHostImportPath(access *filesystemAccess, name string) (string, error
|
|||||||
if !withinPortableRoot(targetPath) {
|
if !withinPortableRoot(targetPath) {
|
||||||
return "", errImportPathOutsideRoot
|
return "", errImportPathOutsideRoot
|
||||||
}
|
}
|
||||||
remaining := append([]string(nil), pending...)
|
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||||
pending = append(strings.Split(targetPath, "/"), remaining...)
|
|
||||||
resolved = ""
|
resolved = ""
|
||||||
}
|
}
|
||||||
if resolved == "" {
|
if resolved == "" {
|
||||||
@@ -167,12 +179,18 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
|
|||||||
if !fs.ValidPath(name) || !access.contains(name) {
|
if !fs.ValidPath(name) || !access.contains(name) {
|
||||||
return "", fs.ErrInvalid
|
return "", fs.ErrInvalid
|
||||||
}
|
}
|
||||||
pending := strings.Split(name, "/")
|
frames := []importLinkFrame{{pending: strings.Split(name, "/")}}
|
||||||
resolved := ""
|
resolved := ""
|
||||||
activeLinks := make(map[string]struct{})
|
activeLinks := make(map[string]struct{})
|
||||||
for len(pending) > 0 {
|
for len(frames) > 0 {
|
||||||
segment := pending[0]
|
frame := &frames[len(frames)-1]
|
||||||
pending = pending[1:]
|
if len(frame.pending) == 0 {
|
||||||
|
delete(activeLinks, frame.link)
|
||||||
|
frames = frames[:len(frames)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segment := frame.pending[0]
|
||||||
|
frame.pending = frame.pending[1:]
|
||||||
candidate := path.Join(resolved, segment)
|
candidate := path.Join(resolved, segment)
|
||||||
if candidate == "" {
|
if candidate == "" {
|
||||||
candidate = "."
|
candidate = "."
|
||||||
@@ -205,8 +223,7 @@ func resolveNativeImportPath(access *filesystemAccess, filesystem fs.ReadLinkFS,
|
|||||||
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
if !fs.ValidPath(targetPath) || !access.contains(targetPath) {
|
||||||
return "", errImportPathOutsideRoot
|
return "", errImportPathOutsideRoot
|
||||||
}
|
}
|
||||||
remaining := append([]string(nil), pending...)
|
frames = append(frames, importLinkFrame{pending: strings.Split(targetPath, "/"), link: candidate})
|
||||||
pending = append(strings.Split(targetPath, "/"), remaining...)
|
|
||||||
resolved = ""
|
resolved = ""
|
||||||
}
|
}
|
||||||
if resolved == "" {
|
if resolved == "" {
|
||||||
|
|||||||
47
internal/engine/filesystem_links_test.go
Normal file
47
internal/engine/filesystem_links_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportLinkResolutionTracksActiveExpansions(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, target := range map[string]string{"parts/up": "..", "self": "self", "first": "second", "second": "first"} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, err := openHostAccess(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer host.close()
|
||||||
|
filesystem := os.DirFS(root)
|
||||||
|
links, ok := filesystem.(fs.ReadLinkFS)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("native directory filesystem does not expose link inspection")
|
||||||
|
}
|
||||||
|
native := newNativeAccess(filesystem, ".", &namespaceToken{})
|
||||||
|
for name, resolve := range map[string]func(string) (string, error){
|
||||||
|
"host": func(name string) (string, error) { return resolveHostImportPath(host, name) },
|
||||||
|
"native": func(name string) (string, error) { return resolveNativeImportPath(native, links, name) },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if got, err := resolve("parts/up/parts/up/parts"); err != nil || got != "parts" {
|
||||||
|
t.Errorf("finite link reuse = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
for _, cycle := range []string{"self", "first"} {
|
||||||
|
if _, err := resolve(cycle); !errors.Is(err, errImportDirectoryLinkCycle) {
|
||||||
|
t.Errorf("%s cycle = %v", cycle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user