amalgm 0.1.238 → 0.1.240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shared-realtime-tunnel.js +22 -14
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -0,0 +1,2201 @@
|
|
|
1
|
+
// amalgm-file-transaction is the descriptor-relative mutation boundary for
|
|
2
|
+
// ordered workspace file applies. It never resolves a repository-relative
|
|
3
|
+
// parent after opening it: every create, rename, link, unlink, and directory
|
|
4
|
+
// sync is performed relative to O_NOFOLLOW directory descriptors.
|
|
5
|
+
package main
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"bytes"
|
|
9
|
+
"crypto/sha256"
|
|
10
|
+
"encoding/base64"
|
|
11
|
+
"encoding/hex"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"errors"
|
|
14
|
+
"fmt"
|
|
15
|
+
"io"
|
|
16
|
+
"os"
|
|
17
|
+
"path/filepath"
|
|
18
|
+
"strconv"
|
|
19
|
+
"strings"
|
|
20
|
+
"time"
|
|
21
|
+
|
|
22
|
+
"golang.org/x/sys/unix"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
type identity struct {
|
|
26
|
+
Kind string `json:"kind"`
|
|
27
|
+
Exec bool `json:"exec,omitempty"`
|
|
28
|
+
Bytes int64 `json:"bytes,omitempty"`
|
|
29
|
+
SHA256 string `json:"sha256,omitempty"`
|
|
30
|
+
Dev string `json:"dev,omitempty"`
|
|
31
|
+
Ino string `json:"ino,omitempty"`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type target struct {
|
|
35
|
+
Kind string `json:"kind"`
|
|
36
|
+
Exec bool `json:"exec,omitempty"`
|
|
37
|
+
Bytes int64 `json:"bytes"`
|
|
38
|
+
SHA256 string `json:"sha256"`
|
|
39
|
+
Content *string `json:"content,omitempty"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type treeEntryIdentity struct {
|
|
43
|
+
Kind string `json:"kind"`
|
|
44
|
+
Dev string `json:"dev"`
|
|
45
|
+
Ino string `json:"ino"`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type request struct {
|
|
49
|
+
Action string `json:"action"`
|
|
50
|
+
Backend string `json:"backend,omitempty"`
|
|
51
|
+
Root string `json:"root"`
|
|
52
|
+
RootDev string `json:"rootDev"`
|
|
53
|
+
RootIno string `json:"rootIno"`
|
|
54
|
+
Source string `json:"source,omitempty"`
|
|
55
|
+
SourceIdentity *treeEntryIdentity `json:"sourceIdentity,omitempty"`
|
|
56
|
+
SourceParentIdentity *treeEntryIdentity `json:"sourceParentIdentity,omitempty"`
|
|
57
|
+
TargetParentIdentity *treeEntryIdentity `json:"targetParentIdentity,omitempty"`
|
|
58
|
+
Path string `json:"path"`
|
|
59
|
+
ID string `json:"id"`
|
|
60
|
+
Operation string `json:"operation"`
|
|
61
|
+
Expected *identity `json:"expected"`
|
|
62
|
+
Target *target `json:"target"`
|
|
63
|
+
TestPauseAfterParentOpenMillis int `json:"testPauseAfterParentOpenMillis,omitempty"`
|
|
64
|
+
TestPauseAfterExtractionMillis int `json:"testPauseAfterExtractionMillis,omitempty"`
|
|
65
|
+
TestPauseBeforeInstallMillis int `json:"testPauseBeforeInstallMillis,omitempty"`
|
|
66
|
+
TestPauseAfterTreeMoveMillis int `json:"testPauseAfterTreeMoveMillis,omitempty"`
|
|
67
|
+
TestCrashAfterExtraction bool `json:"testCrashAfterExtraction,omitempty"`
|
|
68
|
+
TestCrashBeforeInstall bool `json:"testCrashBeforeInstall,omitempty"`
|
|
69
|
+
TestCrashAfterInstall bool `json:"testCrashAfterInstall,omitempty"`
|
|
70
|
+
TestCrashAfterTreeMove bool `json:"testCrashAfterTreeMove,omitempty"`
|
|
71
|
+
PID int `json:"pid,omitempty"`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type response struct {
|
|
75
|
+
Status string `json:"status"`
|
|
76
|
+
Reason string `json:"reason,omitempty"`
|
|
77
|
+
Identity string `json:"identity,omitempty"`
|
|
78
|
+
RootDev string `json:"rootDev,omitempty"`
|
|
79
|
+
RootIno string `json:"rootIno,omitempty"`
|
|
80
|
+
Path string `json:"path,omitempty"`
|
|
81
|
+
Value *identity `json:"value,omitempty"`
|
|
82
|
+
Content *string `json:"content,omitempty"`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type openedParent struct {
|
|
86
|
+
rootFD int
|
|
87
|
+
parentFD int
|
|
88
|
+
parentAbs string
|
|
89
|
+
base string
|
|
90
|
+
dev uint64
|
|
91
|
+
ino uint64
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func main() {
|
|
95
|
+
var input request
|
|
96
|
+
decoder := json.NewDecoder(io.LimitReader(os.Stdin, 16*1024*1024))
|
|
97
|
+
decoder.DisallowUnknownFields()
|
|
98
|
+
if err := decoder.Decode(&input); err != nil {
|
|
99
|
+
fail(fmt.Errorf("invalid request: %w", err))
|
|
100
|
+
}
|
|
101
|
+
if err := validateRequest(input); err != nil {
|
|
102
|
+
fail(err)
|
|
103
|
+
}
|
|
104
|
+
var result response
|
|
105
|
+
var err error
|
|
106
|
+
if input.Backend == "process-identity" {
|
|
107
|
+
switch input.Action {
|
|
108
|
+
case "identity":
|
|
109
|
+
var identity string
|
|
110
|
+
var live bool
|
|
111
|
+
identity, live, err = processIdentity(input.PID)
|
|
112
|
+
if live {
|
|
113
|
+
result = response{Status: "identified", Identity: identity}
|
|
114
|
+
} else {
|
|
115
|
+
result = response{Status: "exited"}
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
err = fmt.Errorf("unsupported action %q", input.Action)
|
|
119
|
+
}
|
|
120
|
+
} else if input.Backend == "git-lock" {
|
|
121
|
+
switch input.Action {
|
|
122
|
+
case "apply":
|
|
123
|
+
result, err = applyGitLock(input)
|
|
124
|
+
case "recover":
|
|
125
|
+
result, err = recoverGitLock(input)
|
|
126
|
+
case "receipt":
|
|
127
|
+
result, err = readGitLockReceipt(input)
|
|
128
|
+
case "release":
|
|
129
|
+
result, err = releaseGitLockReceipt(input)
|
|
130
|
+
default:
|
|
131
|
+
err = fmt.Errorf("unsupported action %q", input.Action)
|
|
132
|
+
}
|
|
133
|
+
} else if input.Backend == "git-guard" {
|
|
134
|
+
switch input.Action {
|
|
135
|
+
case "guard":
|
|
136
|
+
result, err = guardGitLock(input)
|
|
137
|
+
case "recover", "unguard":
|
|
138
|
+
result, err = releaseGitGuard(input)
|
|
139
|
+
default:
|
|
140
|
+
err = fmt.Errorf("unsupported action %q", input.Action)
|
|
141
|
+
}
|
|
142
|
+
} else if input.Backend == "git-worktree-guard" {
|
|
143
|
+
switch input.Action {
|
|
144
|
+
case "guard":
|
|
145
|
+
result, err = guardGitWorktreeNamespace(input)
|
|
146
|
+
case "recover", "unguard":
|
|
147
|
+
result, err = releaseGitWorktreeNamespace(input)
|
|
148
|
+
default:
|
|
149
|
+
err = fmt.Errorf("unsupported action %q", input.Action)
|
|
150
|
+
}
|
|
151
|
+
} else if input.Backend == "tree-rename" {
|
|
152
|
+
switch input.Action {
|
|
153
|
+
case "move":
|
|
154
|
+
result, err = moveTreeEntryNoReplace(input)
|
|
155
|
+
default:
|
|
156
|
+
err = fmt.Errorf("unsupported action %q", input.Action)
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
switch input.Action {
|
|
160
|
+
case "observe":
|
|
161
|
+
result, err = observe(input)
|
|
162
|
+
case "apply":
|
|
163
|
+
result, err = apply(input)
|
|
164
|
+
case "recover":
|
|
165
|
+
result, err = recoverTransaction(input)
|
|
166
|
+
case "receipt":
|
|
167
|
+
result, err = readReceipt(input)
|
|
168
|
+
case "release":
|
|
169
|
+
result, err = releaseReceipt(input)
|
|
170
|
+
default:
|
|
171
|
+
err = fmt.Errorf("unsupported action %q", input.Action)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if err != nil {
|
|
175
|
+
fail(err)
|
|
176
|
+
}
|
|
177
|
+
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
|
|
178
|
+
fail(err)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func fail(err error) {
|
|
183
|
+
_ = json.NewEncoder(os.Stdout).Encode(map[string]string{
|
|
184
|
+
"status": "error",
|
|
185
|
+
"reason": err.Error(),
|
|
186
|
+
})
|
|
187
|
+
os.Exit(1)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func validateRequest(input request) error {
|
|
191
|
+
if input.Backend == "process-identity" {
|
|
192
|
+
if input.Action != "identity" {
|
|
193
|
+
return errors.New("process identity action is invalid")
|
|
194
|
+
}
|
|
195
|
+
if input.PID < 1 {
|
|
196
|
+
return errors.New("process identity PID is invalid")
|
|
197
|
+
}
|
|
198
|
+
return nil
|
|
199
|
+
}
|
|
200
|
+
if input.Backend != "" &&
|
|
201
|
+
input.Backend != "git-lock" &&
|
|
202
|
+
input.Backend != "git-guard" &&
|
|
203
|
+
input.Backend != "git-worktree-guard" &&
|
|
204
|
+
input.Backend != "tree-rename" {
|
|
205
|
+
return errors.New("backend is invalid")
|
|
206
|
+
}
|
|
207
|
+
if !filepath.IsAbs(input.Root) {
|
|
208
|
+
return errors.New("root must be absolute")
|
|
209
|
+
}
|
|
210
|
+
if _, err := strconv.ParseUint(input.RootDev, 10, 64); err != nil {
|
|
211
|
+
return errors.New("root device identity is invalid")
|
|
212
|
+
}
|
|
213
|
+
if _, err := strconv.ParseUint(input.RootIno, 10, 64); err != nil {
|
|
214
|
+
return errors.New("root inode identity is invalid")
|
|
215
|
+
}
|
|
216
|
+
if input.Path == "" || filepath.IsAbs(input.Path) || strings.Contains(input.Path, `\`) {
|
|
217
|
+
return errors.New("path must be a Unix repository-relative path")
|
|
218
|
+
}
|
|
219
|
+
for _, component := range strings.Split(input.Path, "/") {
|
|
220
|
+
if component == "" || component == "." || component == ".." {
|
|
221
|
+
return errors.New("path contains an unsafe component")
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if input.Backend == "tree-rename" {
|
|
225
|
+
if input.Action != "move" {
|
|
226
|
+
return errors.New("tree rename action is invalid")
|
|
227
|
+
}
|
|
228
|
+
if input.Source == "" || filepath.IsAbs(input.Source) || strings.Contains(input.Source, `\`) {
|
|
229
|
+
return errors.New("tree rename source must be a Unix root-relative path")
|
|
230
|
+
}
|
|
231
|
+
for _, component := range strings.Split(input.Source, "/") {
|
|
232
|
+
if component == "" || component == "." || component == ".." {
|
|
233
|
+
return errors.New("tree rename source contains an unsafe component")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if input.Expected != nil || input.Target != nil {
|
|
237
|
+
return errors.New("tree rename has unexpected byte identities")
|
|
238
|
+
}
|
|
239
|
+
if input.SourceIdentity == nil {
|
|
240
|
+
return errors.New("tree rename source identity is missing")
|
|
241
|
+
}
|
|
242
|
+
if input.SourceParentIdentity == nil || input.TargetParentIdentity == nil {
|
|
243
|
+
return errors.New("tree rename parent identities are missing")
|
|
244
|
+
}
|
|
245
|
+
if input.SourceIdentity.Kind != "file" &&
|
|
246
|
+
input.SourceIdentity.Kind != "folder" &&
|
|
247
|
+
input.SourceIdentity.Kind != "symlink" &&
|
|
248
|
+
input.SourceIdentity.Kind != "other" {
|
|
249
|
+
return errors.New("tree rename source kind is invalid")
|
|
250
|
+
}
|
|
251
|
+
if _, err := strconv.ParseUint(input.SourceIdentity.Dev, 10, 64); err != nil {
|
|
252
|
+
return errors.New("tree rename source device identity is invalid")
|
|
253
|
+
}
|
|
254
|
+
if _, err := strconv.ParseUint(input.SourceIdentity.Ino, 10, 64); err != nil {
|
|
255
|
+
return errors.New("tree rename source inode identity is invalid")
|
|
256
|
+
}
|
|
257
|
+
for label, parent := range map[string]*treeEntryIdentity{
|
|
258
|
+
"source": input.SourceParentIdentity,
|
|
259
|
+
"target": input.TargetParentIdentity,
|
|
260
|
+
} {
|
|
261
|
+
if parent.Kind != "folder" {
|
|
262
|
+
return fmt.Errorf("tree rename %s parent must be a folder", label)
|
|
263
|
+
}
|
|
264
|
+
if _, err := strconv.ParseUint(parent.Dev, 10, 64); err != nil {
|
|
265
|
+
return fmt.Errorf("tree rename %s parent device identity is invalid", label)
|
|
266
|
+
}
|
|
267
|
+
if _, err := strconv.ParseUint(parent.Ino, 10, 64); err != nil {
|
|
268
|
+
return fmt.Errorf("tree rename %s parent inode identity is invalid", label)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for _, pause := range []int{
|
|
273
|
+
input.TestPauseAfterParentOpenMillis,
|
|
274
|
+
input.TestPauseAfterExtractionMillis,
|
|
275
|
+
input.TestPauseBeforeInstallMillis,
|
|
276
|
+
input.TestPauseAfterTreeMoveMillis,
|
|
277
|
+
} {
|
|
278
|
+
if pause < 0 || pause > 5000 {
|
|
279
|
+
return errors.New("test pause is outside its bounded range")
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if input.Action == "observe" {
|
|
283
|
+
if input.Backend != "" {
|
|
284
|
+
return errors.New("observation does not support a backend")
|
|
285
|
+
}
|
|
286
|
+
if input.ID != "" || input.Operation != "" || input.Expected != nil || input.Target != nil {
|
|
287
|
+
return errors.New("observation has unexpected transaction fields")
|
|
288
|
+
}
|
|
289
|
+
if input.TestPauseAfterExtractionMillis != 0 ||
|
|
290
|
+
input.TestPauseBeforeInstallMillis != 0 ||
|
|
291
|
+
input.TestCrashAfterExtraction ||
|
|
292
|
+
input.TestCrashBeforeInstall ||
|
|
293
|
+
input.TestCrashAfterInstall {
|
|
294
|
+
return errors.New("observation has unexpected mutation test hooks")
|
|
295
|
+
}
|
|
296
|
+
return nil
|
|
297
|
+
}
|
|
298
|
+
if len(input.ID) != 64 {
|
|
299
|
+
return errors.New("transaction id must be a SHA-256 string")
|
|
300
|
+
}
|
|
301
|
+
if _, err := hex.DecodeString(input.ID); err != nil {
|
|
302
|
+
return errors.New("transaction id must be hexadecimal")
|
|
303
|
+
}
|
|
304
|
+
if input.Operation == "" || len(input.Operation) > 1024 {
|
|
305
|
+
return errors.New("operation identity is invalid")
|
|
306
|
+
}
|
|
307
|
+
if input.Backend == "git-lock" {
|
|
308
|
+
if input.Path != "index" && input.Path != "HEAD" {
|
|
309
|
+
return errors.New("git-lock path must be index or HEAD")
|
|
310
|
+
}
|
|
311
|
+
if input.Target == nil || input.Target.Kind != "file" || input.Target.Exec {
|
|
312
|
+
return errors.New("git-lock target must be a non-executable file")
|
|
313
|
+
}
|
|
314
|
+
if input.Expected != nil && (input.Expected.Kind != "file" || input.Expected.Exec) {
|
|
315
|
+
return errors.New("git-lock expected value must be a non-executable file")
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if input.Backend == "git-guard" {
|
|
319
|
+
if input.Path != "HEAD" {
|
|
320
|
+
return errors.New("git-guard path must be HEAD")
|
|
321
|
+
}
|
|
322
|
+
if input.Target == nil || input.Target.Kind != "file" || input.Target.Exec {
|
|
323
|
+
return errors.New("git-guard target must be a non-executable file")
|
|
324
|
+
}
|
|
325
|
+
if input.Expected == nil || input.Expected.Kind != "file" || input.Expected.Exec {
|
|
326
|
+
return errors.New("git-guard expected value must be a non-executable file")
|
|
327
|
+
}
|
|
328
|
+
targetIdentity, err := requestedTargetIdentity(input.Target)
|
|
329
|
+
if err != nil {
|
|
330
|
+
return err
|
|
331
|
+
}
|
|
332
|
+
if !identitiesEqual(input.Expected, targetIdentity) {
|
|
333
|
+
return errors.New("git-guard target must equal its expected HEAD")
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if input.Backend == "git-worktree-guard" {
|
|
337
|
+
if input.Path != "worktrees" {
|
|
338
|
+
return errors.New("git-worktree-guard path must be worktrees")
|
|
339
|
+
}
|
|
340
|
+
if input.Expected == nil || input.Expected.Kind != "directory" {
|
|
341
|
+
return errors.New("git-worktree-guard expected value must be a directory")
|
|
342
|
+
}
|
|
343
|
+
if input.Target == nil || input.Target.Kind != "file" || input.Target.Exec {
|
|
344
|
+
return errors.New("git-worktree-guard target must be a non-executable file")
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if input.Expected != nil {
|
|
348
|
+
if input.Expected.Kind == "directory" && input.Backend != "git-worktree-guard" {
|
|
349
|
+
return errors.New("directory identity is only valid for a Git worktree guard")
|
|
350
|
+
}
|
|
351
|
+
if err := validateIdentity(*input.Expected); err != nil {
|
|
352
|
+
return fmt.Errorf("invalid expected identity: %w", err)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if input.Target != nil {
|
|
356
|
+
if input.Target.Kind != "file" && input.Target.Kind != "symlink" {
|
|
357
|
+
return errors.New("target kind must be file or symlink")
|
|
358
|
+
}
|
|
359
|
+
if err := validateIdentity(identity{
|
|
360
|
+
Kind: input.Target.Kind,
|
|
361
|
+
Exec: input.Target.Exec,
|
|
362
|
+
Bytes: input.Target.Bytes,
|
|
363
|
+
SHA256: input.Target.SHA256,
|
|
364
|
+
}); err != nil {
|
|
365
|
+
return fmt.Errorf("invalid target identity: %w", err)
|
|
366
|
+
}
|
|
367
|
+
if input.Action == "apply" && input.Backend != "git-lock" {
|
|
368
|
+
if input.Target.Content == nil {
|
|
369
|
+
return errors.New("apply target content is missing")
|
|
370
|
+
}
|
|
371
|
+
content, err := base64.StdEncoding.DecodeString(*input.Target.Content)
|
|
372
|
+
if err != nil {
|
|
373
|
+
return errors.New("target content is not base64")
|
|
374
|
+
}
|
|
375
|
+
sum := sha256.Sum256(content)
|
|
376
|
+
if int64(len(content)) != input.Target.Bytes ||
|
|
377
|
+
hex.EncodeToString(sum[:]) != input.Target.SHA256 {
|
|
378
|
+
return errors.New("target content does not match its identity")
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return nil
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// moveTreeEntryNoReplace is the one descriptor-relative rename boundary used
|
|
386
|
+
// by workspace-tree WALs. It never overwrites a destination that appeared
|
|
387
|
+
// after JavaScript observed absence. Source identity remains caller-owned WAL
|
|
388
|
+
// evidence and is reclassified after this atomic namespace operation.
|
|
389
|
+
func moveTreeEntryNoReplace(input request) (response, error) {
|
|
390
|
+
source, err := openParent(input.Root, input.Source, false)
|
|
391
|
+
if err != nil {
|
|
392
|
+
if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR) {
|
|
393
|
+
return response{Status: "changed", Reason: "source changed before exclusive move"}, nil
|
|
394
|
+
}
|
|
395
|
+
return response{}, err
|
|
396
|
+
}
|
|
397
|
+
defer source.close()
|
|
398
|
+
target, err := openParent(input.Root, input.Path, false)
|
|
399
|
+
if err != nil {
|
|
400
|
+
return response{}, err
|
|
401
|
+
}
|
|
402
|
+
defer target.close()
|
|
403
|
+
if err := verifyRootIdentity(source, input); err != nil {
|
|
404
|
+
return response{}, err
|
|
405
|
+
}
|
|
406
|
+
if err := verifyRootIdentity(target, input); err != nil {
|
|
407
|
+
return response{}, err
|
|
408
|
+
}
|
|
409
|
+
if !openedParentMatches(source, *input.SourceParentIdentity) {
|
|
410
|
+
return response{Status: "changed", Reason: "source parent changed before exclusive move"}, nil
|
|
411
|
+
}
|
|
412
|
+
if !openedParentMatches(target, *input.TargetParentIdentity) {
|
|
413
|
+
return response{Status: "changed", Reason: "target parent changed before exclusive move"}, nil
|
|
414
|
+
}
|
|
415
|
+
matches, err := matchesTreeEntryIdentity(
|
|
416
|
+
source.parentFD,
|
|
417
|
+
source.base,
|
|
418
|
+
*input.SourceIdentity,
|
|
419
|
+
)
|
|
420
|
+
if err != nil {
|
|
421
|
+
if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR) {
|
|
422
|
+
return response{Status: "changed", Reason: "source changed before exclusive move"}, nil
|
|
423
|
+
}
|
|
424
|
+
return response{}, err
|
|
425
|
+
}
|
|
426
|
+
if !matches {
|
|
427
|
+
return response{Status: "changed", Reason: "source changed before exclusive move"}, nil
|
|
428
|
+
}
|
|
429
|
+
if input.TestPauseBeforeInstallMillis > 0 {
|
|
430
|
+
time.Sleep(time.Duration(input.TestPauseBeforeInstallMillis) * time.Millisecond)
|
|
431
|
+
}
|
|
432
|
+
if err := moveNoReplace(source.parentFD, source.base, target.parentFD, target.base); err != nil {
|
|
433
|
+
if errors.Is(err, unix.EEXIST) || errors.Is(err, unix.ENOTEMPTY) {
|
|
434
|
+
return response{Status: "changed", Reason: "destination changed before exclusive move"}, nil
|
|
435
|
+
}
|
|
436
|
+
if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR) {
|
|
437
|
+
return response{Status: "changed", Reason: "source changed before exclusive move"}, nil
|
|
438
|
+
}
|
|
439
|
+
return response{}, fmt.Errorf("exclusive tree move: %w", err)
|
|
440
|
+
}
|
|
441
|
+
if input.TestPauseAfterTreeMoveMillis > 0 {
|
|
442
|
+
time.Sleep(time.Duration(input.TestPauseAfterTreeMoveMillis) * time.Millisecond)
|
|
443
|
+
}
|
|
444
|
+
if input.TestCrashAfterTreeMove {
|
|
445
|
+
os.Exit(89)
|
|
446
|
+
}
|
|
447
|
+
matches, err = matchesTreeEntryIdentity(
|
|
448
|
+
target.parentFD,
|
|
449
|
+
target.base,
|
|
450
|
+
*input.SourceIdentity,
|
|
451
|
+
)
|
|
452
|
+
if err != nil {
|
|
453
|
+
return response{}, fmt.Errorf("verify exclusive tree move source: %w", err)
|
|
454
|
+
}
|
|
455
|
+
if !matches {
|
|
456
|
+
undoErr := moveNoReplace(
|
|
457
|
+
target.parentFD,
|
|
458
|
+
target.base,
|
|
459
|
+
source.parentFD,
|
|
460
|
+
source.base,
|
|
461
|
+
)
|
|
462
|
+
if undoErr == nil {
|
|
463
|
+
if err := unix.Fsync(source.parentFD); err != nil {
|
|
464
|
+
return response{}, fmt.Errorf("sync undone tree move source parent: %w", err)
|
|
465
|
+
}
|
|
466
|
+
if target.parentFD != source.parentFD {
|
|
467
|
+
if err := unix.Fsync(target.parentFD); err != nil {
|
|
468
|
+
return response{}, fmt.Errorf("sync undone tree move target parent: %w", err)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return response{
|
|
472
|
+
Status: "changed",
|
|
473
|
+
Reason: "source changed during exclusive move and was restored",
|
|
474
|
+
}, nil
|
|
475
|
+
}
|
|
476
|
+
if errors.Is(undoErr, unix.EEXIST) || errors.Is(undoErr, unix.ENOTEMPTY) {
|
|
477
|
+
return response{
|
|
478
|
+
Status: "changed",
|
|
479
|
+
Reason: "source changed during exclusive move; both entries were retained",
|
|
480
|
+
}, nil
|
|
481
|
+
}
|
|
482
|
+
return response{}, fmt.Errorf("undo changed exclusive tree move: %w", undoErr)
|
|
483
|
+
}
|
|
484
|
+
if err := unix.Fsync(source.parentFD); err != nil {
|
|
485
|
+
return response{}, fmt.Errorf("sync tree move source parent: %w", err)
|
|
486
|
+
}
|
|
487
|
+
if target.parentFD != source.parentFD {
|
|
488
|
+
if err := unix.Fsync(target.parentFD); err != nil {
|
|
489
|
+
return response{}, fmt.Errorf("sync tree move target parent: %w", err)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return response{Status: "applied"}, nil
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
func openedParentMatches(opened *openedParent, expected treeEntryIdentity) bool {
|
|
496
|
+
return expected.Kind == "folder" &&
|
|
497
|
+
strconv.FormatUint(opened.dev, 10) == expected.Dev &&
|
|
498
|
+
strconv.FormatUint(opened.ino, 10) == expected.Ino
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
func matchesTreeEntryIdentity(
|
|
502
|
+
dirFD int,
|
|
503
|
+
name string,
|
|
504
|
+
expected treeEntryIdentity,
|
|
505
|
+
) (bool, error) {
|
|
506
|
+
var stat unix.Stat_t
|
|
507
|
+
if err := unix.Fstatat(dirFD, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
508
|
+
return false, err
|
|
509
|
+
}
|
|
510
|
+
var kind string
|
|
511
|
+
switch uint32(stat.Mode) & unix.S_IFMT {
|
|
512
|
+
case unix.S_IFREG:
|
|
513
|
+
kind = "file"
|
|
514
|
+
case unix.S_IFDIR:
|
|
515
|
+
kind = "folder"
|
|
516
|
+
case unix.S_IFLNK:
|
|
517
|
+
kind = "symlink"
|
|
518
|
+
default:
|
|
519
|
+
kind = "other"
|
|
520
|
+
}
|
|
521
|
+
return kind == expected.Kind &&
|
|
522
|
+
strconv.FormatUint(uint64(stat.Dev), 10) == expected.Dev &&
|
|
523
|
+
strconv.FormatUint(uint64(stat.Ino), 10) == expected.Ino, nil
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
func validateIdentity(value identity) error {
|
|
527
|
+
if value.Kind == "directory" {
|
|
528
|
+
if value.Exec || value.Bytes != 0 || value.SHA256 != "" {
|
|
529
|
+
return errors.New("directory identity has unexpected byte fields")
|
|
530
|
+
}
|
|
531
|
+
if _, err := strconv.ParseUint(value.Dev, 10, 64); err != nil {
|
|
532
|
+
return errors.New("directory device identity is invalid")
|
|
533
|
+
}
|
|
534
|
+
if _, err := strconv.ParseUint(value.Ino, 10, 64); err != nil {
|
|
535
|
+
return errors.New("directory inode identity is invalid")
|
|
536
|
+
}
|
|
537
|
+
return nil
|
|
538
|
+
}
|
|
539
|
+
if value.Kind != "file" && value.Kind != "symlink" {
|
|
540
|
+
return errors.New("kind must be file, symlink, or directory")
|
|
541
|
+
}
|
|
542
|
+
if (value.Dev == "") != (value.Ino == "") {
|
|
543
|
+
return errors.New("byte identity must name both device and inode or neither")
|
|
544
|
+
}
|
|
545
|
+
if value.Dev != "" {
|
|
546
|
+
if _, err := strconv.ParseUint(value.Dev, 10, 64); err != nil {
|
|
547
|
+
return errors.New("byte device identity is invalid")
|
|
548
|
+
}
|
|
549
|
+
if _, err := strconv.ParseUint(value.Ino, 10, 64); err != nil {
|
|
550
|
+
return errors.New("byte inode identity is invalid")
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if value.Bytes < 0 || len(value.SHA256) != 64 {
|
|
554
|
+
return errors.New("byte identity is incomplete")
|
|
555
|
+
}
|
|
556
|
+
if _, err := hex.DecodeString(value.SHA256); err != nil {
|
|
557
|
+
return errors.New("sha256 must be hexadecimal")
|
|
558
|
+
}
|
|
559
|
+
return nil
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
func scratchName(id string) string {
|
|
563
|
+
return ".amalgm-apply-" + id
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const (
|
|
567
|
+
preparedMarker = "prepared"
|
|
568
|
+
deletedMarker = "deleted"
|
|
569
|
+
gitReceiptDir = "amalgm-metadata-apply"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
func openAbsoluteDirectoryNoFollow(directory string) (int, error) {
|
|
573
|
+
clean := filepath.Clean(directory)
|
|
574
|
+
currentFD, err := unix.Open(
|
|
575
|
+
string(filepath.Separator),
|
|
576
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
577
|
+
0,
|
|
578
|
+
)
|
|
579
|
+
if err != nil {
|
|
580
|
+
return -1, err
|
|
581
|
+
}
|
|
582
|
+
for _, component := range strings.Split(strings.TrimPrefix(clean, string(filepath.Separator)), string(filepath.Separator)) {
|
|
583
|
+
if component == "" {
|
|
584
|
+
continue
|
|
585
|
+
}
|
|
586
|
+
nextFD, openErr := unix.Openat(
|
|
587
|
+
currentFD,
|
|
588
|
+
component,
|
|
589
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
590
|
+
0,
|
|
591
|
+
)
|
|
592
|
+
_ = unix.Close(currentFD)
|
|
593
|
+
if openErr != nil {
|
|
594
|
+
return -1, openErr
|
|
595
|
+
}
|
|
596
|
+
currentFD = nextFD
|
|
597
|
+
}
|
|
598
|
+
return currentFD, nil
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
func openParent(root, relative string, create bool) (*openedParent, error) {
|
|
602
|
+
rootFD, err := openAbsoluteDirectoryNoFollow(root)
|
|
603
|
+
if err != nil {
|
|
604
|
+
return nil, fmt.Errorf("open root: %w", err)
|
|
605
|
+
}
|
|
606
|
+
components := strings.Split(relative, "/")
|
|
607
|
+
currentFD := rootFD
|
|
608
|
+
currentAbs := root
|
|
609
|
+
for _, component := range components[:len(components)-1] {
|
|
610
|
+
nextFD, openErr := unix.Openat(
|
|
611
|
+
currentFD,
|
|
612
|
+
component,
|
|
613
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
614
|
+
0,
|
|
615
|
+
)
|
|
616
|
+
if openErr != nil && errors.Is(openErr, unix.ENOENT) && create {
|
|
617
|
+
if mkdirErr := unix.Mkdirat(currentFD, component, 0o755); mkdirErr != nil &&
|
|
618
|
+
!errors.Is(mkdirErr, unix.EEXIST) {
|
|
619
|
+
closeParentFDs(rootFD, currentFD)
|
|
620
|
+
return nil, fmt.Errorf("create parent %q: %w", component, mkdirErr)
|
|
621
|
+
}
|
|
622
|
+
if syncErr := unix.Fsync(currentFD); syncErr != nil {
|
|
623
|
+
closeParentFDs(rootFD, currentFD)
|
|
624
|
+
return nil, fmt.Errorf("sync parent after mkdir: %w", syncErr)
|
|
625
|
+
}
|
|
626
|
+
nextFD, openErr = unix.Openat(
|
|
627
|
+
currentFD,
|
|
628
|
+
component,
|
|
629
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
630
|
+
0,
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
if openErr != nil {
|
|
634
|
+
closeParentFDs(rootFD, currentFD)
|
|
635
|
+
return nil, fmt.Errorf("open parent %q without following links: %w", component, openErr)
|
|
636
|
+
}
|
|
637
|
+
if currentFD != rootFD {
|
|
638
|
+
_ = unix.Close(currentFD)
|
|
639
|
+
}
|
|
640
|
+
currentFD = nextFD
|
|
641
|
+
currentAbs = filepath.Join(currentAbs, component)
|
|
642
|
+
}
|
|
643
|
+
var stat unix.Stat_t
|
|
644
|
+
if err := unix.Fstat(currentFD, &stat); err != nil {
|
|
645
|
+
closeParentFDs(rootFD, currentFD)
|
|
646
|
+
return nil, fmt.Errorf("stat parent: %w", err)
|
|
647
|
+
}
|
|
648
|
+
return &openedParent{
|
|
649
|
+
rootFD: rootFD,
|
|
650
|
+
parentFD: currentFD,
|
|
651
|
+
parentAbs: currentAbs,
|
|
652
|
+
base: components[len(components)-1],
|
|
653
|
+
dev: uint64(stat.Dev),
|
|
654
|
+
ino: uint64(stat.Ino),
|
|
655
|
+
}, nil
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
func verifyRootIdentity(opened *openedParent, input request) error {
|
|
659
|
+
expectedDev, _ := strconv.ParseUint(input.RootDev, 10, 64)
|
|
660
|
+
expectedIno, _ := strconv.ParseUint(input.RootIno, 10, 64)
|
|
661
|
+
var stat unix.Stat_t
|
|
662
|
+
if err := unix.Fstat(opened.rootFD, &stat); err != nil {
|
|
663
|
+
return fmt.Errorf("stat opened root: %w", err)
|
|
664
|
+
}
|
|
665
|
+
if uint64(stat.Dev) != expectedDev || uint64(stat.Ino) != expectedIno {
|
|
666
|
+
return errors.New("workspace root changed before file transaction")
|
|
667
|
+
}
|
|
668
|
+
return nil
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
func closeParentFDs(rootFD, parentFD int) {
|
|
672
|
+
if parentFD >= 0 && parentFD != rootFD {
|
|
673
|
+
_ = unix.Close(parentFD)
|
|
674
|
+
}
|
|
675
|
+
if rootFD >= 0 {
|
|
676
|
+
_ = unix.Close(rootFD)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
func (opened *openedParent) close() {
|
|
681
|
+
closeParentFDs(opened.rootFD, opened.parentFD)
|
|
682
|
+
opened.rootFD = -1
|
|
683
|
+
opened.parentFD = -1
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const maxObservedBytes = 16 * 1024 * 1024
|
|
687
|
+
|
|
688
|
+
func readObservedFile(fd int) ([]byte, error) {
|
|
689
|
+
if _, err := unix.Seek(fd, 0, io.SeekStart); err != nil {
|
|
690
|
+
return nil, fmt.Errorf("seek observed file: %w", err)
|
|
691
|
+
}
|
|
692
|
+
content := make([]byte, 0, 64*1024)
|
|
693
|
+
buffer := make([]byte, 64*1024)
|
|
694
|
+
for {
|
|
695
|
+
count, err := unix.Read(fd, buffer)
|
|
696
|
+
if count > 0 {
|
|
697
|
+
if len(content)+count > maxObservedBytes {
|
|
698
|
+
return nil, fmt.Errorf("observed file exceeds %d bytes", maxObservedBytes)
|
|
699
|
+
}
|
|
700
|
+
content = append(content, buffer[:count]...)
|
|
701
|
+
}
|
|
702
|
+
if err != nil {
|
|
703
|
+
if errors.Is(err, unix.EINTR) {
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
return nil, fmt.Errorf("read observed file: %w", err)
|
|
707
|
+
}
|
|
708
|
+
if count == 0 {
|
|
709
|
+
return content, nil
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
func sameObservedStat(left, right unix.Stat_t) bool {
|
|
715
|
+
return left.Dev == right.Dev &&
|
|
716
|
+
left.Ino == right.Ino &&
|
|
717
|
+
left.Mode == right.Mode &&
|
|
718
|
+
left.Size == right.Size &&
|
|
719
|
+
statChangedAt(left) == statChangedAt(right)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// observe is the descriptor-relative read boundary. The parent directory and
|
|
723
|
+
// child file stay pinned from identity through bytes, so a rename followed by
|
|
724
|
+
// a replacement at the old spelling can never combine two physical objects
|
|
725
|
+
// into one observation.
|
|
726
|
+
func observe(input request) (response, error) {
|
|
727
|
+
opened, err := openParent(input.Root, input.Path, false)
|
|
728
|
+
if err != nil {
|
|
729
|
+
return response{}, err
|
|
730
|
+
}
|
|
731
|
+
defer opened.close()
|
|
732
|
+
if err := verifyRootIdentity(opened, input); err != nil {
|
|
733
|
+
return response{}, err
|
|
734
|
+
}
|
|
735
|
+
if input.TestPauseAfterParentOpenMillis > 0 {
|
|
736
|
+
time.Sleep(time.Duration(input.TestPauseAfterParentOpenMillis) * time.Millisecond)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
fd, err := unix.Openat(
|
|
740
|
+
opened.parentFD,
|
|
741
|
+
opened.base,
|
|
742
|
+
unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
743
|
+
0,
|
|
744
|
+
)
|
|
745
|
+
if errors.Is(err, unix.ENOENT) {
|
|
746
|
+
return response{
|
|
747
|
+
Status: "observed",
|
|
748
|
+
RootDev: input.RootDev,
|
|
749
|
+
RootIno: input.RootIno,
|
|
750
|
+
Path: input.Path,
|
|
751
|
+
}, nil
|
|
752
|
+
}
|
|
753
|
+
if err != nil {
|
|
754
|
+
return response{}, fmt.Errorf("open observed file: %w", err)
|
|
755
|
+
}
|
|
756
|
+
defer unix.Close(fd)
|
|
757
|
+
|
|
758
|
+
var before unix.Stat_t
|
|
759
|
+
if err := unix.Fstat(fd, &before); err != nil {
|
|
760
|
+
return response{}, fmt.Errorf("stat observed file: %w", err)
|
|
761
|
+
}
|
|
762
|
+
if uint32(before.Mode)&unix.S_IFMT != unix.S_IFREG {
|
|
763
|
+
return response{}, errors.New("observed path is not a regular file")
|
|
764
|
+
}
|
|
765
|
+
first, err := readObservedFile(fd)
|
|
766
|
+
if err != nil {
|
|
767
|
+
return response{}, err
|
|
768
|
+
}
|
|
769
|
+
var middle unix.Stat_t
|
|
770
|
+
if err := unix.Fstat(fd, &middle); err != nil {
|
|
771
|
+
return response{}, fmt.Errorf("restat observed file: %w", err)
|
|
772
|
+
}
|
|
773
|
+
second, err := readObservedFile(fd)
|
|
774
|
+
if err != nil {
|
|
775
|
+
return response{}, err
|
|
776
|
+
}
|
|
777
|
+
var after unix.Stat_t
|
|
778
|
+
if err := unix.Fstat(fd, &after); err != nil {
|
|
779
|
+
return response{}, fmt.Errorf("final stat observed file: %w", err)
|
|
780
|
+
}
|
|
781
|
+
if !sameObservedStat(before, middle) ||
|
|
782
|
+
!sameObservedStat(middle, after) ||
|
|
783
|
+
int64(len(second)) != after.Size ||
|
|
784
|
+
!bytes.Equal(first, second) {
|
|
785
|
+
return response{Status: "changed", Reason: "file changed during observation"}, nil
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
sum := sha256.Sum256(second)
|
|
789
|
+
content := base64.StdEncoding.EncodeToString(second)
|
|
790
|
+
value := &identity{
|
|
791
|
+
Kind: "file",
|
|
792
|
+
Exec: uint32(after.Mode)&0o100 != 0,
|
|
793
|
+
Bytes: int64(len(second)),
|
|
794
|
+
SHA256: hex.EncodeToString(sum[:]),
|
|
795
|
+
Dev: strconv.FormatUint(uint64(after.Dev), 10),
|
|
796
|
+
Ino: strconv.FormatUint(uint64(after.Ino), 10),
|
|
797
|
+
}
|
|
798
|
+
return response{
|
|
799
|
+
Status: "observed",
|
|
800
|
+
RootDev: input.RootDev,
|
|
801
|
+
RootIno: input.RootIno,
|
|
802
|
+
Path: input.Path,
|
|
803
|
+
Value: value,
|
|
804
|
+
Content: &content,
|
|
805
|
+
}, nil
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
func reopenParent(input request, dev, ino uint64) (bool, error) {
|
|
809
|
+
reopened, err := openParent(input.Root, input.Path, false)
|
|
810
|
+
if err != nil {
|
|
811
|
+
return false, nil
|
|
812
|
+
}
|
|
813
|
+
defer reopened.close()
|
|
814
|
+
if err := verifyRootIdentity(reopened, input); err != nil {
|
|
815
|
+
return false, nil
|
|
816
|
+
}
|
|
817
|
+
return reopened.dev == dev && reopened.ino == ino, nil
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
func apply(input request) (response, error) {
|
|
821
|
+
opened, err := openParent(input.Root, input.Path, true)
|
|
822
|
+
if err != nil {
|
|
823
|
+
return response{}, err
|
|
824
|
+
}
|
|
825
|
+
defer opened.close()
|
|
826
|
+
if err := verifyRootIdentity(opened, input); err != nil {
|
|
827
|
+
return response{}, err
|
|
828
|
+
}
|
|
829
|
+
if input.TestPauseAfterParentOpenMillis > 0 {
|
|
830
|
+
time.Sleep(time.Duration(input.TestPauseAfterParentOpenMillis) * time.Millisecond)
|
|
831
|
+
}
|
|
832
|
+
scratch := scratchName(input.ID)
|
|
833
|
+
if err := unix.Mkdirat(opened.parentFD, scratch, 0o700); err != nil {
|
|
834
|
+
return response{}, fmt.Errorf("create transaction scratch: %w", err)
|
|
835
|
+
}
|
|
836
|
+
if err := unix.Fsync(opened.parentFD); err != nil {
|
|
837
|
+
return response{}, fmt.Errorf("sync scratch parent: %w", err)
|
|
838
|
+
}
|
|
839
|
+
scratchFD, err := unix.Openat(
|
|
840
|
+
opened.parentFD,
|
|
841
|
+
scratch,
|
|
842
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
843
|
+
0,
|
|
844
|
+
)
|
|
845
|
+
if err != nil {
|
|
846
|
+
return response{}, fmt.Errorf("open transaction scratch: %w", err)
|
|
847
|
+
}
|
|
848
|
+
defer unix.Close(scratchFD)
|
|
849
|
+
|
|
850
|
+
if input.Target != nil {
|
|
851
|
+
if err := prepareTarget(scratchFD, *input.Target); err != nil {
|
|
852
|
+
return response{}, err
|
|
853
|
+
}
|
|
854
|
+
if err := writeMarker(scratchFD, preparedMarker); err != nil {
|
|
855
|
+
return response{}, fmt.Errorf("record prepared target: %w", err)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if input.Expected == nil {
|
|
860
|
+
if held, err := identityAt(opened.parentFD, opened.base); err != nil {
|
|
861
|
+
return response{}, err
|
|
862
|
+
} else if held != nil {
|
|
863
|
+
if cleanupErr := cleanupScratch(opened.parentFD, scratchFD, scratch); cleanupErr != nil {
|
|
864
|
+
return response{}, cleanupErr
|
|
865
|
+
}
|
|
866
|
+
return response{Status: "changed", Reason: "destination appeared before create"}, nil
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
if err := unix.Renameat(opened.parentFD, opened.base, scratchFD, "source"); err != nil {
|
|
870
|
+
return response{}, fmt.Errorf("extract expected source: %w", err)
|
|
871
|
+
}
|
|
872
|
+
if err := syncBoth(opened.parentFD, scratchFD); err != nil {
|
|
873
|
+
return response{}, err
|
|
874
|
+
}
|
|
875
|
+
if input.TestCrashAfterExtraction {
|
|
876
|
+
os.Exit(86)
|
|
877
|
+
}
|
|
878
|
+
if input.TestPauseAfterExtractionMillis > 0 {
|
|
879
|
+
time.Sleep(time.Duration(input.TestPauseAfterExtractionMillis) * time.Millisecond)
|
|
880
|
+
}
|
|
881
|
+
anchored, err := reopenParent(input, opened.dev, opened.ino)
|
|
882
|
+
if err != nil {
|
|
883
|
+
return response{}, err
|
|
884
|
+
}
|
|
885
|
+
if !anchored {
|
|
886
|
+
return rollbackExtracted(
|
|
887
|
+
opened,
|
|
888
|
+
scratchFD,
|
|
889
|
+
scratch,
|
|
890
|
+
input,
|
|
891
|
+
"repository parent changed during apply",
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
writable, err := hasWritableOpen(
|
|
895
|
+
scratchFD,
|
|
896
|
+
"source",
|
|
897
|
+
filepath.Join(opened.parentAbs, scratch, "source"),
|
|
898
|
+
)
|
|
899
|
+
if err != nil {
|
|
900
|
+
return response{}, fmt.Errorf("inspect extracted source writers: %w", err)
|
|
901
|
+
}
|
|
902
|
+
if writable {
|
|
903
|
+
return rollbackExtracted(
|
|
904
|
+
opened,
|
|
905
|
+
scratchFD,
|
|
906
|
+
scratch,
|
|
907
|
+
input,
|
|
908
|
+
"pre-existing writer still owns the source inode",
|
|
909
|
+
)
|
|
910
|
+
}
|
|
911
|
+
held, err := identityAt(scratchFD, "source")
|
|
912
|
+
if err != nil {
|
|
913
|
+
return response{}, err
|
|
914
|
+
}
|
|
915
|
+
if !identitiesEqual(held, input.Expected) {
|
|
916
|
+
return rollbackExtracted(
|
|
917
|
+
opened,
|
|
918
|
+
scratchFD,
|
|
919
|
+
scratch,
|
|
920
|
+
input,
|
|
921
|
+
"source changed before ordered apply",
|
|
922
|
+
)
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
anchored, err := reopenParent(input, opened.dev, opened.ino)
|
|
927
|
+
if err != nil {
|
|
928
|
+
return response{}, err
|
|
929
|
+
}
|
|
930
|
+
if !anchored {
|
|
931
|
+
if input.Expected != nil {
|
|
932
|
+
return rollbackExtracted(
|
|
933
|
+
opened,
|
|
934
|
+
scratchFD,
|
|
935
|
+
scratch,
|
|
936
|
+
input,
|
|
937
|
+
"repository parent changed during apply",
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
941
|
+
return response{}, err
|
|
942
|
+
}
|
|
943
|
+
return response{Status: "changed", Reason: "repository parent changed during create"}, nil
|
|
944
|
+
}
|
|
945
|
+
if input.TestPauseBeforeInstallMillis > 0 {
|
|
946
|
+
time.Sleep(time.Duration(input.TestPauseBeforeInstallMillis) * time.Millisecond)
|
|
947
|
+
}
|
|
948
|
+
if input.TestCrashBeforeInstall {
|
|
949
|
+
os.Exit(87)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if input.Target != nil {
|
|
953
|
+
if err := moveNoReplace(scratchFD, "target", opened.parentFD, opened.base); err != nil {
|
|
954
|
+
if errors.Is(err, unix.EEXIST) {
|
|
955
|
+
if input.Expected != nil {
|
|
956
|
+
return rollbackExtracted(
|
|
957
|
+
opened,
|
|
958
|
+
scratchFD,
|
|
959
|
+
scratch,
|
|
960
|
+
input,
|
|
961
|
+
"destination changed before exclusive install",
|
|
962
|
+
)
|
|
963
|
+
}
|
|
964
|
+
if cleanupErr := cleanupScratch(opened.parentFD, scratchFD, scratch); cleanupErr != nil {
|
|
965
|
+
return response{}, cleanupErr
|
|
966
|
+
}
|
|
967
|
+
return response{Status: "changed", Reason: "destination changed before exclusive install"}, nil
|
|
968
|
+
}
|
|
969
|
+
return response{}, fmt.Errorf("install target: %w", err)
|
|
970
|
+
}
|
|
971
|
+
if err := syncBoth(opened.parentFD, scratchFD); err != nil {
|
|
972
|
+
return response{}, fmt.Errorf("sync installed target: %w", err)
|
|
973
|
+
}
|
|
974
|
+
if input.TestCrashAfterInstall {
|
|
975
|
+
os.Exit(88)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
anchored, err = reopenParent(input, opened.dev, opened.ino)
|
|
980
|
+
if err != nil {
|
|
981
|
+
return response{}, err
|
|
982
|
+
}
|
|
983
|
+
if !anchored {
|
|
984
|
+
if input.Expected != nil {
|
|
985
|
+
return restoreSourceOverTarget(
|
|
986
|
+
opened,
|
|
987
|
+
scratchFD,
|
|
988
|
+
scratch,
|
|
989
|
+
input,
|
|
990
|
+
"repository parent changed during target install",
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
if input.Target != nil {
|
|
994
|
+
_ = unix.Unlinkat(opened.parentFD, opened.base, 0)
|
|
995
|
+
_ = unix.Fsync(opened.parentFD)
|
|
996
|
+
}
|
|
997
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
998
|
+
return response{}, err
|
|
999
|
+
}
|
|
1000
|
+
return response{Status: "changed", Reason: "repository parent changed during target install"}, nil
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if input.Target == nil {
|
|
1004
|
+
if err := writeMarker(scratchFD, deletedMarker); err != nil {
|
|
1005
|
+
return response{}, fmt.Errorf("record ordered deletion: %w", err)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// The scratch directory is the receipt. It survives the successful
|
|
1009
|
+
// transaction and is removed only after the caller durably advances its
|
|
1010
|
+
// verified cursor.
|
|
1011
|
+
return response{Status: "applied"}, nil
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
func gitPreparedPath(id string) string {
|
|
1015
|
+
return gitReceiptDir + "/" + id + ".target"
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
func gitClaimedName(id string) string {
|
|
1019
|
+
return id + ".claimed"
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
func gitLockName(input request) string {
|
|
1023
|
+
return input.Path + ".lock"
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
func openGitReceipt(input request) (*openedParent, error) {
|
|
1027
|
+
receipt, err := openParent(input.Root, gitPreparedPath(input.ID), false)
|
|
1028
|
+
if err != nil {
|
|
1029
|
+
return nil, err
|
|
1030
|
+
}
|
|
1031
|
+
if err := verifyRootIdentity(receipt, input); err != nil {
|
|
1032
|
+
receipt.close()
|
|
1033
|
+
return nil, err
|
|
1034
|
+
}
|
|
1035
|
+
return receipt, nil
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
func sameInode(
|
|
1039
|
+
leftFD int,
|
|
1040
|
+
leftName string,
|
|
1041
|
+
rightFD int,
|
|
1042
|
+
rightName string,
|
|
1043
|
+
) (same bool, rightExists bool, err error) {
|
|
1044
|
+
var left unix.Stat_t
|
|
1045
|
+
if err := unix.Fstatat(leftFD, leftName, &left, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
1046
|
+
return false, false, fmt.Errorf("stat receipt %q: %w", leftName, err)
|
|
1047
|
+
}
|
|
1048
|
+
var right unix.Stat_t
|
|
1049
|
+
if err := unix.Fstatat(rightFD, rightName, &right, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
1050
|
+
if errors.Is(err, unix.ENOENT) {
|
|
1051
|
+
return false, false, nil
|
|
1052
|
+
}
|
|
1053
|
+
return false, false, fmt.Errorf("stat lock %q: %w", rightName, err)
|
|
1054
|
+
}
|
|
1055
|
+
return left.Dev == right.Dev && left.Ino == right.Ino, true, nil
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
func syncEntry(dirFD int, name string) error {
|
|
1059
|
+
fd, err := unix.Openat(
|
|
1060
|
+
dirFD,
|
|
1061
|
+
name,
|
|
1062
|
+
unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
1063
|
+
0,
|
|
1064
|
+
)
|
|
1065
|
+
if err != nil {
|
|
1066
|
+
return err
|
|
1067
|
+
}
|
|
1068
|
+
if err := unix.Fsync(fd); err != nil {
|
|
1069
|
+
_ = unix.Close(fd)
|
|
1070
|
+
return err
|
|
1071
|
+
}
|
|
1072
|
+
return unix.Close(fd)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
func cleanupGitReceipt(receipt *openedParent, input request) error {
|
|
1076
|
+
for _, name := range []string{receipt.base, gitClaimedName(input.ID)} {
|
|
1077
|
+
if err := removeAt(receipt.parentFD, name, 0); err != nil {
|
|
1078
|
+
return fmt.Errorf("remove Git metadata receipt %q: %w", name, err)
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if err := unix.Fsync(receipt.parentFD); err != nil {
|
|
1082
|
+
return fmt.Errorf("sync Git metadata receipt removal: %w", err)
|
|
1083
|
+
}
|
|
1084
|
+
return nil
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
func gitLockCrossed(
|
|
1088
|
+
destination *openedParent,
|
|
1089
|
+
receipt *openedParent,
|
|
1090
|
+
input request,
|
|
1091
|
+
) (bool, error) {
|
|
1092
|
+
claimed, err := existsAt(receipt.parentFD, gitClaimedName(input.ID))
|
|
1093
|
+
if err != nil {
|
|
1094
|
+
return false, err
|
|
1095
|
+
}
|
|
1096
|
+
if !claimed {
|
|
1097
|
+
return false, nil
|
|
1098
|
+
}
|
|
1099
|
+
same, _, err := sameInode(
|
|
1100
|
+
receipt.parentFD,
|
|
1101
|
+
gitClaimedName(input.ID),
|
|
1102
|
+
destination.parentFD,
|
|
1103
|
+
gitLockName(input),
|
|
1104
|
+
)
|
|
1105
|
+
if err != nil {
|
|
1106
|
+
return false, err
|
|
1107
|
+
}
|
|
1108
|
+
// The claimed hard link survives the canonical lock's rename. A missing
|
|
1109
|
+
// lock, or a later lock with another inode, therefore proves this
|
|
1110
|
+
// operation crossed without consulting the destination's current bytes.
|
|
1111
|
+
return !same, nil
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
func removeOwnedGitLock(
|
|
1115
|
+
destination *openedParent,
|
|
1116
|
+
receipt *openedParent,
|
|
1117
|
+
input request,
|
|
1118
|
+
) error {
|
|
1119
|
+
lock := gitLockName(input)
|
|
1120
|
+
for _, receiptName := range []string{gitClaimedName(input.ID), receipt.base} {
|
|
1121
|
+
exists, err := existsAt(receipt.parentFD, receiptName)
|
|
1122
|
+
if err != nil {
|
|
1123
|
+
return err
|
|
1124
|
+
}
|
|
1125
|
+
if !exists {
|
|
1126
|
+
continue
|
|
1127
|
+
}
|
|
1128
|
+
same, _, err := sameInode(
|
|
1129
|
+
receipt.parentFD,
|
|
1130
|
+
receiptName,
|
|
1131
|
+
destination.parentFD,
|
|
1132
|
+
lock,
|
|
1133
|
+
)
|
|
1134
|
+
if err != nil {
|
|
1135
|
+
return err
|
|
1136
|
+
}
|
|
1137
|
+
if same {
|
|
1138
|
+
if err := removeAt(destination.parentFD, lock, 0); err != nil {
|
|
1139
|
+
return fmt.Errorf("remove retained Git lock: %w", err)
|
|
1140
|
+
}
|
|
1141
|
+
return unix.Fsync(destination.parentFD)
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return nil
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// applyGitLock uses the same lock name Git itself uses. The target is prepared
|
|
1148
|
+
// durably in the per-worktree Git directory before this process starts. An
|
|
1149
|
+
// exclusive hard link claims index.lock/HEAD.lock without copying large index
|
|
1150
|
+
// bytes through JSON. Renaming that lock over the destination is the one
|
|
1151
|
+
// crossing step; the retained hard link is its operation-owned receipt.
|
|
1152
|
+
func applyGitLock(input request) (response, error) {
|
|
1153
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1154
|
+
if err != nil {
|
|
1155
|
+
return response{}, err
|
|
1156
|
+
}
|
|
1157
|
+
defer destination.close()
|
|
1158
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1159
|
+
return response{}, err
|
|
1160
|
+
}
|
|
1161
|
+
receipt, err := openGitReceipt(input)
|
|
1162
|
+
if err != nil {
|
|
1163
|
+
return response{}, fmt.Errorf("open prepared Git metadata: %w", err)
|
|
1164
|
+
}
|
|
1165
|
+
defer receipt.close()
|
|
1166
|
+
|
|
1167
|
+
prepared, err := identityAt(receipt.parentFD, receipt.base)
|
|
1168
|
+
if err != nil {
|
|
1169
|
+
return response{}, err
|
|
1170
|
+
}
|
|
1171
|
+
targetIdentity, err := requestedTargetIdentity(input.Target)
|
|
1172
|
+
if err != nil {
|
|
1173
|
+
return response{}, err
|
|
1174
|
+
}
|
|
1175
|
+
if !identitiesEqual(prepared, targetIdentity) {
|
|
1176
|
+
return response{}, errors.New("prepared Git metadata does not match target identity")
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
lock := gitLockName(input)
|
|
1180
|
+
if err := unix.Linkat(
|
|
1181
|
+
receipt.parentFD,
|
|
1182
|
+
receipt.base,
|
|
1183
|
+
destination.parentFD,
|
|
1184
|
+
lock,
|
|
1185
|
+
0,
|
|
1186
|
+
); err != nil {
|
|
1187
|
+
if errors.Is(err, unix.EEXIST) {
|
|
1188
|
+
return response{Status: "pending", Reason: "Git metadata lock is already held"}, nil
|
|
1189
|
+
}
|
|
1190
|
+
return response{}, fmt.Errorf("claim Git metadata lock: %w", err)
|
|
1191
|
+
}
|
|
1192
|
+
if err := syncEntry(destination.parentFD, lock); err != nil {
|
|
1193
|
+
return response{}, fmt.Errorf("sync Git metadata lock: %w", err)
|
|
1194
|
+
}
|
|
1195
|
+
if err := unix.Fsync(destination.parentFD); err != nil {
|
|
1196
|
+
return response{}, fmt.Errorf("sync claimed Git metadata lock: %w", err)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
current, err := identityAt(destination.parentFD, destination.base)
|
|
1200
|
+
if err != nil {
|
|
1201
|
+
return response{}, err
|
|
1202
|
+
}
|
|
1203
|
+
if !identitiesEqual(current, input.Expected) {
|
|
1204
|
+
if err := removeOwnedGitLock(destination, receipt, input); err != nil {
|
|
1205
|
+
return response{}, err
|
|
1206
|
+
}
|
|
1207
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1208
|
+
return response{}, err
|
|
1209
|
+
}
|
|
1210
|
+
return response{Status: "changed", Reason: "Git metadata changed before ordered apply"}, nil
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if err := unix.Linkat(
|
|
1214
|
+
destination.parentFD,
|
|
1215
|
+
lock,
|
|
1216
|
+
receipt.parentFD,
|
|
1217
|
+
gitClaimedName(input.ID),
|
|
1218
|
+
0,
|
|
1219
|
+
); err != nil {
|
|
1220
|
+
return response{}, fmt.Errorf("record claimed Git metadata lock: %w", err)
|
|
1221
|
+
}
|
|
1222
|
+
if err := unix.Fsync(receipt.parentFD); err != nil {
|
|
1223
|
+
return response{}, fmt.Errorf("sync claimed Git metadata receipt: %w", err)
|
|
1224
|
+
}
|
|
1225
|
+
if input.TestCrashBeforeInstall {
|
|
1226
|
+
os.Exit(87)
|
|
1227
|
+
}
|
|
1228
|
+
if err := unix.Renameat(
|
|
1229
|
+
destination.parentFD,
|
|
1230
|
+
lock,
|
|
1231
|
+
destination.parentFD,
|
|
1232
|
+
destination.base,
|
|
1233
|
+
); err != nil {
|
|
1234
|
+
return response{}, fmt.Errorf("install Git metadata target: %w", err)
|
|
1235
|
+
}
|
|
1236
|
+
if err := unix.Fsync(destination.parentFD); err != nil {
|
|
1237
|
+
return response{}, fmt.Errorf("sync installed Git metadata: %w", err)
|
|
1238
|
+
}
|
|
1239
|
+
if input.TestCrashAfterInstall {
|
|
1240
|
+
os.Exit(88)
|
|
1241
|
+
}
|
|
1242
|
+
return response{Status: "applied"}, nil
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// guardGitLock retains Git's canonical HEAD.lock while a branch-ref
|
|
1246
|
+
// transaction runs. Exact HEAD bytes are verified only after the lock is
|
|
1247
|
+
// owned, so no checkout or symbolic-ref change can enter between that proof
|
|
1248
|
+
// and the guarded ref movement.
|
|
1249
|
+
func guardGitLock(input request) (response, error) {
|
|
1250
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1251
|
+
if err != nil {
|
|
1252
|
+
return response{}, err
|
|
1253
|
+
}
|
|
1254
|
+
defer destination.close()
|
|
1255
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1256
|
+
return response{}, err
|
|
1257
|
+
}
|
|
1258
|
+
receipt, err := openGitReceipt(input)
|
|
1259
|
+
if err != nil {
|
|
1260
|
+
return response{}, fmt.Errorf("open prepared Git guard: %w", err)
|
|
1261
|
+
}
|
|
1262
|
+
defer receipt.close()
|
|
1263
|
+
|
|
1264
|
+
prepared, err := identityAt(receipt.parentFD, receipt.base)
|
|
1265
|
+
if err != nil {
|
|
1266
|
+
return response{}, err
|
|
1267
|
+
}
|
|
1268
|
+
targetIdentity, err := requestedTargetIdentity(input.Target)
|
|
1269
|
+
if err != nil {
|
|
1270
|
+
return response{}, err
|
|
1271
|
+
}
|
|
1272
|
+
if !identitiesEqual(prepared, targetIdentity) {
|
|
1273
|
+
return response{}, errors.New("prepared Git guard does not match expected HEAD")
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
lock := gitLockName(input)
|
|
1277
|
+
if err := unix.Linkat(
|
|
1278
|
+
receipt.parentFD,
|
|
1279
|
+
receipt.base,
|
|
1280
|
+
destination.parentFD,
|
|
1281
|
+
lock,
|
|
1282
|
+
0,
|
|
1283
|
+
); err != nil {
|
|
1284
|
+
if errors.Is(err, unix.EEXIST) {
|
|
1285
|
+
return response{Status: "pending", Reason: "Git HEAD lock is already held"}, nil
|
|
1286
|
+
}
|
|
1287
|
+
return response{}, fmt.Errorf("claim Git HEAD guard: %w", err)
|
|
1288
|
+
}
|
|
1289
|
+
if err := syncEntry(destination.parentFD, lock); err != nil {
|
|
1290
|
+
return response{}, fmt.Errorf("sync Git HEAD guard: %w", err)
|
|
1291
|
+
}
|
|
1292
|
+
if err := unix.Fsync(destination.parentFD); err != nil {
|
|
1293
|
+
return response{}, fmt.Errorf("sync claimed Git HEAD guard: %w", err)
|
|
1294
|
+
}
|
|
1295
|
+
if err := unix.Linkat(
|
|
1296
|
+
destination.parentFD,
|
|
1297
|
+
lock,
|
|
1298
|
+
receipt.parentFD,
|
|
1299
|
+
gitClaimedName(input.ID),
|
|
1300
|
+
0,
|
|
1301
|
+
); err != nil {
|
|
1302
|
+
return response{}, fmt.Errorf("record claimed Git HEAD guard: %w", err)
|
|
1303
|
+
}
|
|
1304
|
+
if err := unix.Fsync(receipt.parentFD); err != nil {
|
|
1305
|
+
return response{}, fmt.Errorf("sync claimed Git HEAD guard receipt: %w", err)
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
current, err := identityAt(destination.parentFD, destination.base)
|
|
1309
|
+
if err != nil {
|
|
1310
|
+
return response{}, err
|
|
1311
|
+
}
|
|
1312
|
+
if !identitiesEqual(current, input.Expected) {
|
|
1313
|
+
if err := removeOwnedGitLock(destination, receipt, input); err != nil {
|
|
1314
|
+
return response{}, err
|
|
1315
|
+
}
|
|
1316
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1317
|
+
return response{}, err
|
|
1318
|
+
}
|
|
1319
|
+
return response{Status: "changed", Reason: "HEAD changed before branch guard"}, nil
|
|
1320
|
+
}
|
|
1321
|
+
return response{Status: "guarded"}, nil
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
func releaseGitGuard(input request) (response, error) {
|
|
1325
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1326
|
+
if err != nil {
|
|
1327
|
+
return response{Status: "released"}, nil
|
|
1328
|
+
}
|
|
1329
|
+
defer destination.close()
|
|
1330
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1331
|
+
return response{}, err
|
|
1332
|
+
}
|
|
1333
|
+
receipt, err := openGitReceipt(input)
|
|
1334
|
+
if err != nil {
|
|
1335
|
+
return response{Status: "released"}, nil
|
|
1336
|
+
}
|
|
1337
|
+
defer receipt.close()
|
|
1338
|
+
if err := removeOwnedGitLock(destination, receipt, input); err != nil {
|
|
1339
|
+
return response{}, err
|
|
1340
|
+
}
|
|
1341
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1342
|
+
return response{}, err
|
|
1343
|
+
}
|
|
1344
|
+
return response{Status: "released"}, nil
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// guardGitWorktreeNamespace atomically exchanges the common `worktrees`
|
|
1348
|
+
// directory with an operation-owned regular file. Stock Git therefore
|
|
1349
|
+
// cannot register a new linked worktree until the deletion transaction
|
|
1350
|
+
// finishes, while the complete original directory remains recoverable at
|
|
1351
|
+
// the prepared receipt path.
|
|
1352
|
+
func guardGitWorktreeNamespace(input request) (response, error) {
|
|
1353
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1354
|
+
if err != nil {
|
|
1355
|
+
return response{}, err
|
|
1356
|
+
}
|
|
1357
|
+
defer destination.close()
|
|
1358
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1359
|
+
return response{}, err
|
|
1360
|
+
}
|
|
1361
|
+
receipt, err := openGitReceipt(input)
|
|
1362
|
+
if err != nil {
|
|
1363
|
+
return response{}, fmt.Errorf("open prepared worktree namespace guard: %w", err)
|
|
1364
|
+
}
|
|
1365
|
+
defer receipt.close()
|
|
1366
|
+
|
|
1367
|
+
current, err := identityAt(destination.parentFD, destination.base)
|
|
1368
|
+
if err != nil {
|
|
1369
|
+
return response{}, err
|
|
1370
|
+
}
|
|
1371
|
+
if !identitiesEqual(current, input.Expected) {
|
|
1372
|
+
return response{Status: "changed", Reason: "Git worktree namespace changed before guard"}, nil
|
|
1373
|
+
}
|
|
1374
|
+
prepared, err := identityAt(receipt.parentFD, receipt.base)
|
|
1375
|
+
if err != nil {
|
|
1376
|
+
return response{}, err
|
|
1377
|
+
}
|
|
1378
|
+
targetIdentity, err := requestedTargetIdentity(input.Target)
|
|
1379
|
+
if err != nil {
|
|
1380
|
+
return response{}, err
|
|
1381
|
+
}
|
|
1382
|
+
if !identitiesEqual(prepared, targetIdentity) {
|
|
1383
|
+
return response{}, errors.New("prepared worktree namespace marker does not match target")
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if err := unix.Linkat(
|
|
1387
|
+
receipt.parentFD,
|
|
1388
|
+
receipt.base,
|
|
1389
|
+
receipt.parentFD,
|
|
1390
|
+
gitClaimedName(input.ID),
|
|
1391
|
+
0,
|
|
1392
|
+
); err != nil {
|
|
1393
|
+
if !errors.Is(err, unix.EEXIST) {
|
|
1394
|
+
return response{}, fmt.Errorf("record claimed worktree namespace guard: %w", err)
|
|
1395
|
+
}
|
|
1396
|
+
same, _, sameErr := sameInode(
|
|
1397
|
+
receipt.parentFD,
|
|
1398
|
+
receipt.base,
|
|
1399
|
+
receipt.parentFD,
|
|
1400
|
+
gitClaimedName(input.ID),
|
|
1401
|
+
)
|
|
1402
|
+
if sameErr != nil {
|
|
1403
|
+
return response{}, sameErr
|
|
1404
|
+
}
|
|
1405
|
+
if !same {
|
|
1406
|
+
return response{}, errors.New("worktree namespace guard claim belongs to another inode")
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if err := unix.Fsync(receipt.parentFD); err != nil {
|
|
1410
|
+
return response{}, fmt.Errorf("sync claimed worktree namespace guard: %w", err)
|
|
1411
|
+
}
|
|
1412
|
+
if err := exchangeEntries(
|
|
1413
|
+
destination.parentFD,
|
|
1414
|
+
destination.base,
|
|
1415
|
+
receipt.parentFD,
|
|
1416
|
+
receipt.base,
|
|
1417
|
+
); err != nil {
|
|
1418
|
+
return response{}, fmt.Errorf("guard Git worktree namespace: %w", err)
|
|
1419
|
+
}
|
|
1420
|
+
if err := unix.Fsync(destination.parentFD); err != nil {
|
|
1421
|
+
return response{}, fmt.Errorf("sync guarded Git worktree namespace: %w", err)
|
|
1422
|
+
}
|
|
1423
|
+
if err := unix.Fsync(receipt.parentFD); err != nil {
|
|
1424
|
+
return response{}, fmt.Errorf("sync retained Git worktree namespace: %w", err)
|
|
1425
|
+
}
|
|
1426
|
+
same, _, err := sameInode(
|
|
1427
|
+
receipt.parentFD,
|
|
1428
|
+
gitClaimedName(input.ID),
|
|
1429
|
+
destination.parentFD,
|
|
1430
|
+
destination.base,
|
|
1431
|
+
)
|
|
1432
|
+
if err != nil {
|
|
1433
|
+
return response{}, err
|
|
1434
|
+
}
|
|
1435
|
+
if !same {
|
|
1436
|
+
return response{}, errors.New("Git worktree namespace guard did not retain its marker")
|
|
1437
|
+
}
|
|
1438
|
+
return response{Status: "guarded"}, nil
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
func releaseGitWorktreeNamespace(input request) (response, error) {
|
|
1442
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1443
|
+
if err != nil {
|
|
1444
|
+
return response{}, fmt.Errorf("open guarded worktree namespace: %w", err)
|
|
1445
|
+
}
|
|
1446
|
+
defer destination.close()
|
|
1447
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1448
|
+
return response{}, err
|
|
1449
|
+
}
|
|
1450
|
+
receipt, err := openGitReceipt(input)
|
|
1451
|
+
if err != nil {
|
|
1452
|
+
return response{Status: "released"}, nil
|
|
1453
|
+
}
|
|
1454
|
+
defer receipt.close()
|
|
1455
|
+
claimed, err := existsAt(receipt.parentFD, gitClaimedName(input.ID))
|
|
1456
|
+
if err != nil {
|
|
1457
|
+
return response{}, err
|
|
1458
|
+
}
|
|
1459
|
+
prepared, err := existsAt(receipt.parentFD, receipt.base)
|
|
1460
|
+
if err != nil {
|
|
1461
|
+
return response{}, err
|
|
1462
|
+
}
|
|
1463
|
+
if !claimed && !prepared {
|
|
1464
|
+
return response{Status: "released"}, nil
|
|
1465
|
+
}
|
|
1466
|
+
if !claimed && prepared {
|
|
1467
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1468
|
+
return response{}, err
|
|
1469
|
+
}
|
|
1470
|
+
return response{Status: "released"}, nil
|
|
1471
|
+
}
|
|
1472
|
+
if !prepared {
|
|
1473
|
+
current, err := identityAt(destination.parentFD, destination.base)
|
|
1474
|
+
if err != nil {
|
|
1475
|
+
return response{}, err
|
|
1476
|
+
}
|
|
1477
|
+
if !identitiesEqual(current, input.Expected) {
|
|
1478
|
+
return response{}, errors.New("worktree namespace guard receipt is incomplete")
|
|
1479
|
+
}
|
|
1480
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1481
|
+
return response{}, err
|
|
1482
|
+
}
|
|
1483
|
+
return response{Status: "released"}, nil
|
|
1484
|
+
}
|
|
1485
|
+
guarded, _, err := sameInode(
|
|
1486
|
+
receipt.parentFD,
|
|
1487
|
+
gitClaimedName(input.ID),
|
|
1488
|
+
destination.parentFD,
|
|
1489
|
+
destination.base,
|
|
1490
|
+
)
|
|
1491
|
+
if err != nil {
|
|
1492
|
+
return response{}, err
|
|
1493
|
+
}
|
|
1494
|
+
if guarded {
|
|
1495
|
+
retained, err := identityAt(receipt.parentFD, receipt.base)
|
|
1496
|
+
if err != nil {
|
|
1497
|
+
return response{}, err
|
|
1498
|
+
}
|
|
1499
|
+
if retained == nil || retained.Kind != "directory" {
|
|
1500
|
+
return response{}, errors.New("retained Git worktree namespace is not a directory")
|
|
1501
|
+
}
|
|
1502
|
+
if err := exchangeEntries(
|
|
1503
|
+
destination.parentFD,
|
|
1504
|
+
destination.base,
|
|
1505
|
+
receipt.parentFD,
|
|
1506
|
+
receipt.base,
|
|
1507
|
+
); err != nil {
|
|
1508
|
+
return response{}, fmt.Errorf("restore Git worktree namespace: %w", err)
|
|
1509
|
+
}
|
|
1510
|
+
if err := unix.Fsync(destination.parentFD); err != nil {
|
|
1511
|
+
return response{}, fmt.Errorf("sync restored Git worktree namespace: %w", err)
|
|
1512
|
+
}
|
|
1513
|
+
if err := unix.Fsync(receipt.parentFD); err != nil {
|
|
1514
|
+
return response{}, fmt.Errorf("sync released worktree namespace marker: %w", err)
|
|
1515
|
+
}
|
|
1516
|
+
} else {
|
|
1517
|
+
current, err := identityAt(destination.parentFD, destination.base)
|
|
1518
|
+
if err != nil {
|
|
1519
|
+
return response{}, err
|
|
1520
|
+
}
|
|
1521
|
+
retainedMarker, _, err := sameInode(
|
|
1522
|
+
receipt.parentFD,
|
|
1523
|
+
gitClaimedName(input.ID),
|
|
1524
|
+
receipt.parentFD,
|
|
1525
|
+
receipt.base,
|
|
1526
|
+
)
|
|
1527
|
+
if err != nil {
|
|
1528
|
+
return response{}, err
|
|
1529
|
+
}
|
|
1530
|
+
if current == nil || current.Kind != "directory" || !retainedMarker {
|
|
1531
|
+
return response{}, errors.New("Git worktree namespace changed outside its guard")
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1535
|
+
return response{}, err
|
|
1536
|
+
}
|
|
1537
|
+
return response{Status: "released"}, nil
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
func recoverGitLock(input request) (response, error) {
|
|
1541
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1542
|
+
if err != nil {
|
|
1543
|
+
return response{Status: "pending", Reason: "Git metadata path is not safely reachable"}, nil
|
|
1544
|
+
}
|
|
1545
|
+
defer destination.close()
|
|
1546
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1547
|
+
return response{Status: "pending", Reason: err.Error()}, nil
|
|
1548
|
+
}
|
|
1549
|
+
receipt, err := openGitReceipt(input)
|
|
1550
|
+
if err != nil {
|
|
1551
|
+
return response{Status: "changed", Reason: "operation has no Git metadata receipt"}, nil
|
|
1552
|
+
}
|
|
1553
|
+
defer receipt.close()
|
|
1554
|
+
crossed, err := gitLockCrossed(destination, receipt, input)
|
|
1555
|
+
if err != nil {
|
|
1556
|
+
return response{}, err
|
|
1557
|
+
}
|
|
1558
|
+
if crossed {
|
|
1559
|
+
return response{Status: "applied"}, nil
|
|
1560
|
+
}
|
|
1561
|
+
if err := removeOwnedGitLock(destination, receipt, input); err != nil {
|
|
1562
|
+
return response{}, err
|
|
1563
|
+
}
|
|
1564
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1565
|
+
return response{}, err
|
|
1566
|
+
}
|
|
1567
|
+
return response{Status: "changed", Reason: "Git metadata operation stopped before crossing"}, nil
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
func readGitLockReceipt(input request) (response, error) {
|
|
1571
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1572
|
+
if err != nil {
|
|
1573
|
+
return response{Status: "not-crossed"}, nil
|
|
1574
|
+
}
|
|
1575
|
+
defer destination.close()
|
|
1576
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1577
|
+
return response{Status: "not-crossed"}, nil
|
|
1578
|
+
}
|
|
1579
|
+
receipt, err := openGitReceipt(input)
|
|
1580
|
+
if err != nil {
|
|
1581
|
+
return response{Status: "not-crossed"}, nil
|
|
1582
|
+
}
|
|
1583
|
+
defer receipt.close()
|
|
1584
|
+
crossed, err := gitLockCrossed(destination, receipt, input)
|
|
1585
|
+
if err != nil {
|
|
1586
|
+
return response{}, err
|
|
1587
|
+
}
|
|
1588
|
+
if crossed {
|
|
1589
|
+
return response{Status: "crossed"}, nil
|
|
1590
|
+
}
|
|
1591
|
+
return response{Status: "not-crossed"}, nil
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
func releaseGitLockReceipt(input request) (response, error) {
|
|
1595
|
+
destination, err := openParent(input.Root, input.Path, false)
|
|
1596
|
+
if err != nil {
|
|
1597
|
+
return response{Status: "released"}, nil
|
|
1598
|
+
}
|
|
1599
|
+
defer destination.close()
|
|
1600
|
+
if err := verifyRootIdentity(destination, input); err != nil {
|
|
1601
|
+
return response{}, err
|
|
1602
|
+
}
|
|
1603
|
+
receipt, err := openGitReceipt(input)
|
|
1604
|
+
if err != nil {
|
|
1605
|
+
return response{Status: "released"}, nil
|
|
1606
|
+
}
|
|
1607
|
+
defer receipt.close()
|
|
1608
|
+
prepared, err := existsAt(receipt.parentFD, receipt.base)
|
|
1609
|
+
if err != nil {
|
|
1610
|
+
return response{}, err
|
|
1611
|
+
}
|
|
1612
|
+
claimed, err := existsAt(receipt.parentFD, gitClaimedName(input.ID))
|
|
1613
|
+
if err != nil {
|
|
1614
|
+
return response{}, err
|
|
1615
|
+
}
|
|
1616
|
+
// Native cleanup is durable before the JavaScript control record is
|
|
1617
|
+
// removed. A crash in that final gap leaves neither artifact; release is
|
|
1618
|
+
// therefore already complete and must remain idempotent.
|
|
1619
|
+
if !prepared && !claimed {
|
|
1620
|
+
return response{Status: "released"}, nil
|
|
1621
|
+
}
|
|
1622
|
+
crossed, err := gitLockCrossed(destination, receipt, input)
|
|
1623
|
+
if err != nil {
|
|
1624
|
+
return response{}, err
|
|
1625
|
+
}
|
|
1626
|
+
if !crossed {
|
|
1627
|
+
return response{Status: "pending", Reason: "operation has no Git metadata crossing receipt"}, nil
|
|
1628
|
+
}
|
|
1629
|
+
if err := cleanupGitReceipt(receipt, input); err != nil {
|
|
1630
|
+
return response{}, err
|
|
1631
|
+
}
|
|
1632
|
+
return response{Status: "released"}, nil
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
func rollbackExtracted(
|
|
1636
|
+
opened *openedParent,
|
|
1637
|
+
scratchFD int,
|
|
1638
|
+
scratch string,
|
|
1639
|
+
input request,
|
|
1640
|
+
reason string,
|
|
1641
|
+
) (response, error) {
|
|
1642
|
+
if err := linkEntry(scratchFD, "source", opened.parentFD, opened.base); err != nil {
|
|
1643
|
+
if errors.Is(err, unix.EEXIST) {
|
|
1644
|
+
return response{Status: "pending", Reason: reason + "; a newer destination also exists"}, nil
|
|
1645
|
+
}
|
|
1646
|
+
return response{}, fmt.Errorf("restore extracted source: %w", err)
|
|
1647
|
+
}
|
|
1648
|
+
if err := unix.Fsync(opened.parentFD); err != nil {
|
|
1649
|
+
return response{}, fmt.Errorf("sync restored source: %w", err)
|
|
1650
|
+
}
|
|
1651
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1652
|
+
return response{}, err
|
|
1653
|
+
}
|
|
1654
|
+
return response{Status: "changed", Reason: reason}, nil
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
func restoreSourceOverTarget(
|
|
1658
|
+
opened *openedParent,
|
|
1659
|
+
scratchFD int,
|
|
1660
|
+
scratch string,
|
|
1661
|
+
input request,
|
|
1662
|
+
reason string,
|
|
1663
|
+
) (response, error) {
|
|
1664
|
+
current, err := identityAt(opened.parentFD, opened.base)
|
|
1665
|
+
if err != nil {
|
|
1666
|
+
return response{}, err
|
|
1667
|
+
}
|
|
1668
|
+
targetIdentity, err := requestedTargetIdentity(input.Target)
|
|
1669
|
+
if err != nil {
|
|
1670
|
+
return response{}, err
|
|
1671
|
+
}
|
|
1672
|
+
if current != nil {
|
|
1673
|
+
if !identitiesEqual(current, targetIdentity) {
|
|
1674
|
+
return response{Status: "pending", Reason: reason + "; a newer destination also exists"}, nil
|
|
1675
|
+
}
|
|
1676
|
+
if err := unix.Renameat(opened.parentFD, opened.base, scratchFD, "current"); err != nil {
|
|
1677
|
+
return response{}, fmt.Errorf("retain installed target during rollback: %w", err)
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if err := linkEntry(scratchFD, "source", opened.parentFD, opened.base); err != nil {
|
|
1681
|
+
if current != nil {
|
|
1682
|
+
_ = unix.Renameat(scratchFD, "current", opened.parentFD, opened.base)
|
|
1683
|
+
}
|
|
1684
|
+
return response{}, fmt.Errorf("restore source over target: %w", err)
|
|
1685
|
+
}
|
|
1686
|
+
if err := unix.Fsync(opened.parentFD); err != nil {
|
|
1687
|
+
return response{}, fmt.Errorf("sync source rollback: %w", err)
|
|
1688
|
+
}
|
|
1689
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1690
|
+
return response{}, err
|
|
1691
|
+
}
|
|
1692
|
+
return response{Status: "changed", Reason: reason}, nil
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
func recoverTransaction(input request) (response, error) {
|
|
1696
|
+
opened, err := openParent(input.Root, input.Path, false)
|
|
1697
|
+
if err != nil {
|
|
1698
|
+
return response{Status: "pending", Reason: "recorded parent is not safely reachable"}, nil
|
|
1699
|
+
}
|
|
1700
|
+
defer opened.close()
|
|
1701
|
+
if err := verifyRootIdentity(opened, input); err != nil {
|
|
1702
|
+
return response{Status: "pending", Reason: err.Error()}, nil
|
|
1703
|
+
}
|
|
1704
|
+
scratch := scratchName(input.ID)
|
|
1705
|
+
scratchFD, err := unix.Openat(
|
|
1706
|
+
opened.parentFD,
|
|
1707
|
+
scratch,
|
|
1708
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
1709
|
+
0,
|
|
1710
|
+
)
|
|
1711
|
+
if errors.Is(err, unix.ENOENT) {
|
|
1712
|
+
return classifyWithoutScratch(opened, input)
|
|
1713
|
+
}
|
|
1714
|
+
if err != nil {
|
|
1715
|
+
return response{}, fmt.Errorf("open retained transaction scratch: %w", err)
|
|
1716
|
+
}
|
|
1717
|
+
defer unix.Close(scratchFD)
|
|
1718
|
+
|
|
1719
|
+
crossed, err := transactionCrossed(scratchFD, input)
|
|
1720
|
+
if err != nil {
|
|
1721
|
+
return response{}, err
|
|
1722
|
+
}
|
|
1723
|
+
if crossed {
|
|
1724
|
+
return response{Status: "applied"}, nil
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
source, err := identityAt(scratchFD, "source")
|
|
1728
|
+
if err != nil {
|
|
1729
|
+
return response{}, err
|
|
1730
|
+
}
|
|
1731
|
+
current, err := identityAt(opened.parentFD, opened.base)
|
|
1732
|
+
if err != nil {
|
|
1733
|
+
return response{}, err
|
|
1734
|
+
}
|
|
1735
|
+
targetIdentity, err := requestedTargetIdentity(input.Target)
|
|
1736
|
+
if err != nil {
|
|
1737
|
+
return response{}, err
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if source != nil {
|
|
1741
|
+
writable, err := hasWritableOpen(
|
|
1742
|
+
scratchFD,
|
|
1743
|
+
"source",
|
|
1744
|
+
filepath.Join(opened.parentAbs, scratch, "source"),
|
|
1745
|
+
)
|
|
1746
|
+
if err != nil {
|
|
1747
|
+
return response{}, err
|
|
1748
|
+
}
|
|
1749
|
+
sourceIsExpected := identitiesEqual(source, input.Expected)
|
|
1750
|
+
if writable || !sourceIsExpected {
|
|
1751
|
+
if current == nil {
|
|
1752
|
+
return rollbackExtracted(
|
|
1753
|
+
opened,
|
|
1754
|
+
scratchFD,
|
|
1755
|
+
scratch,
|
|
1756
|
+
input,
|
|
1757
|
+
"retained source is local or still writable",
|
|
1758
|
+
)
|
|
1759
|
+
}
|
|
1760
|
+
if identitiesEqual(current, targetIdentity) {
|
|
1761
|
+
return restoreSourceOverTarget(
|
|
1762
|
+
opened,
|
|
1763
|
+
scratchFD,
|
|
1764
|
+
scratch,
|
|
1765
|
+
input,
|
|
1766
|
+
"retained source is local or still writable",
|
|
1767
|
+
)
|
|
1768
|
+
}
|
|
1769
|
+
if !writable {
|
|
1770
|
+
sourceTime, sourceTimeErr := entryChangedAt(scratchFD, "source")
|
|
1771
|
+
if sourceTimeErr != nil {
|
|
1772
|
+
return response{}, sourceTimeErr
|
|
1773
|
+
}
|
|
1774
|
+
currentTime, currentTimeErr := entryChangedAt(opened.parentFD, opened.base)
|
|
1775
|
+
if currentTimeErr != nil {
|
|
1776
|
+
return response{}, currentTimeErr
|
|
1777
|
+
}
|
|
1778
|
+
if sourceTime > currentTime {
|
|
1779
|
+
return replaceCurrentWithSource(
|
|
1780
|
+
opened,
|
|
1781
|
+
scratchFD,
|
|
1782
|
+
scratch,
|
|
1783
|
+
"retained source is the later local write",
|
|
1784
|
+
)
|
|
1785
|
+
}
|
|
1786
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1787
|
+
return response{}, err
|
|
1788
|
+
}
|
|
1789
|
+
return response{
|
|
1790
|
+
Status: "changed",
|
|
1791
|
+
Reason: "visible destination is the later local write",
|
|
1792
|
+
}, nil
|
|
1793
|
+
}
|
|
1794
|
+
return response{
|
|
1795
|
+
Status: "pending",
|
|
1796
|
+
Reason: "retained source and visible destination are both local",
|
|
1797
|
+
}, nil
|
|
1798
|
+
}
|
|
1799
|
+
if identitiesEqual(current, targetIdentity) || (input.Target == nil && current == nil) {
|
|
1800
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1801
|
+
return response{}, err
|
|
1802
|
+
}
|
|
1803
|
+
return response{Status: "applied"}, nil
|
|
1804
|
+
}
|
|
1805
|
+
if current == nil {
|
|
1806
|
+
return rollbackExtracted(
|
|
1807
|
+
opened,
|
|
1808
|
+
scratchFD,
|
|
1809
|
+
scratch,
|
|
1810
|
+
input,
|
|
1811
|
+
"apply stopped before target installation",
|
|
1812
|
+
)
|
|
1813
|
+
}
|
|
1814
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1815
|
+
return response{}, err
|
|
1816
|
+
}
|
|
1817
|
+
return response{Status: "changed", Reason: "newer visible destination won interrupted apply"}, nil
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
if identitiesEqual(current, targetIdentity) || (input.Target == nil && current == nil) {
|
|
1821
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1822
|
+
return response{}, err
|
|
1823
|
+
}
|
|
1824
|
+
return response{Status: "applied"}, nil
|
|
1825
|
+
}
|
|
1826
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1827
|
+
return response{}, err
|
|
1828
|
+
}
|
|
1829
|
+
return response{Status: "changed", Reason: "visible destination superseded interrupted apply"}, nil
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
func readReceipt(input request) (response, error) {
|
|
1833
|
+
opened, err := openParent(input.Root, input.Path, false)
|
|
1834
|
+
if err != nil {
|
|
1835
|
+
return response{Status: "not-crossed"}, nil
|
|
1836
|
+
}
|
|
1837
|
+
defer opened.close()
|
|
1838
|
+
if err := verifyRootIdentity(opened, input); err != nil {
|
|
1839
|
+
return response{Status: "not-crossed"}, nil
|
|
1840
|
+
}
|
|
1841
|
+
scratchFD, err := unix.Openat(
|
|
1842
|
+
opened.parentFD,
|
|
1843
|
+
scratchName(input.ID),
|
|
1844
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
1845
|
+
0,
|
|
1846
|
+
)
|
|
1847
|
+
if errors.Is(err, unix.ENOENT) {
|
|
1848
|
+
return response{Status: "not-crossed"}, nil
|
|
1849
|
+
}
|
|
1850
|
+
if err != nil {
|
|
1851
|
+
return response{}, fmt.Errorf("open receipt artifact: %w", err)
|
|
1852
|
+
}
|
|
1853
|
+
defer unix.Close(scratchFD)
|
|
1854
|
+
crossed, err := transactionCrossed(scratchFD, input)
|
|
1855
|
+
if err != nil {
|
|
1856
|
+
return response{}, err
|
|
1857
|
+
}
|
|
1858
|
+
if crossed {
|
|
1859
|
+
return response{Status: "crossed"}, nil
|
|
1860
|
+
}
|
|
1861
|
+
return response{Status: "not-crossed"}, nil
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
func releaseReceipt(input request) (response, error) {
|
|
1865
|
+
opened, err := openParent(input.Root, input.Path, false)
|
|
1866
|
+
if err != nil {
|
|
1867
|
+
// A missing locator does not prove the operation-owned receipt is
|
|
1868
|
+
// absent. The physical parent may merely have moved and will be rebound
|
|
1869
|
+
// by the durable document plan. Keep that plan until the exact parent
|
|
1870
|
+
// can be reopened and the receipt is inspected.
|
|
1871
|
+
return response{
|
|
1872
|
+
Status: "pending",
|
|
1873
|
+
Reason: fmt.Sprintf("recorded receipt parent is unavailable: %v", err),
|
|
1874
|
+
}, nil
|
|
1875
|
+
}
|
|
1876
|
+
defer opened.close()
|
|
1877
|
+
if err := verifyRootIdentity(opened, input); err != nil {
|
|
1878
|
+
return response{}, err
|
|
1879
|
+
}
|
|
1880
|
+
scratch := scratchName(input.ID)
|
|
1881
|
+
scratchFD, err := unix.Openat(
|
|
1882
|
+
opened.parentFD,
|
|
1883
|
+
scratch,
|
|
1884
|
+
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
1885
|
+
0,
|
|
1886
|
+
)
|
|
1887
|
+
if errors.Is(err, unix.ENOENT) {
|
|
1888
|
+
return response{Status: "released"}, nil
|
|
1889
|
+
}
|
|
1890
|
+
if err != nil {
|
|
1891
|
+
return response{}, fmt.Errorf("open receipt artifact: %w", err)
|
|
1892
|
+
}
|
|
1893
|
+
defer unix.Close(scratchFD)
|
|
1894
|
+
crossed, err := transactionCrossed(scratchFD, input)
|
|
1895
|
+
if err != nil {
|
|
1896
|
+
return response{}, err
|
|
1897
|
+
}
|
|
1898
|
+
if !crossed {
|
|
1899
|
+
return response{Status: "pending", Reason: "operation has no crossing receipt"}, nil
|
|
1900
|
+
}
|
|
1901
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1902
|
+
return response{}, err
|
|
1903
|
+
}
|
|
1904
|
+
return response{Status: "released"}, nil
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
func transactionCrossed(scratchFD int, input request) (bool, error) {
|
|
1908
|
+
if input.Target == nil {
|
|
1909
|
+
deleted, err := existsAt(scratchFD, deletedMarker)
|
|
1910
|
+
if err != nil {
|
|
1911
|
+
return false, err
|
|
1912
|
+
}
|
|
1913
|
+
source, err := existsAt(scratchFD, "source")
|
|
1914
|
+
if err != nil {
|
|
1915
|
+
return false, err
|
|
1916
|
+
}
|
|
1917
|
+
return deleted && source, nil
|
|
1918
|
+
}
|
|
1919
|
+
prepared, err := existsAt(scratchFD, preparedMarker)
|
|
1920
|
+
if err != nil {
|
|
1921
|
+
return false, err
|
|
1922
|
+
}
|
|
1923
|
+
target, err := existsAt(scratchFD, "target")
|
|
1924
|
+
if err != nil {
|
|
1925
|
+
return false, err
|
|
1926
|
+
}
|
|
1927
|
+
return prepared && !target, nil
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
func replaceCurrentWithSource(
|
|
1931
|
+
opened *openedParent,
|
|
1932
|
+
scratchFD int,
|
|
1933
|
+
scratch string,
|
|
1934
|
+
reason string,
|
|
1935
|
+
) (response, error) {
|
|
1936
|
+
if err := unix.Renameat(opened.parentFD, opened.base, scratchFD, "current"); err != nil {
|
|
1937
|
+
return response{}, fmt.Errorf("retain older visible destination: %w", err)
|
|
1938
|
+
}
|
|
1939
|
+
if err := linkEntry(scratchFD, "source", opened.parentFD, opened.base); err != nil {
|
|
1940
|
+
_ = unix.Renameat(scratchFD, "current", opened.parentFD, opened.base)
|
|
1941
|
+
return response{}, fmt.Errorf("restore later retained source: %w", err)
|
|
1942
|
+
}
|
|
1943
|
+
if err := unix.Fsync(opened.parentFD); err != nil {
|
|
1944
|
+
return response{}, fmt.Errorf("sync later retained source: %w", err)
|
|
1945
|
+
}
|
|
1946
|
+
if err := cleanupScratch(opened.parentFD, scratchFD, scratch); err != nil {
|
|
1947
|
+
return response{}, err
|
|
1948
|
+
}
|
|
1949
|
+
return response{Status: "changed", Reason: reason}, nil
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
func classifyWithoutScratch(opened *openedParent, input request) (response, error) {
|
|
1953
|
+
return response{
|
|
1954
|
+
Status: "changed",
|
|
1955
|
+
Reason: "transaction has no operation-owned crossing receipt",
|
|
1956
|
+
}, nil
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
func requestedTargetIdentity(value *target) (*identity, error) {
|
|
1960
|
+
if value == nil {
|
|
1961
|
+
return nil, nil
|
|
1962
|
+
}
|
|
1963
|
+
return &identity{
|
|
1964
|
+
Kind: value.Kind,
|
|
1965
|
+
Exec: value.Kind == "file" && value.Exec,
|
|
1966
|
+
Bytes: value.Bytes,
|
|
1967
|
+
SHA256: value.SHA256,
|
|
1968
|
+
}, nil
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
func prepareTarget(scratchFD int, value target) error {
|
|
1972
|
+
if value.Content == nil {
|
|
1973
|
+
return errors.New("apply target content is missing")
|
|
1974
|
+
}
|
|
1975
|
+
content, err := base64.StdEncoding.DecodeString(*value.Content)
|
|
1976
|
+
if err != nil {
|
|
1977
|
+
return err
|
|
1978
|
+
}
|
|
1979
|
+
if value.Kind == "symlink" {
|
|
1980
|
+
if bytes.IndexByte(content, 0) >= 0 {
|
|
1981
|
+
return errors.New("symlink target contains NUL")
|
|
1982
|
+
}
|
|
1983
|
+
if err := unix.Symlinkat(string(content), scratchFD, "target"); err != nil {
|
|
1984
|
+
return fmt.Errorf("prepare target symlink: %w", err)
|
|
1985
|
+
}
|
|
1986
|
+
return unix.Fsync(scratchFD)
|
|
1987
|
+
}
|
|
1988
|
+
mode := uint32(0o644)
|
|
1989
|
+
if value.Exec {
|
|
1990
|
+
mode = 0o755
|
|
1991
|
+
}
|
|
1992
|
+
fd, err := unix.Openat(
|
|
1993
|
+
scratchFD,
|
|
1994
|
+
"target",
|
|
1995
|
+
unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
1996
|
+
mode,
|
|
1997
|
+
)
|
|
1998
|
+
if err != nil {
|
|
1999
|
+
return fmt.Errorf("prepare target file: %w", err)
|
|
2000
|
+
}
|
|
2001
|
+
file := os.NewFile(uintptr(fd), "target")
|
|
2002
|
+
if _, err := file.Write(content); err != nil {
|
|
2003
|
+
_ = file.Close()
|
|
2004
|
+
return fmt.Errorf("write target: %w", err)
|
|
2005
|
+
}
|
|
2006
|
+
if err := unix.Fchmod(fd, mode); err != nil {
|
|
2007
|
+
_ = file.Close()
|
|
2008
|
+
return fmt.Errorf("chmod target: %w", err)
|
|
2009
|
+
}
|
|
2010
|
+
if err := unix.Fsync(fd); err != nil {
|
|
2011
|
+
_ = file.Close()
|
|
2012
|
+
return fmt.Errorf("sync target: %w", err)
|
|
2013
|
+
}
|
|
2014
|
+
if err := file.Close(); err != nil {
|
|
2015
|
+
return fmt.Errorf("close target: %w", err)
|
|
2016
|
+
}
|
|
2017
|
+
return unix.Fsync(scratchFD)
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
func writeMarker(scratchFD int, name string) error {
|
|
2021
|
+
fd, err := unix.Openat(
|
|
2022
|
+
scratchFD,
|
|
2023
|
+
name,
|
|
2024
|
+
unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC,
|
|
2025
|
+
0o600,
|
|
2026
|
+
)
|
|
2027
|
+
if err != nil {
|
|
2028
|
+
return err
|
|
2029
|
+
}
|
|
2030
|
+
if err := unix.Fsync(fd); err != nil {
|
|
2031
|
+
_ = unix.Close(fd)
|
|
2032
|
+
return err
|
|
2033
|
+
}
|
|
2034
|
+
if err := unix.Close(fd); err != nil {
|
|
2035
|
+
return err
|
|
2036
|
+
}
|
|
2037
|
+
return unix.Fsync(scratchFD)
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
func existsAt(dirFD int, name string) (bool, error) {
|
|
2041
|
+
var stat unix.Stat_t
|
|
2042
|
+
if err := unix.Fstatat(dirFD, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
2043
|
+
if errors.Is(err, unix.ENOENT) {
|
|
2044
|
+
return false, nil
|
|
2045
|
+
}
|
|
2046
|
+
return false, err
|
|
2047
|
+
}
|
|
2048
|
+
return true, nil
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
func identityAt(dirFD int, name string) (*identity, error) {
|
|
2052
|
+
var stat unix.Stat_t
|
|
2053
|
+
if err := unix.Fstatat(dirFD, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
2054
|
+
if errors.Is(err, unix.ENOENT) {
|
|
2055
|
+
return nil, nil
|
|
2056
|
+
}
|
|
2057
|
+
return nil, fmt.Errorf("stat %q: %w", name, err)
|
|
2058
|
+
}
|
|
2059
|
+
switch uint32(stat.Mode) & unix.S_IFMT {
|
|
2060
|
+
case unix.S_IFDIR:
|
|
2061
|
+
return &identity{
|
|
2062
|
+
Kind: "directory",
|
|
2063
|
+
Dev: strconv.FormatUint(uint64(stat.Dev), 10),
|
|
2064
|
+
Ino: strconv.FormatUint(uint64(stat.Ino), 10),
|
|
2065
|
+
}, nil
|
|
2066
|
+
case unix.S_IFLNK:
|
|
2067
|
+
content, err := readlinkAt(dirFD, name)
|
|
2068
|
+
if err != nil {
|
|
2069
|
+
return nil, err
|
|
2070
|
+
}
|
|
2071
|
+
sum := sha256.Sum256(content)
|
|
2072
|
+
return &identity{
|
|
2073
|
+
Kind: "symlink",
|
|
2074
|
+
Bytes: int64(len(content)),
|
|
2075
|
+
SHA256: hex.EncodeToString(sum[:]),
|
|
2076
|
+
Dev: strconv.FormatUint(uint64(stat.Dev), 10),
|
|
2077
|
+
Ino: strconv.FormatUint(uint64(stat.Ino), 10),
|
|
2078
|
+
}, nil
|
|
2079
|
+
case unix.S_IFREG:
|
|
2080
|
+
fd, err := unix.Openat(dirFD, name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
|
2081
|
+
if err != nil {
|
|
2082
|
+
return nil, fmt.Errorf("open %q for identity: %w", name, err)
|
|
2083
|
+
}
|
|
2084
|
+
file := os.NewFile(uintptr(fd), name)
|
|
2085
|
+
hash := sha256.New()
|
|
2086
|
+
bytesRead, readErr := io.Copy(hash, file)
|
|
2087
|
+
closeErr := file.Close()
|
|
2088
|
+
if readErr != nil {
|
|
2089
|
+
return nil, fmt.Errorf("hash %q: %w", name, readErr)
|
|
2090
|
+
}
|
|
2091
|
+
if closeErr != nil {
|
|
2092
|
+
return nil, fmt.Errorf("close %q: %w", name, closeErr)
|
|
2093
|
+
}
|
|
2094
|
+
return &identity{
|
|
2095
|
+
Kind: "file",
|
|
2096
|
+
Exec: uint32(stat.Mode)&0o100 != 0,
|
|
2097
|
+
Bytes: bytesRead,
|
|
2098
|
+
SHA256: hex.EncodeToString(hash.Sum(nil)),
|
|
2099
|
+
Dev: strconv.FormatUint(uint64(stat.Dev), 10),
|
|
2100
|
+
Ino: strconv.FormatUint(uint64(stat.Ino), 10),
|
|
2101
|
+
}, nil
|
|
2102
|
+
default:
|
|
2103
|
+
return nil, fmt.Errorf("%q is not a regular file or symlink", name)
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
func entryChangedAt(dirFD int, name string) (int64, error) {
|
|
2108
|
+
var stat unix.Stat_t
|
|
2109
|
+
if err := unix.Fstatat(dirFD, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
|
2110
|
+
return 0, fmt.Errorf("stat %q change time: %w", name, err)
|
|
2111
|
+
}
|
|
2112
|
+
return statChangedAt(stat), nil
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
func statChangedAt(stat unix.Stat_t) int64 {
|
|
2116
|
+
mtime := stat.Mtim.Sec*1_000_000_000 + stat.Mtim.Nsec
|
|
2117
|
+
ctime := stat.Ctim.Sec*1_000_000_000 + stat.Ctim.Nsec
|
|
2118
|
+
if ctime > mtime {
|
|
2119
|
+
return ctime
|
|
2120
|
+
}
|
|
2121
|
+
return mtime
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
func readlinkAt(dirFD int, name string) ([]byte, error) {
|
|
2125
|
+
size := 256
|
|
2126
|
+
for size <= 1024*1024 {
|
|
2127
|
+
buffer := make([]byte, size)
|
|
2128
|
+
count, err := unix.Readlinkat(dirFD, name, buffer)
|
|
2129
|
+
if err != nil {
|
|
2130
|
+
return nil, fmt.Errorf("read symlink %q: %w", name, err)
|
|
2131
|
+
}
|
|
2132
|
+
if count < len(buffer) {
|
|
2133
|
+
return buffer[:count], nil
|
|
2134
|
+
}
|
|
2135
|
+
size *= 2
|
|
2136
|
+
}
|
|
2137
|
+
return nil, errors.New("symlink target exceeds one MiB")
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
func identitiesEqual(left, right *identity) bool {
|
|
2141
|
+
if left == nil || right == nil {
|
|
2142
|
+
return left == nil && right == nil
|
|
2143
|
+
}
|
|
2144
|
+
return left.Kind == right.Kind &&
|
|
2145
|
+
left.Exec == right.Exec &&
|
|
2146
|
+
left.Bytes == right.Bytes &&
|
|
2147
|
+
left.SHA256 == right.SHA256 &&
|
|
2148
|
+
(left.Dev == "" || right.Dev == "" || left.Dev == right.Dev) &&
|
|
2149
|
+
(left.Ino == "" || right.Ino == "" || left.Ino == right.Ino)
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
func linkEntry(sourceFD int, source string, destinationFD int, destination string) error {
|
|
2153
|
+
return unix.Linkat(sourceFD, source, destinationFD, destination, 0)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
func removeAt(dirFD int, name string, flags int) error {
|
|
2157
|
+
err := unix.Unlinkat(dirFD, name, flags)
|
|
2158
|
+
if errors.Is(err, unix.ENOENT) {
|
|
2159
|
+
return nil
|
|
2160
|
+
}
|
|
2161
|
+
return err
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
func cleanupScratch(parentFD, scratchFD int, scratch string) error {
|
|
2165
|
+
for _, name := range []string{
|
|
2166
|
+
"source",
|
|
2167
|
+
"target",
|
|
2168
|
+
"current",
|
|
2169
|
+
preparedMarker,
|
|
2170
|
+
deletedMarker,
|
|
2171
|
+
} {
|
|
2172
|
+
if err := removeAt(scratchFD, name, 0); err != nil {
|
|
2173
|
+
return fmt.Errorf("remove retained %s: %w", name, err)
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
if err := unix.Fsync(scratchFD); err != nil {
|
|
2177
|
+
return fmt.Errorf("sync emptied scratch: %w", err)
|
|
2178
|
+
}
|
|
2179
|
+
if err := removeAt(parentFD, scratch, unix.AT_REMOVEDIR); err != nil {
|
|
2180
|
+
return fmt.Errorf("remove transaction scratch: %w", err)
|
|
2181
|
+
}
|
|
2182
|
+
if err := unix.Fsync(parentFD); err != nil {
|
|
2183
|
+
return fmt.Errorf("sync scratch removal: %w", err)
|
|
2184
|
+
}
|
|
2185
|
+
return nil
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
func syncBoth(first, second int) error {
|
|
2189
|
+
if err := unix.Fsync(first); err != nil {
|
|
2190
|
+
return fmt.Errorf("sync destination parent: %w", err)
|
|
2191
|
+
}
|
|
2192
|
+
if err := unix.Fsync(second); err != nil {
|
|
2193
|
+
return fmt.Errorf("sync transaction scratch: %w", err)
|
|
2194
|
+
}
|
|
2195
|
+
return nil
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
func parseFD(value string) (int, bool) {
|
|
2199
|
+
fd, err := strconv.Atoi(value)
|
|
2200
|
+
return fd, err == nil
|
|
2201
|
+
}
|