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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
|
// SPDX-License-Identifier: GPL-3.0-or-later
package plan
import (
"fmt"
"os"
"path/filepath"
"git.labunix.xyz/krino/internal/config"
"git.labunix.xyz/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 struct {
// taken is the set of destination paths already spoken for.
taken map[string]bool
// next is the highest suffix already tried for a stem, so filling one
// directory with one name does not begin at _1 for every file. Within
// a plan that is the same answer as starting from 1: the taken set
// only grows while a plan is built, and the disk is not being written
// to, so a suffix taken once stays taken.
next map[string]int
}
// newClaimed returns an empty claimed, both maps ready.
func newClaimed() claimed {
return claimed{taken: map[string]bool{}, next: map[string]int{}}
}
// 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) {
// 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 would then be trashed
// 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 - 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.taken[dst] {
return dst, "", ""
}
switch policy {
case config.ConflictSkip:
return dst, "target exists", ""
case config.ConflictOverwrite:
if onDisk && !c.taken[dst] {
// Only a regular file is ever trashed to make room: 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. 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: 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 without a cap, a Disk that always
// reports existence (or a directory being filled without the "already
// there" guard above) 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.
func suffixed(dst string, d Disk, c claimed) (resolved, skip string) {
dir, base := filepath.Split(dst)
stem, ext := splitExt(base)
key := dir + "\x00" + stem + "\x00" + ext
start := c.next[key]
if start < 1 {
start = 1
}
for n := start; n <= maxSuffixAttempts; n++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext))
if !d.Exists(candidate) && !c.taken[candidate] {
// n itself, not n+1: the caller claims this candidate, so the
// next search sees it taken and moves on. Recording n+1 would
// skip a suffix that is still free if this step is dropped.
c.next[key] = n
return candidate, ""
}
}
return "", "too many conflicting names"
}
|