dsh-live-compact 1.1.0 → 1.1.2
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/README.md +87 -35
- package/adapter.js +16 -0
- package/client.js +145 -0
- package/cooldown.js +21 -5
- package/cordis.patch.yml +5 -9
- package/gate.js +73 -46
- package/index.js +351 -126
- package/package.json +22 -6
- package/plan.js +116 -27
- package/raw.js +52 -15
- package/settings.js +136 -0
- package/shadow.js +802 -81
- package/shell.js +38 -28
package/shadow.js
CHANGED
|
@@ -9,27 +9,495 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { performance } from "node:perf_hooks";
|
|
12
14
|
import {
|
|
13
15
|
chmodSync,
|
|
14
16
|
appendFileSync,
|
|
15
17
|
closeSync,
|
|
16
18
|
existsSync,
|
|
19
|
+
fstatSync,
|
|
20
|
+
fsyncSync,
|
|
21
|
+
linkSync,
|
|
22
|
+
lstatSync,
|
|
17
23
|
mkdirSync,
|
|
18
|
-
mkdtempSync,
|
|
19
24
|
openSync,
|
|
20
25
|
readFileSync,
|
|
21
26
|
readSync,
|
|
22
27
|
readdirSync,
|
|
28
|
+
realpathSync,
|
|
23
29
|
renameSync,
|
|
24
|
-
rmSync,
|
|
25
30
|
statSync,
|
|
26
31
|
unlinkSync,
|
|
27
32
|
writeFileSync,
|
|
28
33
|
} from "node:fs";
|
|
29
|
-
import {
|
|
30
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
34
|
+
import { basename, dirname, join, resolve, win32 } from "node:path";
|
|
31
35
|
|
|
36
|
+
const DRIVE_ROOT = /^[A-Za-z]:[\\/]/;
|
|
32
37
|
const PLUGIN_NAME = /^(?:(?:cut|official|shadow)-\d+-[0-9a-f]{8}|raw-[0-9a-f]{16}|\d+-[0-9a-f]{8}-[0-9a-f]{8}|\d+-[0-9a-f]{8,16})\.txt$/;
|
|
38
|
+
let cachedSid = "";
|
|
39
|
+
const lockedDirs = new Map();
|
|
40
|
+
const lockedFiles = new Map();
|
|
41
|
+
let shadowFuse = false;
|
|
42
|
+
const shadowWarnings = new Set();
|
|
43
|
+
|
|
44
|
+
// No shared byte ledger. A positively dead holder leaves an immutable fence:
|
|
45
|
+
// recovery enables the original shared scans, never ownership or an estimate.
|
|
46
|
+
const ROTATION_LOCK = ".spine-rotation.lock";
|
|
47
|
+
const ROTATION_OWNER = ".spine-rotation.owner";
|
|
48
|
+
const ROTATION_SHARED = ".spine-rotation.shared";
|
|
49
|
+
const ROTATION_RECOVERED = ".spine-rotation.recovered";
|
|
50
|
+
const USAGE_MAX_AGE_MS = 60_000;
|
|
51
|
+
const rotationOwner = randomBytes(24).toString("hex");
|
|
52
|
+
let rotationScope = null;
|
|
53
|
+
let shadowUsage = null;
|
|
54
|
+
|
|
55
|
+
export function shadowDirectory(env = process.env) {
|
|
56
|
+
return env.SPINE_SHADOW_DIR || defaultShadowDirectory(env);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function defaultShadowDirectory(env = process.env) {
|
|
60
|
+
return join(env.DSH_HOME || join(homedir(), ".dsh"), "compaction-spine", "shadow");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function realDirectory(dir) {
|
|
64
|
+
return realpathSync.native(dir);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function directoryIdentity(dir) {
|
|
68
|
+
const info = statSync(dir, { bigint: true });
|
|
69
|
+
if (!info.isDirectory() || info.ino <= 0n) return "";
|
|
70
|
+
return `${info.dev}:${info.ino}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function coordinationError(cause) {
|
|
74
|
+
const error = new Error("spine: shadow rotation coordination unavailable; backup refused", { cause });
|
|
75
|
+
error.code = "ESHADOWBUSY";
|
|
76
|
+
return error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function touchExclusive(file, body = "") {
|
|
80
|
+
let fd;
|
|
81
|
+
try {
|
|
82
|
+
fd = openSync(file, "wx", 0o600);
|
|
83
|
+
writeFileSync(fd, body);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error?.code !== "EEXIST") throw error;
|
|
86
|
+
} finally {
|
|
87
|
+
if (fd !== undefined) closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readRotationOwner(file) {
|
|
92
|
+
const fd = openSync(file, "r");
|
|
93
|
+
try {
|
|
94
|
+
const buffer = Buffer.alloc(49);
|
|
95
|
+
const count = readSync(fd, buffer, 0, buffer.length, 0);
|
|
96
|
+
const token = buffer.subarray(0, count).toString("utf8");
|
|
97
|
+
return /^[0-9a-f]{48}$/.test(token) ? token : "";
|
|
98
|
+
} finally {
|
|
99
|
+
closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rotationRecordText(record) {
|
|
104
|
+
return `${JSON.stringify({ v: 1, pid: record.pid, id: record.id })}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readRotationRecord(file) {
|
|
108
|
+
// Reject directories (including old empty locks), symlinks and special files
|
|
109
|
+
// before opening. Neither malformed nor oversized metadata proves death.
|
|
110
|
+
if (!lstatSync(file).isFile()) throw new Error("shadow holder is not a regular file");
|
|
111
|
+
const fd = openSync(file, "r");
|
|
112
|
+
try {
|
|
113
|
+
const info = fstatSync(fd, { bigint: true });
|
|
114
|
+
if (!info.isFile() || info.size <= 0n || info.size > 256n) throw new Error("invalid shadow holder size");
|
|
115
|
+
const buffer = Buffer.alloc(257);
|
|
116
|
+
const count = readSync(fd, buffer, 0, buffer.length, 0);
|
|
117
|
+
const text = buffer.subarray(0, count).toString("utf8");
|
|
118
|
+
const record = JSON.parse(text);
|
|
119
|
+
if (count !== Number(info.size) || record?.v !== 1 ||
|
|
120
|
+
!Number.isSafeInteger(record.pid) || record.pid <= 0 || record.pid > 0x7fffffff ||
|
|
121
|
+
!/^[0-9a-f]{48}$/.test(record.id) || rotationRecordText(record) !== text) {
|
|
122
|
+
throw new Error("invalid shadow holder record");
|
|
123
|
+
}
|
|
124
|
+
return { ...record, identity: `${info.dev}:${info.ino}` };
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sameRotationRecord(left, right) {
|
|
131
|
+
return left.pid === right.pid && left.id === right.id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function publishRotationRecord(file, record) {
|
|
135
|
+
const temp = `${file}.${randomBytes(24).toString("hex")}.tmp`;
|
|
136
|
+
let fd;
|
|
137
|
+
let created = false;
|
|
138
|
+
try {
|
|
139
|
+
fd = openSync(temp, "wx", 0o600);
|
|
140
|
+
created = true;
|
|
141
|
+
writeFileSync(fd, rotationRecordText(record));
|
|
142
|
+
fsyncSync(fd);
|
|
143
|
+
const info = fstatSync(fd, { bigint: true });
|
|
144
|
+
const published = { ...record, identity: `${info.dev}:${info.ino}` };
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
fd = undefined;
|
|
147
|
+
// Same-directory hard links atomically publish a complete, closed file on
|
|
148
|
+
// POSIX and Windows. Unlike rename, link never replaces an existing lock.
|
|
149
|
+
try { linkSync(temp, file); } catch (cause) {
|
|
150
|
+
// Only a failure of this operation proves that hardlink publication is
|
|
151
|
+
// unsupported. Permission, I/O and other ambiguous errors stay closed.
|
|
152
|
+
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(cause?.code)) throw cause;
|
|
153
|
+
const error = new Error("shadow hardlink publication unsupported", { cause });
|
|
154
|
+
error.code = "ESHADOWLINKUNSUPPORTED";
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
return published;
|
|
158
|
+
} finally {
|
|
159
|
+
if (fd !== undefined) closeSync(fd);
|
|
160
|
+
// The temp is only an extra name. Cleanup must neither hide publication's
|
|
161
|
+
// outcome nor discard the exact ownership handle of an already live lock.
|
|
162
|
+
if (created) { try { unlinkSync(temp); } catch {} }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function syncRotationDirectory(root) {
|
|
167
|
+
// Node cannot open/flush directory handles on Windows. The marker files are
|
|
168
|
+
// flushed there too; POSIX also flushes their directory entries.
|
|
169
|
+
if (process.platform === "win32") return;
|
|
170
|
+
const fd = openSync(root, "r");
|
|
171
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function persistRotationFence(root, name) {
|
|
175
|
+
const file = join(root, name);
|
|
176
|
+
const fd = openSync(file, "a", 0o600);
|
|
177
|
+
let identity;
|
|
178
|
+
try {
|
|
179
|
+
const info = fstatSync(fd, { bigint: true });
|
|
180
|
+
if (!info.isFile()) throw new Error("invalid shadow fence");
|
|
181
|
+
identity = `${info.dev}:${info.ino}`;
|
|
182
|
+
fsyncSync(fd);
|
|
183
|
+
} finally {
|
|
184
|
+
closeSync(fd);
|
|
185
|
+
}
|
|
186
|
+
syncRotationDirectory(root);
|
|
187
|
+
return identity;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function persistRotationShared(root) {
|
|
191
|
+
return persistRotationFence(root, ROTATION_SHARED);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function linklessRecoveryName(record) {
|
|
195
|
+
// An empty file's name atomically records the complete proven-dead token;
|
|
196
|
+
// unlike writing JSON via wx, a crash cannot expose a partial recovery record.
|
|
197
|
+
return `${ROTATION_RECOVERED}-${record.pid}-${record.id}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function hasLinklessRecovery(root, record) {
|
|
201
|
+
try {
|
|
202
|
+
const info = lstatSync(join(root, linklessRecoveryName(record)));
|
|
203
|
+
if (!info.isFile() || info.size !== 0) throw new Error("invalid shadow recovery fence");
|
|
204
|
+
return true;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error?.code === "ENOENT") return false;
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function verifyRotationGuard(root, identity, guard) {
|
|
212
|
+
if (identity && directoryIdentity(root) !== identity) throw new Error("shadow directory identity changed");
|
|
213
|
+
if (!guard.record) {
|
|
214
|
+
const fence = lstatSync(join(root, ROTATION_SHARED), { bigint: true });
|
|
215
|
+
if (!guard.sharedIdentity || !fence.isFile() || `${fence.dev}:${fence.ino}` !== guard.sharedIdentity) {
|
|
216
|
+
throw new Error("shadow shared fence identity changed");
|
|
217
|
+
}
|
|
218
|
+
try { lstatSync(join(root, ROTATION_LOCK)); } catch (error) {
|
|
219
|
+
if (error?.code === "ENOENT") return;
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
throw new Error("shadow transaction appeared during shared fallback");
|
|
223
|
+
}
|
|
224
|
+
const current = readRotationRecord(join(root, ROTATION_LOCK));
|
|
225
|
+
if (!sameRotationRecord(current, guard.record) || current.identity !== guard.record.identity) {
|
|
226
|
+
throw new Error("shadow lock identity changed");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function releaseRotationGuard(root, identity, guard) {
|
|
231
|
+
verifyRotationGuard(root, identity, guard);
|
|
232
|
+
if (guard.owned) unlinkSync(join(root, ROTATION_LOCK));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function acquireRotationGuard(root, identity) {
|
|
236
|
+
const lock = join(root, ROTATION_LOCK);
|
|
237
|
+
const record = { pid: process.pid, id: randomBytes(24).toString("hex") };
|
|
238
|
+
let guard;
|
|
239
|
+
try {
|
|
240
|
+
const published = publishRotationRecord(lock, record);
|
|
241
|
+
guard = { owned: true, record: published };
|
|
242
|
+
verifyRotationGuard(root, identity, guard);
|
|
243
|
+
return guard;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
shadowUsage = null;
|
|
246
|
+
if (guard) {
|
|
247
|
+
// Publication succeeded, but acquisition never returned its ownership
|
|
248
|
+
// handle. Retry validation before releasing only that exact root/object/
|
|
249
|
+
// record; a replacement or an unreadable guard must remain untouched.
|
|
250
|
+
try { releaseRotationGuard(root, identity, guard); } catch {}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
if (error?.code === "ESHADOWLINKUNSUPPORTED") {
|
|
254
|
+
// Fence out all future exclusive estimates BEFORE observing a possible
|
|
255
|
+
// earlier transaction. An exclusive owner publishes its lock first, so
|
|
256
|
+
// absence after this durable fence cannot hide an active exclusive scope.
|
|
257
|
+
const sharedIdentity = persistRotationShared(root);
|
|
258
|
+
let observed;
|
|
259
|
+
try { observed = readRotationRecord(lock); } catch (failure) {
|
|
260
|
+
if (failure?.code !== "ENOENT") throw failure;
|
|
261
|
+
const guard = { owned: false, sharedIdentity };
|
|
262
|
+
verifyRotationGuard(root, identity, guard);
|
|
263
|
+
return guard;
|
|
264
|
+
}
|
|
265
|
+
return recoverRotationGuard(root, identity, { owned: false, record: observed });
|
|
266
|
+
}
|
|
267
|
+
try { touchExclusive(join(root, ROTATION_SHARED)); } catch {}
|
|
268
|
+
if (error?.code !== "EEXIST") throw error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return recoverRotationGuard(root, identity, { owned: false, record: readRotationRecord(lock) });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function recoverRotationGuard(root, identity, guard) {
|
|
275
|
+
const recovered = join(root, ROTATION_RECOVERED);
|
|
276
|
+
let recovery;
|
|
277
|
+
try { recovery = readRotationRecord(recovered); } catch (error) {
|
|
278
|
+
if (error?.code !== "ENOENT") throw error;
|
|
279
|
+
}
|
|
280
|
+
if (recovery && !sameRotationRecord(recovery, guard.record)) throw new Error("shadow recovery identity changed");
|
|
281
|
+
const knownDead = !!recovery || hasLinklessRecovery(root, guard.record);
|
|
282
|
+
if (!knownDead) {
|
|
283
|
+
let dead = false;
|
|
284
|
+
try { process.kill(guard.record.pid, 0); } catch (error) { dead = error?.code === "ESRCH"; }
|
|
285
|
+
if (!dead) throw new Error("shadow holder is live or its death is unknown");
|
|
286
|
+
}
|
|
287
|
+
// Persist shared participation BEFORE permitting any action or recording
|
|
288
|
+
// recovery. A later reused PID cannot resurrect this exact proven-dead token.
|
|
289
|
+
persistRotationShared(root);
|
|
290
|
+
verifyRotationGuard(root, identity, guard);
|
|
291
|
+
if (!knownDead) {
|
|
292
|
+
try { publishRotationRecord(recovered, guard.record); } catch (error) {
|
|
293
|
+
if (error?.code === "ESHADOWLINKUNSUPPORTED") persistRotationFence(root, linklessRecoveryName(guard.record));
|
|
294
|
+
else if (error?.code !== "EEXIST" || !sameRotationRecord(readRotationRecord(recovered), guard.record)) throw error;
|
|
295
|
+
}
|
|
296
|
+
syncRotationDirectory(root);
|
|
297
|
+
}
|
|
298
|
+
verifyRotationGuard(root, identity, guard);
|
|
299
|
+
return guard; // Never unlink or replace an observed lock, even after ESRCH.
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function dedicatedRoot(root, identity) {
|
|
303
|
+
if (!identity || process.env.SPINE_SHADOW_SINGLE_PROCESS !== "1" || !process.env.SPINE_SHADOW_DIR) return false;
|
|
304
|
+
try {
|
|
305
|
+
if (realDirectory(process.env.SPINE_SHADOW_DIR) !== root) return false;
|
|
306
|
+
// Explicitly spelling the normal shared root is not a dedicated-root contract.
|
|
307
|
+
let normal = resolve(defaultShadowDirectory());
|
|
308
|
+
try { normal = realDirectory(normal); } catch (error) {
|
|
309
|
+
if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") return false;
|
|
310
|
+
}
|
|
311
|
+
return normal !== root;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A root is explicit at production call sites. Standalone helpers can also find
|
|
318
|
+
// an already participating parent; custom callers must pass root when using a
|
|
319
|
+
// session directory outside their configured shadowDirectory().
|
|
320
|
+
export function shadowRootForDir(dir) {
|
|
321
|
+
const configured = shadowDirectory();
|
|
322
|
+
try {
|
|
323
|
+
const root = realDirectory(configured);
|
|
324
|
+
let path;
|
|
325
|
+
try { path = realDirectory(dir); } catch {
|
|
326
|
+
path = join(realDirectory(dirname(resolve(dir))), basename(dir));
|
|
327
|
+
}
|
|
328
|
+
if (path === root || dirname(path) === root) return configured;
|
|
329
|
+
} catch {}
|
|
330
|
+
if (existsSync(join(dirname(dir), ROTATION_OWNER)) || existsSync(join(dirname(dir), ROTATION_SHARED))) return dirname(dir);
|
|
331
|
+
return dir;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function scopedFile(file, root) {
|
|
335
|
+
const path = join(realDirectory(dirname(file)), basename(file));
|
|
336
|
+
const dir = dirname(path);
|
|
337
|
+
return dir === root || dirname(dir) === root ? path : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function verifyRotationScope() {
|
|
341
|
+
try {
|
|
342
|
+
verifyRotationGuard(rotationScope.root, rotationScope.identity, rotationScope.guard);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
shadowUsage = null;
|
|
345
|
+
throw coordinationError(error);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function retireRotationScope() {
|
|
350
|
+
shadowUsage = null;
|
|
351
|
+
rotationScope.exclusive = false;
|
|
352
|
+
touchExclusive(join(rotationScope.root, ROTATION_SHARED));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function rememberShadowParent(dir) {
|
|
356
|
+
const identity = directoryIdentity(dir);
|
|
357
|
+
const expected = rotationScope.parents.get(dir);
|
|
358
|
+
if (expected !== undefined && expected !== identity) {
|
|
359
|
+
retireRotationScope();
|
|
360
|
+
throw coordinationError(new Error("shadow session directory identity changed"));
|
|
361
|
+
}
|
|
362
|
+
rotationScope.parents.set(dir, identity);
|
|
363
|
+
if (!identity && rotationScope.exclusive) retireRotationScope();
|
|
364
|
+
return identity;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Synchronous and reentrant only within this module's current root transaction.
|
|
368
|
+
// Different module instances/workers have different random owner tokens and use
|
|
369
|
+
// the same filesystem lock, even when they share a PID or a path alias.
|
|
370
|
+
export function withShadowRoot(dir, action) {
|
|
371
|
+
let root;
|
|
372
|
+
try { root = realDirectory(dir); } catch (error) { throw coordinationError(error); }
|
|
373
|
+
if (rotationScope) {
|
|
374
|
+
if (rotationScope.root !== root) throw coordinationError(new Error("nested shadow roots"));
|
|
375
|
+
verifyRotationScope();
|
|
376
|
+
return action();
|
|
377
|
+
}
|
|
378
|
+
const shared = join(root, ROTATION_SHARED);
|
|
379
|
+
let identity;
|
|
380
|
+
let guard;
|
|
381
|
+
try {
|
|
382
|
+
identity = directoryIdentity(root);
|
|
383
|
+
guard = acquireRotationGuard(root, identity);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
shadowUsage = null;
|
|
386
|
+
throw coordinationError(error);
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
verifyRotationGuard(root, identity, guard);
|
|
390
|
+
const optedIn = guard.owned && dedicatedRoot(root, identity);
|
|
391
|
+
const owner = join(root, ROTATION_OWNER);
|
|
392
|
+
let token = "";
|
|
393
|
+
if (optedIn && !existsSync(owner) && !existsSync(shared)) {
|
|
394
|
+
shadowUsage = null;
|
|
395
|
+
touchExclusive(owner, rotationOwner);
|
|
396
|
+
}
|
|
397
|
+
if (existsSync(owner)) {
|
|
398
|
+
try { token = readRotationOwner(owner); } catch {}
|
|
399
|
+
if (token !== rotationOwner || !optedIn) touchExclusive(shared);
|
|
400
|
+
}
|
|
401
|
+
const exclusive = optedIn && token === rotationOwner && !existsSync(shared);
|
|
402
|
+
if (!exclusive || shadowUsage?.root !== root || shadowUsage?.identity !== identity) shadowUsage = null;
|
|
403
|
+
rotationScope = { root, identity, guard, exclusive, parents: new Map() };
|
|
404
|
+
return action();
|
|
405
|
+
} finally {
|
|
406
|
+
rotationScope = null;
|
|
407
|
+
try {
|
|
408
|
+
releaseRotationGuard(root, identity, guard);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
shadowUsage = null;
|
|
411
|
+
// A transient verification failure must not strand a live owned lock.
|
|
412
|
+
// Retry only with the original handle and the same verification checks.
|
|
413
|
+
if (guard.owned) { try { releaseRotationGuard(root, identity, guard); } catch {} }
|
|
414
|
+
throw coordinationError(error);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function resetShadowAccessState() {
|
|
420
|
+
cachedSid = "";
|
|
421
|
+
lockedDirs.clear();
|
|
422
|
+
lockedFiles.clear();
|
|
423
|
+
shadowFuse = false;
|
|
424
|
+
shadowWarnings.clear();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function shadowAccessFused() {
|
|
428
|
+
return shadowFuse;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function tripFuse() {
|
|
432
|
+
shadowFuse = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function envValue(env, name) {
|
|
436
|
+
if (!env) return "";
|
|
437
|
+
const found = Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase());
|
|
438
|
+
return found ? String(env[found] ?? "").trim() : "";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function windowsSystemRoot(env = process.env) {
|
|
442
|
+
const systemRoot = envValue(env, "SystemRoot");
|
|
443
|
+
const windir = envValue(env, "windir");
|
|
444
|
+
const root = (DRIVE_ROOT.test(systemRoot) ? systemRoot : "") || (DRIVE_ROOT.test(windir) ? windir : "");
|
|
445
|
+
return root.replace(/[\\/]+$/, "");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function win32Tool(name, env = process.env) {
|
|
449
|
+
const root = windowsSystemRoot(env);
|
|
450
|
+
if (!root) {
|
|
451
|
+
const error = new Error("spine: Windows system root is not a drive path");
|
|
452
|
+
error.code = "ESHADOWACCESS";
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
return win32.join(root, "System32", name);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function accessIdentity(info) {
|
|
459
|
+
if (info.ino <= 0n) return "";
|
|
460
|
+
return `${info.dev}:${info.ino}:${info.birthtimeNs}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function accessEntry(path, kind) {
|
|
464
|
+
const absolute = resolve(path);
|
|
465
|
+
// Directory aliases are supported, but cached aliases must keep pointing to
|
|
466
|
+
// the same object. Never follow a final file symlink or chmod a hard link.
|
|
467
|
+
const canonical = kind === "dir" ? realDirectory(absolute) : join(realDirectory(dirname(absolute)), basename(absolute));
|
|
468
|
+
const info = lstatSync(canonical, { bigint: true });
|
|
469
|
+
if (kind === "dir" ? !info.isDirectory() : !info.isFile() || info.nlink !== 1n) {
|
|
470
|
+
throw new Error("spine: unsupported shadow permission target");
|
|
471
|
+
}
|
|
472
|
+
return { absolute, canonical, identity: accessIdentity(info) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function sameAccessEntry(left, right) {
|
|
476
|
+
return left.canonical === right.canonical && left.identity && left.identity === right.identity;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function lockedDirectory(entry) {
|
|
480
|
+
const previous = lockedDirs.get(entry.absolute) ?? lockedDirs.get(entry.canonical);
|
|
481
|
+
if (!previous) return false;
|
|
482
|
+
if (!sameAccessEntry(previous, entry)) throw new Error("spine: shadow permission directory identity changed");
|
|
483
|
+
lockedDirs.set(entry.absolute, previous);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function shadowDirLocked(path) {
|
|
488
|
+
return lockedDirectory(accessEntry(path, "dir"));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function noteShadowWarning(logger, error) {
|
|
492
|
+
const code = error?.code || "ESHADOWACCESS";
|
|
493
|
+
if (shadowWarnings.has(code)) return;
|
|
494
|
+
shadowWarnings.add(code);
|
|
495
|
+
const status = error?.cause?.status ?? error?.status;
|
|
496
|
+
const step = error?.step || error?.cause?.step || "";
|
|
497
|
+
const where = step ? ` ${step}` : "";
|
|
498
|
+
const exit = status == null ? "" : ` exit ${status}`;
|
|
499
|
+
logger?.warn?.(`spine: shadow permission failed ${code}${where}${exit}`);
|
|
500
|
+
}
|
|
33
501
|
const SESSION_MARK = /^# spine-session (\S+)/;
|
|
34
502
|
|
|
35
503
|
export function isPluginShadowName(name) {
|
|
@@ -49,9 +517,9 @@ function ownerPid(dir) {
|
|
|
49
517
|
try {
|
|
50
518
|
const text = readFileSync(join(dir, "owner"), "utf8").trim();
|
|
51
519
|
const pid = Number(text);
|
|
52
|
-
return
|
|
53
|
-
} catch {
|
|
54
|
-
return 0;
|
|
520
|
+
return /^[1-9]\d*$/.test(text) && Number.isSafeInteger(pid) ? pid : null;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
return error?.code === "ENOENT" ? 0 : null;
|
|
55
523
|
}
|
|
56
524
|
}
|
|
57
525
|
|
|
@@ -59,8 +527,8 @@ function processAlive(pid) {
|
|
|
59
527
|
try {
|
|
60
528
|
process.kill(pid, 0);
|
|
61
529
|
return true;
|
|
62
|
-
} catch {
|
|
63
|
-
return
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return error?.code !== "ESRCH";
|
|
64
532
|
}
|
|
65
533
|
}
|
|
66
534
|
|
|
@@ -188,25 +656,92 @@ export function recordCutStats(dir, { saved = 0, rewrite = 0 } = {}) {
|
|
|
188
656
|
}
|
|
189
657
|
}
|
|
190
658
|
|
|
191
|
-
|
|
659
|
+
function validWindowsSid(sid) {
|
|
660
|
+
if (typeof sid !== "string" || sid.length > 184 || sid.trim() !== sid ||
|
|
661
|
+
!/^S-1-(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*)){1,15}$/.test(sid)) return false;
|
|
662
|
+
const numbers = sid.slice(4).split("-").map(BigInt);
|
|
663
|
+
return numbers[0] <= 0xffffffffffffn && numbers.slice(1).every((number) => number <= 0xffffffffn);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Fixed code only: path/SID/kind are process environment DATA, never PowerShell
|
|
667
|
+
// source or command-line fragments. Full .NET Framework is supplied by the fixed
|
|
668
|
+
// System32 WindowsPowerShell v1.0 executable (Windows PowerShell 5.1).
|
|
669
|
+
const WINDOWS_ACL_SCRIPT = `
|
|
670
|
+
$ErrorActionPreference = 'Stop'
|
|
671
|
+
try {
|
|
672
|
+
$path = [Environment]::GetEnvironmentVariable('DSH_SHADOW_ACL_PATH', 'Process')
|
|
673
|
+
$kind = [Environment]::GetEnvironmentVariable('DSH_SHADOW_ACL_KIND', 'Process')
|
|
674
|
+
$sidText = [Environment]::GetEnvironmentVariable('DSH_SHADOW_ACL_SID', 'Process')
|
|
675
|
+
if ([String]::IsNullOrEmpty($path) -or ($kind -ne 'dir' -and $kind -ne 'file')) { throw 'Invalid target' }
|
|
676
|
+
$userSid = [Security.Principal.SecurityIdentifier]::new($sidText)
|
|
677
|
+
if ($userSid.Value -cne $sidText -or [Security.Principal.WindowsIdentity]::GetCurrent().User.Value -cne $sidText) { throw 'Invalid identity' }
|
|
678
|
+
$attributes = [IO.File]::GetAttributes($path)
|
|
679
|
+
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse target' }
|
|
680
|
+
if ((($attributes -band [IO.FileAttributes]::Directory) -ne 0) -ne ($kind -eq 'dir')) { throw 'Wrong target kind' }
|
|
681
|
+
$sids = @($sidText, 'S-1-5-18', 'S-1-5-32-544')
|
|
682
|
+
$expected = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
|
683
|
+
$inheritance = [Security.AccessControl.InheritanceFlags]::None
|
|
684
|
+
if ($kind -eq 'dir') {
|
|
685
|
+
$acl = [Security.AccessControl.DirectorySecurity]::new()
|
|
686
|
+
$inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit
|
|
687
|
+
} else {
|
|
688
|
+
$acl = [Security.AccessControl.FileSecurity]::new()
|
|
689
|
+
}
|
|
690
|
+
# A NEW descriptor, not GetAccessControl + grant replacement: no old explicit
|
|
691
|
+
# grant or deny can survive. Only Access is modified; owner/group/SACL are not.
|
|
692
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
693
|
+
foreach ($sid in $sids) {
|
|
694
|
+
if ($expected.Add($sid)) {
|
|
695
|
+
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
|
696
|
+
[Security.Principal.SecurityIdentifier]::new($sid),
|
|
697
|
+
[Security.AccessControl.FileSystemRights]::FullControl,
|
|
698
|
+
$inheritance, [Security.AccessControl.PropagationFlags]::None,
|
|
699
|
+
[Security.AccessControl.AccessControlType]::Allow)
|
|
700
|
+
$acl.AddAccessRule($rule)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if ($kind -eq 'dir') {
|
|
704
|
+
[IO.Directory]::SetAccessControl($path, $acl)
|
|
705
|
+
$actual = [IO.Directory]::GetAccessControl($path, [Security.AccessControl.AccessControlSections]::Access)
|
|
706
|
+
} else {
|
|
707
|
+
[IO.File]::SetAccessControl($path, $acl)
|
|
708
|
+
$actual = [IO.File]::GetAccessControl($path, [Security.AccessControl.AccessControlSections]::Access)
|
|
709
|
+
}
|
|
710
|
+
if (!$actual.AreAccessRulesProtected -or !$actual.AreAccessRulesCanonical) { throw 'Unprotected DACL' }
|
|
711
|
+
$rules = $actual.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
|
712
|
+
if ($rules.Count -ne $expected.Count) { throw 'Unexpected ACE count' }
|
|
713
|
+
foreach ($rule in $rules) {
|
|
714
|
+
if (!$expected.Remove($rule.IdentityReference.Value) -or $rule.IsInherited -or
|
|
715
|
+
$rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or
|
|
716
|
+
$rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl -or
|
|
717
|
+
$rule.InheritanceFlags -ne $inheritance -or
|
|
718
|
+
$rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) { throw 'Unexpected ACE' }
|
|
719
|
+
}
|
|
720
|
+
if ($expected.Count -ne 0) { throw 'Missing ACE' }
|
|
721
|
+
[Console]::Out.Write('DSH_SHADOW_ACL_OK')
|
|
722
|
+
exit 0
|
|
723
|
+
} catch {
|
|
724
|
+
# Never echo paths, identity output, or exception data into host logs.
|
|
725
|
+
exit 1
|
|
726
|
+
}
|
|
727
|
+
`;
|
|
728
|
+
const WINDOWS_ACL_COMMAND = Buffer.from(WINDOWS_ACL_SCRIPT, "utf16le").toString("base64");
|
|
729
|
+
|
|
730
|
+
export function shadowAccessPlan(platform = process.platform, userSid = "", kind = "dir") {
|
|
192
731
|
if (platform === "win32") {
|
|
193
|
-
if (
|
|
732
|
+
if (!validWindowsSid(userSid)) {
|
|
194
733
|
throw new Error("spine: current Windows user SID is required");
|
|
195
734
|
}
|
|
735
|
+
if (kind !== "dir" && kind !== "file") throw new Error("spine: invalid shadow permission kind");
|
|
196
736
|
|
|
197
737
|
return {
|
|
198
738
|
kind: "acl",
|
|
199
739
|
usesChmodOnly: false,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
],
|
|
206
|
-
sddl:
|
|
207
|
-
`D:P(A;OICI;FA;;;${userSid})` +
|
|
208
|
-
"(A;OICI;FA;;;SY)" +
|
|
209
|
-
"(A;OICI;FA;;;BA)",
|
|
740
|
+
replace: true,
|
|
741
|
+
protected: true,
|
|
742
|
+
inheritance: kind === "dir" ? ["ObjectInherit", "ContainerInherit"] : [],
|
|
743
|
+
allow: [...new Set([userSid, "S-1-5-18", "S-1-5-32-544"])],
|
|
744
|
+
rights: "FullControl",
|
|
210
745
|
};
|
|
211
746
|
}
|
|
212
747
|
|
|
@@ -218,62 +753,133 @@ export function shadowAccessPlan(platform = process.platform, userSid = "") {
|
|
|
218
753
|
};
|
|
219
754
|
}
|
|
220
755
|
|
|
221
|
-
export function defaultShadowAccessRunner({
|
|
756
|
+
export function defaultShadowAccessRunner({
|
|
757
|
+
path,
|
|
758
|
+
kind,
|
|
759
|
+
platform,
|
|
760
|
+
execFile = execFileSync,
|
|
761
|
+
env = process.env,
|
|
762
|
+
} = {}) {
|
|
222
763
|
if (platform !== "win32") {
|
|
223
764
|
const plan = shadowAccessPlan(platform);
|
|
224
765
|
chmodSync(path, kind === "dir" ? plan.dirMode : plan.fileMode);
|
|
225
766
|
return true;
|
|
226
767
|
}
|
|
227
768
|
|
|
228
|
-
|
|
229
|
-
"
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
);
|
|
233
|
-
const match = identity.match(/,"(S-\d+(?:-\d+)+)"\s*$/i);
|
|
234
|
-
if (!match) {
|
|
235
|
-
throw new Error("spine: cannot determine current Windows user SID");
|
|
769
|
+
if (shadowFuse) {
|
|
770
|
+
const error = new Error("spine: shadow permission already failed");
|
|
771
|
+
error.code = "ESHADOWACCESS";
|
|
772
|
+
throw error;
|
|
236
773
|
}
|
|
237
774
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const temp = mkdtempSync(join(tmpdir(), "spine-acl-"));
|
|
241
|
-
|
|
775
|
+
let whoamiPath = "";
|
|
776
|
+
let powershellPath = "";
|
|
242
777
|
try {
|
|
243
|
-
|
|
244
|
-
|
|
778
|
+
whoamiPath = win32Tool("whoami.exe", env);
|
|
779
|
+
powershellPath = win32Tool("WindowsPowerShell\\v1.0\\powershell.exe", env);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
tripFuse();
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
245
784
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
785
|
+
let sid = cachedSid;
|
|
786
|
+
if (!sid) {
|
|
787
|
+
let identity = "";
|
|
788
|
+
try {
|
|
789
|
+
identity = execFile(whoamiPath, ["/user", "/fo", "csv", "/nh"], {
|
|
790
|
+
encoding: "utf8",
|
|
791
|
+
windowsHide: true,
|
|
792
|
+
timeout: 10000,
|
|
793
|
+
maxBuffer: 65536,
|
|
794
|
+
});
|
|
795
|
+
} catch (error) {
|
|
796
|
+
error.step = "whoami";
|
|
797
|
+
tripFuse();
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
const match = String(identity ?? "").match(/^"(?:[^"\r\n]|"")*","(S-1-[\d-]+)"\s*$/);
|
|
801
|
+
if (!match || !validWindowsSid(match[1])) {
|
|
802
|
+
const error = new Error("spine: cannot determine current Windows user SID");
|
|
803
|
+
error.step = "whoami";
|
|
804
|
+
tripFuse();
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
sid = match[1];
|
|
808
|
+
cachedSid = sid;
|
|
809
|
+
}
|
|
250
810
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
811
|
+
try {
|
|
812
|
+
shadowAccessPlan("win32", sid, kind);
|
|
813
|
+
const before = accessEntry(path, kind);
|
|
814
|
+
// Also guard direct runner callers, including legacy files through aliases.
|
|
815
|
+
lockedDirectory(kind === "dir" ? before : accessEntry(dirname(path), "dir"));
|
|
816
|
+
const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) =>
|
|
817
|
+
!/^DSH_SHADOW_ACL_(PATH|KIND|SID)$/i.test(key)));
|
|
818
|
+
Object.assign(childEnv, {
|
|
819
|
+
DSH_SHADOW_ACL_PATH: before.canonical,
|
|
820
|
+
DSH_SHADOW_ACL_KIND: kind,
|
|
821
|
+
DSH_SHADOW_ACL_SID: sid,
|
|
822
|
+
});
|
|
823
|
+
const result = execFile(
|
|
824
|
+
powershellPath,
|
|
825
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", WINDOWS_ACL_COMMAND],
|
|
826
|
+
{ env: childEnv, encoding: "utf8", stdio: "pipe", windowsHide: true, timeout: 15000, maxBuffer: 65536 },
|
|
255
827
|
);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
828
|
+
if (String(result).trim() !== "DSH_SHADOW_ACL_OK") throw new Error("spine: shadow DACL verification not confirmed");
|
|
829
|
+
const after = accessEntry(path, kind);
|
|
830
|
+
if (!sameAccessEntry(before, after)) throw new Error("spine: shadow permission target identity changed");
|
|
831
|
+
if (kind === "dir") {
|
|
832
|
+
lockedDirs.set(after.absolute, after);
|
|
833
|
+
lockedDirs.set(after.canonical, after);
|
|
834
|
+
} else {
|
|
835
|
+
lockedFiles.set(after.canonical, after);
|
|
836
|
+
}
|
|
837
|
+
} catch (error) {
|
|
838
|
+
error.step = kind === "file" ? "file" : "dir";
|
|
839
|
+
tripFuse();
|
|
840
|
+
throw error;
|
|
260
841
|
}
|
|
842
|
+
|
|
843
|
+
return true;
|
|
261
844
|
}
|
|
262
845
|
|
|
263
846
|
export function tightenShadow(path, kind = "dir", access = {}) {
|
|
264
847
|
const platform = access.platform ?? process.platform;
|
|
265
|
-
const
|
|
848
|
+
const windows = platform === "win32" && !access.runner;
|
|
849
|
+
if (windows && shadowFuse) {
|
|
850
|
+
const error = new Error("spine: shadow permission action failed; backup refused");
|
|
851
|
+
error.code = "ESHADOWACCESS";
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const runner = access.runner ?? ((action) => defaultShadowAccessRunner({
|
|
856
|
+
...action,
|
|
857
|
+
execFile: access.execFile,
|
|
858
|
+
env: access.env,
|
|
859
|
+
}));
|
|
266
860
|
|
|
267
861
|
try {
|
|
268
|
-
if (
|
|
862
|
+
if (windows) {
|
|
863
|
+
if (kind === "dir" && shadowDirLocked(path)) return;
|
|
864
|
+
if (kind === "file") {
|
|
865
|
+
const parentLocked = shadowDirLocked(dirname(path));
|
|
866
|
+
const current = accessEntry(path, kind);
|
|
867
|
+
// A cached parent alone says nothing about an old file's explicit ACEs.
|
|
868
|
+
const previous = lockedFiles.get(current.canonical);
|
|
869
|
+
if (previous && sameAccessEntry(previous, current) && parentLocked) return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (runner({ path, kind, platform, env: access.env, execFile: access.execFile }) !== true) {
|
|
269
873
|
throw new Error("permission runner did not confirm success");
|
|
270
874
|
}
|
|
271
875
|
} catch (cause) {
|
|
876
|
+
if (windows) tripFuse();
|
|
272
877
|
const error = new Error(
|
|
273
878
|
"spine: shadow permission action failed; backup refused",
|
|
274
879
|
{ cause },
|
|
275
880
|
);
|
|
276
881
|
error.code = "ESHADOWACCESS";
|
|
882
|
+
error.step = cause?.step;
|
|
277
883
|
throw error;
|
|
278
884
|
}
|
|
279
885
|
}
|
|
@@ -284,12 +890,42 @@ export function ensureShadowDir(dir, access = {}) {
|
|
|
284
890
|
return dir;
|
|
285
891
|
}
|
|
286
892
|
|
|
287
|
-
export function
|
|
893
|
+
export function tightenCreatedShadow(file, fd, access, { remember = true } = {}) {
|
|
894
|
+
if ((access.platform ?? process.platform) === "win32" && !access.runner && !shadowFuse) {
|
|
895
|
+
try {
|
|
896
|
+
if (shadowDirLocked(dirname(file))) {
|
|
897
|
+
const entry = accessEntry(file, "file");
|
|
898
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
899
|
+
if (!entry.identity || entry.identity !== accessIdentity(opened) || opened.nlink !== 1n) {
|
|
900
|
+
throw new Error("spine: shadow created file identity changed");
|
|
901
|
+
}
|
|
902
|
+
// Only this wx-created object is known to inherit our confirmed DACL.
|
|
903
|
+
if (remember) lockedFiles.set(entry.canonical, entry);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
} catch (cause) {
|
|
907
|
+
tripFuse();
|
|
908
|
+
const error = new Error("spine: shadow permission action failed; backup refused", { cause });
|
|
909
|
+
error.code = "ESHADOWACCESS";
|
|
910
|
+
error.step = "file";
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
tightenShadow(file, "file", access);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function discardShadowFiles(files, cause, unlink = unlinkSync) {
|
|
288
918
|
const errors = [];
|
|
289
919
|
|
|
290
920
|
for (const file of files) {
|
|
921
|
+
if (rotationScope) {
|
|
922
|
+
verifyRotationScope();
|
|
923
|
+
const path = scopedFile(file, rotationScope.root);
|
|
924
|
+
if (!path) throw coordinationError(new Error("cleanup outside shadow root"));
|
|
925
|
+
rememberShadowParent(dirname(path));
|
|
926
|
+
}
|
|
291
927
|
try {
|
|
292
|
-
|
|
928
|
+
unlink(file);
|
|
293
929
|
} catch (error) {
|
|
294
930
|
if (error?.code !== "ENOENT") errors.push(error);
|
|
295
931
|
}
|
|
@@ -309,10 +945,21 @@ export function writeShadowFile(
|
|
|
309
945
|
file,
|
|
310
946
|
body,
|
|
311
947
|
access = {},
|
|
312
|
-
{ exclusive = false } = {},
|
|
948
|
+
{ exclusive = false, root, write = writeFileSync, unlink = unlinkSync } = {},
|
|
313
949
|
) {
|
|
314
950
|
const dir = dirname(file);
|
|
315
951
|
ensureShadowDir(dir, access);
|
|
952
|
+
return withShadowRoot(root ?? shadowRootForDir(dir), () => {
|
|
953
|
+
const path = scopedFile(file, rotationScope.root);
|
|
954
|
+
if (!path) throw coordinationError(new Error("shadow file outside root scope"));
|
|
955
|
+
writeShadowFileLocked(path, body, access, { exclusive, write, unlink, accessPath: file });
|
|
956
|
+
return file;
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function writeShadowFileLocked(file, body, access, { exclusive, write, unlink, accessPath }) {
|
|
961
|
+
const dir = dirname(file);
|
|
962
|
+
const parentIdentity = rememberShadowParent(dir);
|
|
316
963
|
|
|
317
964
|
const target = exclusive
|
|
318
965
|
? file
|
|
@@ -334,8 +981,20 @@ export function writeShadowFile(
|
|
|
334
981
|
protectedPath = file;
|
|
335
982
|
}
|
|
336
983
|
|
|
337
|
-
|
|
338
|
-
|
|
984
|
+
// Keep the caller's spelling for the permission cache/runner.
|
|
985
|
+
// The opened descriptor and cleanup path remain in the canonical root.
|
|
986
|
+
tightenCreatedShadow(accessPath, fd, access);
|
|
987
|
+
verifyRotationScope();
|
|
988
|
+
if (parentIdentity && directoryIdentity(dir) !== parentIdentity) {
|
|
989
|
+
retireRotationScope();
|
|
990
|
+
throw coordinationError(new Error("shadow session directory identity changed"));
|
|
991
|
+
}
|
|
992
|
+
// Full replacement bytes are a conservative charge, even for overwrites,
|
|
993
|
+
// partial writes and failed cleanup. Metadata/owner/stats are not capacity.
|
|
994
|
+
if (shadowUsage && isPluginShadowName(basename(file))) {
|
|
995
|
+
shadowUsage.bytes += typeof body === "string" ? Buffer.byteLength(body, "utf8") : body.byteLength;
|
|
996
|
+
}
|
|
997
|
+
write(fd, body);
|
|
339
998
|
|
|
340
999
|
const opened = fd;
|
|
341
1000
|
fd = null;
|
|
@@ -348,16 +1007,23 @@ export function writeShadowFile(
|
|
|
348
1007
|
} catch {}
|
|
349
1008
|
}
|
|
350
1009
|
|
|
351
|
-
if (created)
|
|
1010
|
+
if (created) {
|
|
1011
|
+
// Do not unlink through a replacement directory after a failed write.
|
|
1012
|
+
let sameParent = false;
|
|
1013
|
+
try { sameParent = !parentIdentity || directoryIdentity(dir) === parentIdentity; } catch {}
|
|
1014
|
+
if (sameParent) discardShadowFiles([protectedPath], error, unlink);
|
|
1015
|
+
else shadowUsage = null;
|
|
1016
|
+
}
|
|
352
1017
|
throw error;
|
|
353
1018
|
}
|
|
354
1019
|
}
|
|
355
1020
|
|
|
356
|
-
export function writeShadowOwner(dir, access = {}) {
|
|
1021
|
+
export function writeShadowOwner(dir, access = {}, root) {
|
|
357
1022
|
return writeShadowFile(
|
|
358
1023
|
join(dir, "owner"),
|
|
359
1024
|
String(process.pid),
|
|
360
1025
|
access,
|
|
1026
|
+
{ root },
|
|
361
1027
|
);
|
|
362
1028
|
}
|
|
363
1029
|
|
|
@@ -383,11 +1049,12 @@ function readSessionKey(file) {
|
|
|
383
1049
|
}
|
|
384
1050
|
}
|
|
385
1051
|
|
|
386
|
-
export function listPluginShadows(dir) {
|
|
1052
|
+
export function listPluginShadows(dir, { readdir = readdirSync, stat = statSync, onError } = {}) {
|
|
387
1053
|
let names = [];
|
|
388
1054
|
try {
|
|
389
|
-
names =
|
|
1055
|
+
names = readdir(dir);
|
|
390
1056
|
} catch {
|
|
1057
|
+
onError?.();
|
|
391
1058
|
return [];
|
|
392
1059
|
}
|
|
393
1060
|
const files = [];
|
|
@@ -395,7 +1062,7 @@ export function listPluginShadows(dir) {
|
|
|
395
1062
|
if (!isPluginShadowName(name)) continue;
|
|
396
1063
|
const path = resolve(dir, name);
|
|
397
1064
|
try {
|
|
398
|
-
const info =
|
|
1065
|
+
const info = stat(path);
|
|
399
1066
|
if (!info.isFile()) continue;
|
|
400
1067
|
files.push({
|
|
401
1068
|
name,
|
|
@@ -404,7 +1071,7 @@ export function listPluginShadows(dir) {
|
|
|
404
1071
|
mtimeMs: info.mtimeMs,
|
|
405
1072
|
sessionKey: readSessionKey(path),
|
|
406
1073
|
});
|
|
407
|
-
} catch {}
|
|
1074
|
+
} catch { onError?.(); }
|
|
408
1075
|
}
|
|
409
1076
|
return files;
|
|
410
1077
|
}
|
|
@@ -413,19 +1080,21 @@ function byAge(left, right) {
|
|
|
413
1080
|
return left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name);
|
|
414
1081
|
}
|
|
415
1082
|
|
|
416
|
-
export function listShadowEntries(dir, alive = processAlive) {
|
|
1083
|
+
export function listShadowEntries(dir, alive = processAlive, { readdir = readdirSync, stat = statSync, onError } = {}) {
|
|
417
1084
|
const entries = [];
|
|
418
1085
|
let names = [];
|
|
419
1086
|
try {
|
|
420
|
-
names =
|
|
1087
|
+
names = readdir(dir, { withFileTypes: true });
|
|
421
1088
|
} catch {
|
|
1089
|
+
onError?.();
|
|
422
1090
|
return entries;
|
|
423
1091
|
}
|
|
424
1092
|
for (const entry of names) {
|
|
1093
|
+
if (entry.name === ROTATION_LOCK) continue;
|
|
425
1094
|
const path = resolve(dir, entry.name);
|
|
426
1095
|
if (entry.isFile() && isPluginShadowName(entry.name)) {
|
|
427
1096
|
try {
|
|
428
|
-
const info =
|
|
1097
|
+
const info = stat(path);
|
|
429
1098
|
entries.push({
|
|
430
1099
|
name: entry.name,
|
|
431
1100
|
path,
|
|
@@ -435,13 +1104,19 @@ export function listShadowEntries(dir, alive = processAlive) {
|
|
|
435
1104
|
legacy: true,
|
|
436
1105
|
live: false,
|
|
437
1106
|
});
|
|
438
|
-
} catch {}
|
|
1107
|
+
} catch { onError?.(); }
|
|
439
1108
|
continue;
|
|
440
1109
|
}
|
|
441
1110
|
if (!entry.isDirectory()) continue;
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
1111
|
+
const owner = ownerPid(path);
|
|
1112
|
+
let live = owner === null;
|
|
1113
|
+
if (owner > 0) {
|
|
1114
|
+
try { live = alive(owner) !== false; } catch { live = true; }
|
|
1115
|
+
}
|
|
1116
|
+
for (const file of listPluginShadows(path, { readdir, stat, onError })) {
|
|
1117
|
+
// The directory and its owner define deletion authority. Raw tool output
|
|
1118
|
+
// is untrusted data and may itself start with a spine-session marker.
|
|
1119
|
+
entries.push({ ...file, legacy: false, live, sessionKey: entry.name });
|
|
445
1120
|
}
|
|
446
1121
|
}
|
|
447
1122
|
return entries;
|
|
@@ -451,14 +1126,62 @@ export function listShadowEntries(dir, alive = processAlive) {
|
|
|
451
1126
|
// 当前卡片和这次引用的原文路径受保护。删不掉且仍然超限时返回 ok:false。
|
|
452
1127
|
// Delete legacy root files, then dead sessions, then old files of this session.
|
|
453
1128
|
// Protect the current card and cited raw paths. If unlink fails and the cap is still exceeded, ok is false.
|
|
454
|
-
export function rotateShadow(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1129
|
+
export function rotateShadow(options) {
|
|
1130
|
+
return withShadowRoot(options.dir, () => rotateShadowLocked(options));
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function rotationTime(now) {
|
|
1134
|
+
try {
|
|
1135
|
+
const value = now();
|
|
1136
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1137
|
+
} catch {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function rotateShadowLocked({
|
|
1143
|
+
incomingBytes, protectPath, protectPaths = [], sessionKey, cap,
|
|
1144
|
+
unlink = unlinkSync, alive = processAlive, readdir = readdirSync, stat = statSync,
|
|
1145
|
+
now = () => performance.now(),
|
|
1146
|
+
}) {
|
|
1147
|
+
const { root, identity, exclusive } = rotationScope;
|
|
459
1148
|
const low = Math.floor(cap * 0.8);
|
|
460
1149
|
const oversize = incomingBytes > cap;
|
|
1150
|
+
const cached = shadowUsage;
|
|
1151
|
+
const observed = exclusive ? rotationTime(now) : null;
|
|
1152
|
+
const validTime = observed !== null && (!cached || observed >= cached.lastObservedAt);
|
|
1153
|
+
if (exclusive && cached?.cap === cap && validTime && observed >= cached.calibratedAt &&
|
|
1154
|
+
observed - cached.calibratedAt < USAGE_MAX_AGE_MS && Number.isSafeInteger(cached.bytes) &&
|
|
1155
|
+
Number.isSafeInteger(incomingBytes) && incomingBytes >= 0 && !oversize && cached.bytes + incomingBytes < low) {
|
|
1156
|
+
cached.lastObservedAt = observed;
|
|
1157
|
+
return { ok: true, deleted: 0, freed: 0, used: cached.bytes, cap, oversize: false, failed: false, estimated: true };
|
|
1158
|
+
}
|
|
1159
|
+
shadowUsage = null;
|
|
1160
|
+
let complete = true;
|
|
1161
|
+
const files = listShadowEntries(root, alive, { readdir, stat, onError() { complete = false; } });
|
|
1162
|
+
const protect = new Set([protectPath, ...protectPaths].filter(Boolean).map((path) => {
|
|
1163
|
+
try { return join(realDirectory(dirname(path)), basename(path)); } catch { return resolve(path); }
|
|
1164
|
+
}));
|
|
1165
|
+
const current = sanitizeSessionKey(sessionKey);
|
|
1166
|
+
let used = files.reduce((sum, file) => sum + file.size, 0);
|
|
1167
|
+
const validBytes = [used, incomingBytes, cap].every(Number.isSafeInteger) && used >= 0 && incomingBytes >= 0 && cap > 0;
|
|
1168
|
+
// Missing entries make used a lower bound, never evidence of free space.
|
|
1169
|
+
// Refuse before deletion as well: a partial scan cannot establish a safe
|
|
1170
|
+
// rotation, even when the incoming backup qualifies for the oversize rule.
|
|
1171
|
+
if (!complete) {
|
|
1172
|
+
return { ok: false, deleted: 0, freed: 0, used, cap, oversize, failed: true, reason: "scan-incomplete" };
|
|
1173
|
+
}
|
|
1174
|
+
if (!validBytes) {
|
|
1175
|
+
return { ok: false, deleted: 0, freed: 0, used, cap, oversize, failed: true, reason: "invalid-measurement" };
|
|
1176
|
+
}
|
|
1177
|
+
const publishUsage = () => {
|
|
1178
|
+
if (!exclusive || !validTime) return;
|
|
1179
|
+
const completed = rotationTime(now);
|
|
1180
|
+
if (completed === null || completed < observed) return;
|
|
1181
|
+
shadowUsage = { root, identity, cap, bytes: used, calibratedAt: completed, lastObservedAt: completed };
|
|
1182
|
+
};
|
|
461
1183
|
if (!oversize && used + incomingBytes <= cap) {
|
|
1184
|
+
publishUsage();
|
|
462
1185
|
return { ok: true, deleted: 0, freed: 0, used, cap, oversize: false, failed: false };
|
|
463
1186
|
}
|
|
464
1187
|
const legacy = [];
|
|
@@ -482,14 +1205,10 @@ export function rotateShadow({ dir, incomingBytes, protectPath, protectPaths = [
|
|
|
482
1205
|
try {
|
|
483
1206
|
unlink(file.path);
|
|
484
1207
|
} catch (error) {
|
|
485
|
-
if (error?.code
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
deleted += 1;
|
|
489
|
-
continue;
|
|
1208
|
+
if (error?.code !== "ENOENT") {
|
|
1209
|
+
failed = true;
|
|
1210
|
+
break;
|
|
490
1211
|
}
|
|
491
|
-
failed = true;
|
|
492
|
-
break;
|
|
493
1212
|
}
|
|
494
1213
|
used -= file.size;
|
|
495
1214
|
freed += file.size;
|
|
@@ -501,8 +1220,10 @@ export function rotateShadow({ dir, incomingBytes, protectPath, protectPaths = [
|
|
|
501
1220
|
}
|
|
502
1221
|
}
|
|
503
1222
|
const fits = used + incomingBytes <= cap;
|
|
1223
|
+
const ok = !failed && (oversize || fits);
|
|
1224
|
+
if (ok) publishUsage();
|
|
504
1225
|
return {
|
|
505
|
-
ok
|
|
1226
|
+
ok,
|
|
506
1227
|
deleted,
|
|
507
1228
|
freed,
|
|
508
1229
|
used,
|