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/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 Set();
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 lockKey(path) {
75
- return resolve(path).replace(/[\\/]+$/, "").toLowerCase();
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 lockedDirs.has(lockKey(path));
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 Number.isInteger(pid) && pid > 0 ? pid : 0;
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 false;
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 (!/^S-\d+(?:-\d+)+$/.test(userSid)) {
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
- inheritance: "remove",
264
- grant: [
265
- `*${userSid}:${scope}F`,
266
- `*S-1-5-18:${scope}F`,
267
- `*S-1-5-32-544:${scope}F`,
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 icaclsPath = "";
776
+ let powershellPath = "";
301
777
  try {
302
778
  whoamiPath = win32Tool("whoami.exe", env);
303
- icaclsPath = win32Tool("icacls.exe", env);
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(/,"(S-\d+(?:-\d+)+)"\s*$/i);
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
- execFile(
336
- icaclsPath,
337
- [resolve(path), "/inheritance:r", "/grant:r", ...plan.grant],
338
- { stdio: "pipe", windowsHide: true },
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 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) {
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
- unlinkSync(file);
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
- tightenShadow(protectedPath, "file", access);
441
- 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);
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) 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
+ }
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 = readdirSync(dir);
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 = statSync(path);
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 = readdirSync(dir, { withFileTypes: true });
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 = statSync(path);
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 live = ownerPid(path) ? alive(ownerPid(path)) : false;
546
- for (const file of listPluginShadows(path)) {
547
- 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 });
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({ dir, incomingBytes, protectPath, protectPaths = [], sessionKey, cap, unlink = unlinkSync, alive = processAlive }) {
558
- const files = listShadowEntries(dir, alive);
559
- const protect = new Set([protectPath, ...protectPaths].filter(Boolean).map((path) => resolve(path)));
560
- const current = sanitizeSessionKey(sessionKey);
561
- 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;
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 === "ENOENT") {
589
- used -= file.size;
590
- freed += file.size;
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: !failed && (oversize || fits),
1226
+ ok,
609
1227
  deleted,
610
1228
  freed,
611
1229
  used,