diff --git a/internal/filesystem/parents.go b/internal/filesystem/parents.go index 587cb78..abcfe6f 100644 --- a/internal/filesystem/parents.go +++ b/internal/filesystem/parents.go @@ -15,28 +15,13 @@ func targetPath(destination Destination) string { } func walkParentsValid(root, relative string) error { - segments, err := pathsafe.Segments(relative) - if err != nil { - return err - } - if err := requireDir(root); err != nil { - return err - } - current := root - for _, segment := range segments[:len(segments)-1] { - current = filepath.Join(current, segment) - info, err := os.Lstat(current) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return err - } - if err := requireDirEntry(current, info); err != nil { - return err - } - } - return nil + return pathsafe.ExistingAncestorWalk(root, relative, + func(err error) error { + return fmt.Errorf("filesystem: stat root %s: %w", root, err) + }, + func(path, reason string) error { + return fmt.Errorf("filesystem: %s parent component %s", reason, path) + }) } // ensureParents creates only missing parent components. Mkdir is deliberately diff --git a/internal/pathsafe/ancestor.go b/internal/pathsafe/ancestor.go index 7600bdc..1fd28c7 100644 --- a/internal/pathsafe/ancestor.go +++ b/internal/pathsafe/ancestor.go @@ -21,6 +21,53 @@ func AncestorWalk(root, relativePath string) error { return walkParents(root, parentSegments(segments)) } +// ExistingAncestorWalk validates every existing component from root through +// the parent of relativePath. It stops at the first missing component because +// nothing below it can exist yet. Callers provide error rendering so the +// validation stays shared without imposing their package's error vocabulary. +func ExistingAncestorWalk(root, relativePath string, rootError func(error) error, entryError func(path, reason string) error) error { + segments, err := Segments(relativePath) + if err != nil { + return err + } + info, err := os.Lstat(root) + if err != nil { + return rootError(err) + } + if err := directoryEntryError(root, info, entryError); err != nil { + return err + } + current := root + for _, segment := range parentSegments(segments) { + current = filepath.Join(current, segment) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if err := directoryEntryError(current, info, entryError); err != nil { + return err + } + } + return nil +} + +func directoryEntryError(path string, info os.FileInfo, entryError func(path, reason string) error) error { + mode := info.Mode() + switch { + case mode&os.ModeSymlink != 0: + return entryError(path, "symlink") + case isSpecial(mode): + return entryError(path, "special") + case !mode.IsDir(): + return entryError(path, "non-directory") + default: + return nil + } +} + // parentSegments drops the final destination segment, leaving the chain of // directories that must already be real directories. func parentSegments(segments []string) []string { diff --git a/internal/reconcile/target_snapshot.go b/internal/reconcile/target_snapshot.go index 0d86e00..c4db377 100644 --- a/internal/reconcile/target_snapshot.go +++ b/internal/reconcile/target_snapshot.go @@ -36,28 +36,13 @@ func CaptureTarget(destination Destination) (TargetSnapshot, error) { // parent of relative; each must be a real directory. The walk stops at the // first missing component because nothing deeper can exist yet. func walkParentComponents(root, relative string) error { - segments, err := pathsafe.Segments(relative) - if err != nil { - return err - } - if err := requireDirectory(root); err != nil { - return err - } - current := root - for _, segment := range segments[:len(segments)-1] { - current = filepath.Join(current, segment) - info, err := os.Lstat(current) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return err - } - if err := requireDirectoryEntry(current, info); err != nil { - return err - } - } - return nil + return pathsafe.ExistingAncestorWalk(root, relative, + func(err error) error { + return fmt.Errorf("reconcile: stat root %s: %w", root, err) + }, + func(path, reason string) error { + return fmt.Errorf("reconcile: %s component %s", reason, path) + }) } // parentIdentity freezes the identity of the destination parent directory; @@ -163,31 +148,6 @@ func validateOpenedFile(snapshot TargetSnapshot, info os.FileInfo) error { return nil } -// requireDirectory rejects a root that is missing or not a real directory. -func requireDirectory(path string) error { - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("reconcile: stat root %s: %w", path, err) - } - return requireDirectoryEntry(path, info) -} - -// requireDirectoryEntry rejects symlink, special, and non-directory -// components so a destination can never be redirected through its parents. -func requireDirectoryEntry(path string, info os.FileInfo) error { - mode := info.Mode() - if mode&os.ModeSymlink != 0 { - return fmt.Errorf("reconcile: symlink component %s", path) - } - if mode&(os.ModeDevice|os.ModeNamedPipe|os.ModeSocket|os.ModeCharDevice) != 0 { - return fmt.Errorf("reconcile: special component %s", path) - } - if !mode.IsDir() { - return fmt.Errorf("reconcile: non-directory component %s", path) - } - return nil -} - // KindOfIdentity classifies an existing identity without touching the path. func KindOfIdentity(identity pathsafe.Identity) EntryKind { mode := identity.Mode()