1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
|
// 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
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
}
// 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) 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] {
// 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, ""
default: // config.ConflictSuffix
resolved, skip := suffixed(dst, d, c)
return resolved, skip, ""
}
}
// 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"
}
|