diff --git a/internal/hooks/discover.go b/internal/hooks/discover.go index 088001d..c40df7c 100644 --- a/internal/hooks/discover.go +++ b/internal/hooks/discover.go @@ -74,7 +74,7 @@ func discoverEntry(scope deployment.Scope, phasePath string, entry os.DirEntry) if !info.Mode().IsRegular() { return deployment.Hook{}, fmt.Errorf("hooks: %q is not a regular file", full) } - if info.Mode().Perm()&0o111 == 0 { + if info.Mode().Perm()&deployment.ExecutableBitMask == 0 { return deployment.Hook{}, fmt.Errorf("hooks: %q is not executable", full) } return deployment.NewHook(deployment.Hook{ diff --git a/internal/repository/collisions.go b/internal/repository/collisions.go index d6ba278..22148c5 100644 --- a/internal/repository/collisions.go +++ b/internal/repository/collisions.go @@ -88,9 +88,7 @@ func destinationsCollide(first, second string) (bool, error) { if err != nil { return false, err } - return pathsafe.PathsEquivalent(firstSegments, secondSegments) || - pathsafe.IsParentEquivalent(firstSegments, secondSegments) || - pathsafe.IsParentEquivalent(secondSegments, firstSegments), nil + return pathsafe.PortableOverlap(firstSegments, secondSegments), nil } // filePairError identifies both source owners and the colliding target. @@ -146,9 +144,7 @@ func aliasFileCollide(alias deployment.Alias, file deployment.ManagedFile) (bool if pathsafe.PathsEquivalent(destination, canonical) && pathsafe.PathsEquivalent(canonical, target) { return false, nil } - return pathsafe.PathsEquivalent(destination, target) || - pathsafe.IsParentEquivalent(destination, target) || - pathsafe.IsParentEquivalent(target, destination), nil + return pathsafe.PortableOverlap(destination, target), nil } // aliasFilePairError identifies the alias scope, the file owner, and the diff --git a/internal/repository/compiler.go b/internal/repository/compiler.go index 4e5810a..dfd9cf7 100644 --- a/internal/repository/compiler.go +++ b/internal/repository/compiler.go @@ -120,6 +120,11 @@ func scopeKept(group string, selected []string) bool { return len(selected) == 0 || slices.Contains(selected, group) } +// hookKept applies the documented asymmetry between repository-scope hooks +// and root files under explicit group selection. Repository-scope (root) +// hooks — identified by scope.Group == "" — always run even when a subset of +// groups is selected, whereas root files are dropped, because Section 10.1 +// mandates that repository hooks run for every apply. func hookKept(scope deployment.Scope, selected []string) bool { if scope.Group == "" { return true diff --git a/internal/repository/overlay.go b/internal/repository/overlay.go index 6be4796..4e222bd 100644 --- a/internal/repository/overlay.go +++ b/internal/repository/overlay.go @@ -56,6 +56,11 @@ type layerView struct { dirs map[string]bool } +// covers reports whether any base-layer entry suppresses the platform layer +// for target. A file entry at any ancestor path prefix covers the target: a +// single platform FILE at an ancestor suppresses the entire base subtree +// beneath it. That is why covers walks prefixes of the target rather than only +// exact file/dir matches. func (view layerView) covers(target string) bool { if _, ok := view.files[target]; ok || view.dirs[target] { return true @@ -95,6 +100,17 @@ func resolveScopeFiles(base ScanResult, scope deployment.Scope, platform layerVi return recordsFor(merged.files) } +// representableRootSecretTarget reports whether target can be produced by a +// root-scope secret source under Section 2.1: a root secret must target either +// a dot-prefixed tree (ungrouped HOME tree) or a single non-underscore segment. +// Multi-segment non-dot paths (e.g. bin/...) and leading-underscore targets are +// not representable at the root layer and require a group or a platform overlay. +func representableRootSecretTarget(target string) bool { + first := strings.Split(target, "/")[0] + return strings.HasPrefix(first, ".") || + (!strings.Contains(target, "/") && !strings.HasPrefix(first, "_")) +} + func baseTarget(candidate Candidate) (string, error) { target := candidate.SourceRepoPath if !candidate.Scope.IsRoot() { @@ -104,13 +120,8 @@ func baseTarget(candidate Candidate) (string, error) { return target, nil } target = strings.TrimPrefix(target, "_secrets/") - if candidate.Scope.IsRoot() { - first := strings.Split(target, "/")[0] - representable := strings.HasPrefix(first, ".") || - (!strings.Contains(target, "/") && !strings.HasPrefix(first, "_")) - if !representable { - return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) - } + if candidate.Scope.IsRoot() && !representableRootSecretTarget(target) { + return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) } return target, nil } @@ -217,7 +228,7 @@ func (walker *layerWalker) visit(path string, entry os.DirEntry, kind deployment candidate := Candidate{ Scope: walker.scope, Layer: walker.layer, Kind: kind, SourceRepoPath: filepath.Join(walker.relative, path), SourceAbsPath: filepath.Join(walker.absolute, path), - ExecutableBits: info.Mode() & 0o111, + ExecutableBits: info.Mode() & deployment.ExecutableBitMask, } existing, ok := walker.view.files[target] if ok && existing.Kind != kind { @@ -232,13 +243,8 @@ func (walker *layerWalker) target(path string, kind deployment.FileKind) (string return path, nil } target := strings.TrimPrefix(strings.TrimPrefix(path, "_secrets"), "/") - if walker.scope.IsRoot() { - first := strings.Split(target, "/")[0] - representable := strings.HasPrefix(first, ".") || - (!strings.Contains(target, "/") && !strings.HasPrefix(first, "_")) - if !representable { - return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) - } + if walker.scope.IsRoot() && !representableRootSecretTarget(target) { + return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) } return target, nil } diff --git a/internal/repository/scan.go b/internal/repository/scan.go index a3a0796..3fc3d51 100644 --- a/internal/repository/scan.go +++ b/internal/repository/scan.go @@ -80,6 +80,10 @@ func (s *scopeScanner) scanScopeRoot() error { func (s *scopeScanner) scanEntry(entry os.DirEntry) error { control := ClassifyRoot(entry.Name()) switch { + // Root dot-directories do not promote to groups: pathsafe.GroupName + // rejects leading-"." names, so they must route to ordinary scanning + // (e.g. an ungrouped HOME tree rooted at a dot-directory) rather than + // being treated as a group. case s.rootTree && control == ControlNone && entry.IsDir() && !strings.HasPrefix(entry.Name(), "."): return s.beginGroup(entry) case control == ControlNone: @@ -142,8 +146,8 @@ func (s *scopeScanner) scanHooks(entry os.DirEntry) error { return nil } -func (s *scopeScanner) scanHookPhase(hooks string, phase deployment.HookPhase) error { - path := filepath.Join(s.repoRoot, s.scopeRoot, hooks, string(phase)) +func (s *scopeScanner) scanHookPhase(hooksDir string, phase deployment.HookPhase) error { + path := filepath.Join(s.repoRoot, s.scopeRoot, hooksDir, string(phase)) info, err := os.Lstat(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -160,7 +164,7 @@ func (s *scopeScanner) scanHookPhase(hooks string, phase deployment.HookPhase) e } for _, entry := range entries { if entry.Type().IsRegular() { - s.hooks = append(s.hooks, s.hookCandidate(hooks, phase, entry.Name())) + s.hooks = append(s.hooks, s.hookCandidate(hooksDir, phase, entry.Name())) } } return nil @@ -202,7 +206,7 @@ func (s *scopeScanner) addFileAt(relative string, entry os.DirEntry, kind deploy Kind: kind, SourceRepoPath: path, SourceAbsPath: filepath.Join(s.repoRoot, path), - ExecutableBits: info.Mode() & 0o111, + ExecutableBits: info.Mode() & deployment.ExecutableBitMask, }) return nil } diff --git a/internal/routes/activate.go b/internal/routes/activate.go index 430bc11..89ef884 100644 --- a/internal/routes/activate.go +++ b/internal/routes/activate.go @@ -92,8 +92,14 @@ func canonicalSet(canonical []string) map[string]bool { // destination pointing at canonical (PLAN.md Section 5.4): the payload is // relative from the alias destination's parent directory, never absolute, // and never needs to climb above the home root. Both paths must be valid -// HOME-relative paths; a canonical that equals or contains the alias parent -// would produce an empty or self-referential payload and is rejected. +// HOME-relative paths. +// +// AliasPayload rejects only the case where the canonical segments that remain +// after the common prefix with the alias's parent directory are empty — i.e. +// when canonical is the alias parent or one of its ancestors, which would +// leave no target to point at. A self-referential single-segment alias where +// canonical == alias is not rejected here; that case is caught upstream by +// Activate (which errors when destination == canonical). func AliasPayload(canonical, alias string) (string, error) { canonicalSegments, err := pathsafe.Segments(canonical) if err != nil {