dsh-live-compact 1.1.1 → 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 +76 -31
- package/adapter.js +3 -0
- package/client.js +145 -0
- package/cooldown.js +21 -5
- package/cordis.patch.yml +5 -9
- package/gate.js +72 -45
- package/index.js +271 -128
- package/package.json +21 -6
- package/plan.js +83 -21
- package/raw.js +52 -15
- package/settings.js +136 -0
- package/shadow.js +682 -64
- package/shell.js +36 -27
package/shadow.js
CHANGED
|
@@ -9,16 +9,23 @@
|
|
|
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
24
|
openSync,
|
|
19
25
|
readFileSync,
|
|
20
26
|
readSync,
|
|
21
27
|
readdirSync,
|
|
28
|
+
realpathSync,
|
|
22
29
|
renameSync,
|
|
23
30
|
statSync,
|
|
24
31
|
unlinkSync,
|
|
@@ -29,13 +36,390 @@ import { basename, dirname, join, resolve, win32 } from "node:path";
|
|
|
29
36
|
const DRIVE_ROOT = /^[A-Za-z]:[\\/]/;
|
|
30
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$/;
|
|
31
38
|
let cachedSid = "";
|
|
32
|
-
const lockedDirs = new
|
|
39
|
+
const lockedDirs = new Map();
|
|
40
|
+
const lockedFiles = new Map();
|
|
33
41
|
let shadowFuse = false;
|
|
34
42
|
const shadowWarnings = new Set();
|
|
35
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
|
+
|
|
36
419
|
export function resetShadowAccessState() {
|
|
37
420
|
cachedSid = "";
|
|
38
421
|
lockedDirs.clear();
|
|
422
|
+
lockedFiles.clear();
|
|
39
423
|
shadowFuse = false;
|
|
40
424
|
shadowWarnings.clear();
|
|
41
425
|
}
|
|
@@ -71,12 +455,37 @@ export function win32Tool(name, env = process.env) {
|
|
|
71
455
|
return win32.join(root, "System32", name);
|
|
72
456
|
}
|
|
73
457
|
|
|
74
|
-
function
|
|
75
|
-
|
|
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;
|
|
76
485
|
}
|
|
77
486
|
|
|
78
487
|
export function shadowDirLocked(path) {
|
|
79
|
-
return
|
|
488
|
+
return lockedDirectory(accessEntry(path, "dir"));
|
|
80
489
|
}
|
|
81
490
|
|
|
82
491
|
export function noteShadowWarning(logger, error) {
|
|
@@ -108,9 +517,9 @@ function ownerPid(dir) {
|
|
|
108
517
|
try {
|
|
109
518
|
const text = readFileSync(join(dir, "owner"), "utf8").trim();
|
|
110
519
|
const pid = Number(text);
|
|
111
|
-
return
|
|
112
|
-
} catch {
|
|
113
|
-
return 0;
|
|
520
|
+
return /^[1-9]\d*$/.test(text) && Number.isSafeInteger(pid) ? pid : null;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
return error?.code === "ENOENT" ? 0 : null;
|
|
114
523
|
}
|
|
115
524
|
}
|
|
116
525
|
|
|
@@ -118,8 +527,8 @@ function processAlive(pid) {
|
|
|
118
527
|
try {
|
|
119
528
|
process.kill(pid, 0);
|
|
120
529
|
return true;
|
|
121
|
-
} catch {
|
|
122
|
-
return
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return error?.code !== "ESRCH";
|
|
123
532
|
}
|
|
124
533
|
}
|
|
125
534
|
|
|
@@ -247,25 +656,92 @@ export function recordCutStats(dir, { saved = 0, rewrite = 0 } = {}) {
|
|
|
247
656
|
}
|
|
248
657
|
}
|
|
249
658
|
|
|
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
|
+
|
|
250
730
|
export function shadowAccessPlan(platform = process.platform, userSid = "", kind = "dir") {
|
|
251
731
|
if (platform === "win32") {
|
|
252
|
-
if (
|
|
732
|
+
if (!validWindowsSid(userSid)) {
|
|
253
733
|
throw new Error("spine: current Windows user SID is required");
|
|
254
734
|
}
|
|
255
|
-
|
|
256
|
-
// 文件不能带继承标志,否则 icacls 会把整条 ACE 当无效丢掉,留下空 DACL。
|
|
257
|
-
// Files reject (OI)(CI), so a file grant must not carry it.
|
|
258
|
-
const scope = kind === "dir" ? "(OI)(CI)" : "";
|
|
735
|
+
if (kind !== "dir" && kind !== "file") throw new Error("spine: invalid shadow permission kind");
|
|
259
736
|
|
|
260
737
|
return {
|
|
261
738
|
kind: "acl",
|
|
262
739
|
usesChmodOnly: false,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
],
|
|
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",
|
|
269
745
|
};
|
|
270
746
|
}
|
|
271
747
|
|
|
@@ -297,10 +773,10 @@ export function defaultShadowAccessRunner({
|
|
|
297
773
|
}
|
|
298
774
|
|
|
299
775
|
let whoamiPath = "";
|
|
300
|
-
let
|
|
776
|
+
let powershellPath = "";
|
|
301
777
|
try {
|
|
302
778
|
whoamiPath = win32Tool("whoami.exe", env);
|
|
303
|
-
|
|
779
|
+
powershellPath = win32Tool("WindowsPowerShell\\v1.0\\powershell.exe", env);
|
|
304
780
|
} catch (error) {
|
|
305
781
|
tripFuse();
|
|
306
782
|
throw error;
|
|
@@ -313,14 +789,16 @@ export function defaultShadowAccessRunner({
|
|
|
313
789
|
identity = execFile(whoamiPath, ["/user", "/fo", "csv", "/nh"], {
|
|
314
790
|
encoding: "utf8",
|
|
315
791
|
windowsHide: true,
|
|
792
|
+
timeout: 10000,
|
|
793
|
+
maxBuffer: 65536,
|
|
316
794
|
});
|
|
317
795
|
} catch (error) {
|
|
318
796
|
error.step = "whoami";
|
|
319
797
|
tripFuse();
|
|
320
798
|
throw error;
|
|
321
799
|
}
|
|
322
|
-
const match = String(identity ?? "").match(
|
|
323
|
-
if (!match) {
|
|
800
|
+
const match = String(identity ?? "").match(/^"(?:[^"\r\n]|"")*","(S-1-[\d-]+)"\s*$/);
|
|
801
|
+
if (!match || !validWindowsSid(match[1])) {
|
|
324
802
|
const error = new Error("spine: cannot determine current Windows user SID");
|
|
325
803
|
error.step = "whoami";
|
|
326
804
|
tripFuse();
|
|
@@ -330,27 +808,44 @@ export function defaultShadowAccessRunner({
|
|
|
330
808
|
cachedSid = sid;
|
|
331
809
|
}
|
|
332
810
|
|
|
333
|
-
const plan = shadowAccessPlan("win32", sid, kind);
|
|
334
811
|
try {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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 },
|
|
339
827
|
);
|
|
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
|
+
}
|
|
340
837
|
} catch (error) {
|
|
341
838
|
error.step = kind === "file" ? "file" : "dir";
|
|
342
839
|
tripFuse();
|
|
343
840
|
throw error;
|
|
344
841
|
}
|
|
345
842
|
|
|
346
|
-
if (kind === "dir") lockedDirs.add(lockKey(path));
|
|
347
843
|
return true;
|
|
348
844
|
}
|
|
349
845
|
|
|
350
846
|
export function tightenShadow(path, kind = "dir", access = {}) {
|
|
351
847
|
const platform = access.platform ?? process.platform;
|
|
352
848
|
const windows = platform === "win32" && !access.runner;
|
|
353
|
-
if (windows && kind === "file" && shadowDirLocked(dirname(path))) return;
|
|
354
849
|
if (windows && shadowFuse) {
|
|
355
850
|
const error = new Error("spine: shadow permission action failed; backup refused");
|
|
356
851
|
error.code = "ESHADOWACCESS";
|
|
@@ -364,6 +859,16 @@ export function tightenShadow(path, kind = "dir", access = {}) {
|
|
|
364
859
|
}));
|
|
365
860
|
|
|
366
861
|
try {
|
|
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
|
+
}
|
|
367
872
|
if (runner({ path, kind, platform, env: access.env, execFile: access.execFile }) !== true) {
|
|
368
873
|
throw new Error("permission runner did not confirm success");
|
|
369
874
|
}
|
|
@@ -381,18 +886,46 @@ export function tightenShadow(path, kind = "dir", access = {}) {
|
|
|
381
886
|
|
|
382
887
|
export function ensureShadowDir(dir, access = {}) {
|
|
383
888
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
384
|
-
const platform = access.platform ?? process.platform;
|
|
385
|
-
if (!access.runner && platform === "win32" && shadowDirLocked(dir)) return dir;
|
|
386
889
|
tightenShadow(dir, "dir", access);
|
|
387
890
|
return dir;
|
|
388
891
|
}
|
|
389
892
|
|
|
390
|
-
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) {
|
|
391
918
|
const errors = [];
|
|
392
919
|
|
|
393
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
|
+
}
|
|
394
927
|
try {
|
|
395
|
-
|
|
928
|
+
unlink(file);
|
|
396
929
|
} catch (error) {
|
|
397
930
|
if (error?.code !== "ENOENT") errors.push(error);
|
|
398
931
|
}
|
|
@@ -412,10 +945,21 @@ export function writeShadowFile(
|
|
|
412
945
|
file,
|
|
413
946
|
body,
|
|
414
947
|
access = {},
|
|
415
|
-
{ exclusive = false } = {},
|
|
948
|
+
{ exclusive = false, root, write = writeFileSync, unlink = unlinkSync } = {},
|
|
416
949
|
) {
|
|
417
950
|
const dir = dirname(file);
|
|
418
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);
|
|
419
963
|
|
|
420
964
|
const target = exclusive
|
|
421
965
|
? file
|
|
@@ -437,8 +981,20 @@ export function writeShadowFile(
|
|
|
437
981
|
protectedPath = file;
|
|
438
982
|
}
|
|
439
983
|
|
|
440
|
-
|
|
441
|
-
|
|
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);
|
|
442
998
|
|
|
443
999
|
const opened = fd;
|
|
444
1000
|
fd = null;
|
|
@@ -451,16 +1007,23 @@ export function writeShadowFile(
|
|
|
451
1007
|
} catch {}
|
|
452
1008
|
}
|
|
453
1009
|
|
|
454
|
-
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
|
+
}
|
|
455
1017
|
throw error;
|
|
456
1018
|
}
|
|
457
1019
|
}
|
|
458
1020
|
|
|
459
|
-
export function writeShadowOwner(dir, access = {}) {
|
|
1021
|
+
export function writeShadowOwner(dir, access = {}, root) {
|
|
460
1022
|
return writeShadowFile(
|
|
461
1023
|
join(dir, "owner"),
|
|
462
1024
|
String(process.pid),
|
|
463
1025
|
access,
|
|
1026
|
+
{ root },
|
|
464
1027
|
);
|
|
465
1028
|
}
|
|
466
1029
|
|
|
@@ -486,11 +1049,12 @@ function readSessionKey(file) {
|
|
|
486
1049
|
}
|
|
487
1050
|
}
|
|
488
1051
|
|
|
489
|
-
export function listPluginShadows(dir) {
|
|
1052
|
+
export function listPluginShadows(dir, { readdir = readdirSync, stat = statSync, onError } = {}) {
|
|
490
1053
|
let names = [];
|
|
491
1054
|
try {
|
|
492
|
-
names =
|
|
1055
|
+
names = readdir(dir);
|
|
493
1056
|
} catch {
|
|
1057
|
+
onError?.();
|
|
494
1058
|
return [];
|
|
495
1059
|
}
|
|
496
1060
|
const files = [];
|
|
@@ -498,7 +1062,7 @@ export function listPluginShadows(dir) {
|
|
|
498
1062
|
if (!isPluginShadowName(name)) continue;
|
|
499
1063
|
const path = resolve(dir, name);
|
|
500
1064
|
try {
|
|
501
|
-
const info =
|
|
1065
|
+
const info = stat(path);
|
|
502
1066
|
if (!info.isFile()) continue;
|
|
503
1067
|
files.push({
|
|
504
1068
|
name,
|
|
@@ -507,7 +1071,7 @@ export function listPluginShadows(dir) {
|
|
|
507
1071
|
mtimeMs: info.mtimeMs,
|
|
508
1072
|
sessionKey: readSessionKey(path),
|
|
509
1073
|
});
|
|
510
|
-
} catch {}
|
|
1074
|
+
} catch { onError?.(); }
|
|
511
1075
|
}
|
|
512
1076
|
return files;
|
|
513
1077
|
}
|
|
@@ -516,19 +1080,21 @@ function byAge(left, right) {
|
|
|
516
1080
|
return left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name);
|
|
517
1081
|
}
|
|
518
1082
|
|
|
519
|
-
export function listShadowEntries(dir, alive = processAlive) {
|
|
1083
|
+
export function listShadowEntries(dir, alive = processAlive, { readdir = readdirSync, stat = statSync, onError } = {}) {
|
|
520
1084
|
const entries = [];
|
|
521
1085
|
let names = [];
|
|
522
1086
|
try {
|
|
523
|
-
names =
|
|
1087
|
+
names = readdir(dir, { withFileTypes: true });
|
|
524
1088
|
} catch {
|
|
1089
|
+
onError?.();
|
|
525
1090
|
return entries;
|
|
526
1091
|
}
|
|
527
1092
|
for (const entry of names) {
|
|
1093
|
+
if (entry.name === ROTATION_LOCK) continue;
|
|
528
1094
|
const path = resolve(dir, entry.name);
|
|
529
1095
|
if (entry.isFile() && isPluginShadowName(entry.name)) {
|
|
530
1096
|
try {
|
|
531
|
-
const info =
|
|
1097
|
+
const info = stat(path);
|
|
532
1098
|
entries.push({
|
|
533
1099
|
name: entry.name,
|
|
534
1100
|
path,
|
|
@@ -538,13 +1104,19 @@ export function listShadowEntries(dir, alive = processAlive) {
|
|
|
538
1104
|
legacy: true,
|
|
539
1105
|
live: false,
|
|
540
1106
|
});
|
|
541
|
-
} catch {}
|
|
1107
|
+
} catch { onError?.(); }
|
|
542
1108
|
continue;
|
|
543
1109
|
}
|
|
544
1110
|
if (!entry.isDirectory()) continue;
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
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 });
|
|
548
1120
|
}
|
|
549
1121
|
}
|
|
550
1122
|
return entries;
|
|
@@ -554,14 +1126,62 @@ export function listShadowEntries(dir, alive = processAlive) {
|
|
|
554
1126
|
// 当前卡片和这次引用的原文路径受保护。删不掉且仍然超限时返回 ok:false。
|
|
555
1127
|
// Delete legacy root files, then dead sessions, then old files of this session.
|
|
556
1128
|
// Protect the current card and cited raw paths. If unlink fails and the cap is still exceeded, ok is false.
|
|
557
|
-
export function rotateShadow(
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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;
|
|
562
1148
|
const low = Math.floor(cap * 0.8);
|
|
563
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
|
+
};
|
|
564
1183
|
if (!oversize && used + incomingBytes <= cap) {
|
|
1184
|
+
publishUsage();
|
|
565
1185
|
return { ok: true, deleted: 0, freed: 0, used, cap, oversize: false, failed: false };
|
|
566
1186
|
}
|
|
567
1187
|
const legacy = [];
|
|
@@ -585,14 +1205,10 @@ export function rotateShadow({ dir, incomingBytes, protectPath, protectPaths = [
|
|
|
585
1205
|
try {
|
|
586
1206
|
unlink(file.path);
|
|
587
1207
|
} catch (error) {
|
|
588
|
-
if (error?.code
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
deleted += 1;
|
|
592
|
-
continue;
|
|
1208
|
+
if (error?.code !== "ENOENT") {
|
|
1209
|
+
failed = true;
|
|
1210
|
+
break;
|
|
593
1211
|
}
|
|
594
|
-
failed = true;
|
|
595
|
-
break;
|
|
596
1212
|
}
|
|
597
1213
|
used -= file.size;
|
|
598
1214
|
freed += file.size;
|
|
@@ -604,8 +1220,10 @@ export function rotateShadow({ dir, incomingBytes, protectPath, protectPaths = [
|
|
|
604
1220
|
}
|
|
605
1221
|
}
|
|
606
1222
|
const fits = used + incomingBytes <= cap;
|
|
1223
|
+
const ok = !failed && (oversize || fits);
|
|
1224
|
+
if (ok) publishUsage();
|
|
607
1225
|
return {
|
|
608
|
-
ok
|
|
1226
|
+
ok,
|
|
609
1227
|
deleted,
|
|
610
1228
|
freed,
|
|
611
1229
|
used,
|