// SPDX-License-Identifier: GPL-3.0-or-later package plan import ( "fmt" "os" "path/filepath" "krino/internal/config" "krino/internal/dup" ) // Disk is what Build needs from the filesystem to resolve conflicts, so // tests can supply a stub and Build stays pure otherwise. type Disk interface { Exists(path string) bool Regular(path string) bool SameContent(a, b string) (bool, error) } // OS is the real filesystem. type OS struct{} // Exists reports whether path names an existing file or directory, without // following a symlink at path itself: a dangling or otherwise unwanted // symlink still counts as "something is there". func (OS) Exists(path string) bool { _, err := os.Lstat(path) return err == nil } // Regular reports whether path is a regular file, not following a symlink // at path itself. func (OS) Regular(path string) bool { fi, err := os.Lstat(path) return err == nil && fi.Mode().IsRegular() } // SameContent delegates to internal/dup, the one place content identity is // decided. func (OS) SameContent(a, b string) (bool, error) { return dup.SameContent(a, b) } // NoDisk is the empty filesystem: nothing exists, and nothing is ever the // same content. For tests that are not about conflicts. type NoDisk struct{} func (NoDisk) Exists(string) bool { return false } func (NoDisk) Regular(string) bool { return false } func (NoDisk) SameContent(string, string) (bool, error) { return false, nil } // claimed is the set of destination paths already spoken for by an earlier // step of this plan. type claimed map[string]bool // resolveConflict decides what a step whose destination is contested does, // per spec §7.4. src is the step's source (its current path, before this // step runs); dst is the target the action computed. It returns the // resolved destination (possibly unchanged), a Skip reason (non-empty when // the step must not run) and Displaces (non-empty only for overwrite of a // file that exists on disk). func resolveConflict(kind Kind, policy config.Conflict, src, dst string, d Disk, c claimed) (resolved, skip, displaces string) { // A1/A2: the file is already where this step would put it, so its own // existence must not read as a conflict with itself. Without this guard a // move or rename plans a rename to stem_1 and every later run adds // another generation; under (on-conflict overwrite) the step records the // file as its own Displaces, which plan 4 would trash before moving from // a path that no longer exists. Checked before the policy switch, so // overwrite never reaches its own branch. if dst == src { return dst, "already there", "" } onDisk := d.Exists(dst) if kind == Copy && onDisk { // A SameContent error (the source vanished, a permission problem, // ...) is treated the same as "different content": the step falls // through to the ordinary conflict policy below instead of failing // outright. A wrong "different" verdict costs at worst an // unnecessary suffixed copy, never data loss, so resolving the // conflict anyway is an acceptable trade-off here (D8) - a caller // that wants the failure itself visible would need it surfaced as // a chain warning instead. if same, err := d.SameContent(src, dst); err == nil && same { return dst, "already there", "" } } if !onDisk && !c[dst] { return dst, "", "" } switch policy { case config.ConflictSkip: return dst, "target exists", "" case config.ConflictOverwrite: if onDisk && !c[dst] { // Only a regular file is ever trashed to make room (review M4): // a directory or link of the same name stays, and so does the // step - skipped, saying why. if !d.Regular(dst) { return dst, "target is not a regular file", "" } // The existing file is trashed first (plan 4). Only the first // step to reach this path may displace it: once another step // in this same plan has already claimed dst, that path will // hold that step's own output by the time this one runs, so // displacing it again would destroy it. return dst, "", dst } // Either claimed in-plan only (nothing on disk to displace — an // in-plan claim is never displaced), or on disk but already // claimed by an earlier step of this plan (displacing it again // would destroy that step's output): either way the two chains // cannot share one destination, so fall back to a free name, // exactly as suffix would. A step that takes a free name // displaces nothing. resolved, skip := suffixed(dst, d, c) return resolved, skip, "" case config.ConflictSuffix: resolved, skip := suffixed(dst, d, c) return resolved, skip, "" } // Every policy has its own branch (review cli F13): a new one must not // quietly plan as another. panic(fmt.Sprintf("plan: unknown config.Conflict %d", int(policy))) } // maxSuffixAttempts bounds suffixed(): it is unbounded by design and // terminates on a real filesystem, but C2 - without a cap, a Disk that // always reports existence (or a directory A1 had been filling before its // fix) turns planning quadratic instead of failing fast. const maxSuffixAttempts = 10000 // suffixed finds the first stem_N.ext (N starting at 1) that is free: // neither on disk nor already claimed by an earlier step in this plan. It // gives up after maxSuffixAttempts, returning a Skip reason and no path // (C2). func suffixed(dst string, d Disk, c claimed) (resolved, skip string) { dir, base := filepath.Split(dst) stem, ext := splitExt(base) for n := 1; n <= maxSuffixAttempts; n++ { candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext)) if !d.Exists(candidate) && !c[candidate] { return candidate, "" } } return "", "too many conflicting names" }