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/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 { tmpdir } from "node:os";
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 Number.isInteger(pid) && pid > 0 ? pid : 0;
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 false;
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
- export function shadowAccessPlan(platform = process.platform, userSid = "") {
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 (!/^S-\d+(?:-\d+)+$/.test(userSid)) {
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
- inheritance: "remove",
201
- grant: [
202
- `*${userSid}:(OI)(CI)F`,
203
- "*S-1-5-18:(OI)(CI)F",
204
- "*S-1-5-32-544:(OI)(CI)F",
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({ path, kind, platform, execFile = execFileSync }) {
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
- const identity = execFile(
229
- "whoami.exe",
230
- ["/user", "/fo", "csv", "/nh"],
231
- { encoding: "utf8", windowsHide: true },
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
- const plan = shadowAccessPlan("win32", match[1]);
239
- const target = resolve(path);
240
- const temp = mkdtempSync(join(tmpdir(), "spine-acl-"));
241
-
775
+ let whoamiPath = "";
776
+ let powershellPath = "";
242
777
  try {
243
- const aclFile = join(temp, "access.acl");
244
- const aclBody = `\ufeff${basename(target)}\r\n${plan.sddl}\r\n`;
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
- writeFileSync(aclFile, Buffer.from(aclBody, "utf16le"), {
247
- flag: "wx",
248
- mode: 0o600,
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
- execFile(
252
- "icacls.exe",
253
- [dirname(target), "/restore", aclFile, "/q"],
254
- { stdio: "pipe", windowsHide: true },
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
- return true;
258
- } finally {
259
- rmSync(temp, { recursive: true, force: true });
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 runner = access.runner ?? defaultShadowAccessRunner;
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 (runner({ path, kind, platform }) !== true) {
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 discardShadowFiles(files, cause) {
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
- unlinkSync(file);
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
- tightenShadow(protectedPath, "file", access);
338
- writeFileSync(fd, body);
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) discardShadowFiles([protectedPath], error);
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 = readdirSync(dir);
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 = statSync(path);
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 = readdirSync(dir, { withFileTypes: true });
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 = statSync(path);
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 live = ownerPid(path) ? alive(ownerPid(path)) : false;
443
- for (const file of listPluginShadows(path)) {
444
- entries.push({ ...file, legacy: false, live, sessionKey: file.sessionKey || entry.name });
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({ dir, incomingBytes, protectPath, protectPaths = [], sessionKey, cap, unlink = unlinkSync, alive = processAlive }) {
455
- const files = listShadowEntries(dir, alive);
456
- const protect = new Set([protectPath, ...protectPaths].filter(Boolean).map((path) => resolve(path)));
457
- const current = sanitizeSessionKey(sessionKey);
458
- let used = files.reduce((sum, file) => sum + file.size, 0);
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 === "ENOENT") {
486
- used -= file.size;
487
- freed += file.size;
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: !failed && (oversize || fits),
1226
+ ok,
506
1227
  deleted,
507
1228
  freed,
508
1229
  used,