summaryrefslogtreecommitdiff
path: root/internal/apply/fs.go
blob: 56737e8171bf2b13001d4e80ea50bbba9e5e5f63 (plain) (blame)
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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
// SPDX-License-Identifier: GPL-3.0-or-later

package apply

import (
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"syscall"
)

// copyFile copies src to dst by streaming its content through a temporary
// file created in dst's directory, syncing it, then renaming it into place.
// dst must not already exist. Mode and modification time are preserved from
// src. On any failure the temporary file is removed and neither src nor a
// pre-existing dst is touched.
//
// This is not internal/config's replaceFile reused: that helper resolves a
// destination symlink and overwrites a file that is already there, and it
// takes the whole replacement as a []byte. copy's destination must not exist
// beforehand, and a []byte cannot stand in for a file that may be many
// gigabytes, so this is a separate, streaming equivalent kept local to
// internal/apply rather than shared with internal/config.
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()

	fi, err := in.Stat()
	if err != nil {
		return err
	}

	dir := filepath.Dir(dst)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}

	tmp, err := os.CreateTemp(dir, ".krino-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	done := false
	defer func() {
		if !done {
			os.Remove(tmpName)
		}
	}()

	if _, err := io.Copy(tmp, in); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chmod(fi.Mode().Perm()); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Chtimes(tmpName, fi.ModTime(), fi.ModTime()); err != nil {
		return err
	}

	// Deliberate, not redundant: the executor's own conflict re-check
	// (runFileStep, apply.go) already found a name free of anything on disk
	// before ever calling copyFile, so dst existing here means something
	// else claimed it in the meantime. Refusing is the only safe response —
	// silently overwriting it, via os.Rename below, would destroy whatever
	// just raced us.
	if _, err := os.Lstat(dst); err == nil {
		return fmt.Errorf("copy: destination already exists: %s", dst)
	} else if !os.IsNotExist(err) {
		return err
	}
	if err := os.Rename(tmpName, dst); err != nil {
		return err
	}
	done = true
	return nil
}

// moveFile moves src to dst. It tries os.Rename first, which is atomic when
// src and dst are on the same filesystem. Only when that fails with EXDEV
// (a different filesystem) does it fall back to copying src to dst through
// copyFile — itself leaving no temporary file and touching neither src nor
// dst on failure — and, only once that copy has landed at dst, removing src.
// A failure at any point before the copy has landed leaves src exactly
// where it was; a failure to remove src afterward leaves both a full copy
// at dst and the original at src rather than risk deleting the only good
// copy.
//
// The filesystem check is done by unwrapping the error for syscall.EXDEV,
// never by comparing a Stat_t's device field: that field's type differs
// across the platforms `make ci` vets (freebsd, openbsd), while syscall.EXDEV
// itself is defined identically on all three.
func moveFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	// Item 16 (fix round 2026-09-12, plan 5 Task 2): this guard must hold
	// independently of runFileStep's own pre-check, layered rather than
	// moved - the exact arrangement that produced plan 4's Task 5 Critical,
	// where a helper that replaced silently was trusted because some caller
	// had checked. Placed immediately before the operation that would
	// otherwise clobber dst, the same way copyFile's own guard sits right
	// before its rename into place.
	if err := refuseIfExists(dst); err != nil {
		return err
	}
	err := os.Rename(src, dst)
	if err == nil {
		return nil
	}
	if !errors.Is(err, syscall.EXDEV) {
		return err
	}
	if err := copyFile(src, dst); err != nil {
		return err
	}
	return os.Remove(src)
}

// renameFile renames src to dst, refusing on its own when dst already
// exists rather than trusting that a caller checked first (item 16, same
// reasoning as moveFile's guard above): a bare os.Rename silently replaces
// an occupied destination, and runFileStep's own pre-check must not be the
// only thing standing between a rename step and that.
func renameFile(src, dst string) error {
	if err := refuseIfExists(dst); err != nil {
		return err
	}
	return os.Rename(src, dst)
}

// refuseIfExists reports an error naming dst if something is already there
// (os.Lstat succeeds, following no symlink), and propagates any other stat
// failure. A nil return means dst was confirmed absent at the moment of the
// check.
func refuseIfExists(dst string) error {
	if _, err := os.Lstat(dst); err == nil {
		return fmt.Errorf("destination already exists: %s", dst)
	} else if !os.IsNotExist(err) {
		return err
	}
	return nil
}

// maxSuffixAttempts bounds nextFreeName. internal/plan/conflict.go and
// internal/trash/trash.go each have their own cap of the same size, for the
// same reason given below: nextFreeName solves yet another, independent
// collision problem and is not sharing code with either.
const maxSuffixAttempts = 10000

// nextFreeName finds the first stem_N.ext, N starting at 1, that does not
// currently exist on disk. It is the executor's own conflict re-check (spec
// §7.4, last paragraph): planning already resolved every conflict once
// against the filesystem as it was then, but something else can claim the
// planned name before the executor gets to it, so the executor looks again,
// right before acting, using only the real filesystem — it has no run-wide
// claim set to consult, unlike planning's.
//
// internal/plan's own suffixed() and splitExt are unexported, so they are
// not reachable from here; nextFreeName and splitExt below are a second,
// small implementation, the same shape as internal/plan's and
// internal/trash's for the same reason those two do not share code with
// each other either — each resolves a distinct, independently changing set
// of collisions (planned destinations; entries already in the Trash; names
// that appeared on disk since this plan was made).
func nextFreeName(dst string) (string, error) {
	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 _, err := os.Lstat(candidate); os.IsNotExist(err) {
			return candidate, nil
		}
	}
	return "", errors.New("too many conflicting names")
}

// splitExt splits name on its last dot, which does not count when it is the
// first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "".
// Duplicated from internal/plan/conflict.go and internal/trash/trash.go
// (unexported in both) rather than shared; see nextFreeName's comment.
func splitExt(name string) (stem, ext string) {
	i := strings.LastIndexByte(name, '.')
	if i <= 0 {
		return name, ""
	}
	return name[:i], name[i:]
}

// MkdirAllTracked creates dir and any missing ancestors (mode 0755),
// returning every directory it actually created, outermost first, so undo
// can later remove the empty ones again. A directory that already existed
// is not included, and nothing is created or returned on error.
func MkdirAllTracked(dir string) ([]string, error) {
	dir = filepath.Clean(dir)
	if fi, err := os.Stat(dir); err == nil {
		if !fi.IsDir() {
			return nil, fmt.Errorf("%s exists and is not a directory", dir)
		}
		return nil, nil
	} else if !os.IsNotExist(err) {
		return nil, err
	}

	parent := filepath.Dir(dir)
	var made []string
	if parent != dir {
		parentMade, err := MkdirAllTracked(parent)
		if err != nil {
			return nil, err
		}
		made = parentMade
	}
	if err := os.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
		return made, err
	}
	return append(made, dir), nil
}