hikoutei 0.11.102-dev → 0.11.103-dev

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.
Files changed (48) hide show
  1. package/dist/cli/adoptFlow.d.ts.map +1 -1
  2. package/dist/cli/adoptFlow.js +31 -0
  3. package/dist/cli/adoptFlow.js.map +1 -1
  4. package/dist/cli/checkpoint.d.ts +36 -1
  5. package/dist/cli/checkpoint.d.ts.map +1 -1
  6. package/dist/cli/checkpoint.js +81 -21
  7. package/dist/cli/checkpoint.js.map +1 -1
  8. package/dist/cli/envFileWriter.d.ts +42 -8
  9. package/dist/cli/envFileWriter.d.ts.map +1 -1
  10. package/dist/cli/envFileWriter.js +60 -30
  11. package/dist/cli/envFileWriter.js.map +1 -1
  12. package/dist/cli/errors.d.ts +6 -5
  13. package/dist/cli/errors.d.ts.map +1 -1
  14. package/dist/cli/errors.js +6 -5
  15. package/dist/cli/errors.js.map +1 -1
  16. package/dist/cli/gcloudRunner.d.ts +22 -5
  17. package/dist/cli/gcloudRunner.d.ts.map +1 -1
  18. package/dist/cli/gcloudRunner.js +168 -0
  19. package/dist/cli/gcloudRunner.js.map +1 -1
  20. package/dist/cli/keyProvision.d.ts +22 -44
  21. package/dist/cli/keyProvision.d.ts.map +1 -1
  22. package/dist/cli/keyProvision.js +212 -103
  23. package/dist/cli/keyProvision.js.map +1 -1
  24. package/dist/cli/setupFlow.d.ts +2 -7
  25. package/dist/cli/setupFlow.d.ts.map +1 -1
  26. package/dist/cli/setupFlow.js +301 -55
  27. package/dist/cli/setupFlow.js.map +1 -1
  28. package/dist/cli/setupPathCollision.d.ts +11 -3
  29. package/dist/cli/setupPathCollision.d.ts.map +1 -1
  30. package/dist/cli/setupPathCollision.js +9 -6
  31. package/dist/cli/setupPathCollision.js.map +1 -1
  32. package/dist/cli/setupPaths.d.ts +2 -0
  33. package/dist/cli/setupPaths.d.ts.map +1 -1
  34. package/dist/cli/setupPaths.js +9 -0
  35. package/dist/cli/setupPaths.js.map +1 -1
  36. package/dist/sheets/providers/google-sheets-api/model/batchBuilder.d.ts +10 -0
  37. package/dist/sheets/providers/google-sheets-api/model/batchBuilder.d.ts.map +1 -1
  38. package/dist/sheets/providers/google-sheets-api/model/batchBuilder.js +25 -36
  39. package/dist/sheets/providers/google-sheets-api/model/batchBuilder.js.map +1 -1
  40. package/dist/sheets/providers/google-sheets-api/model/planner.d.ts.map +1 -1
  41. package/dist/sheets/providers/google-sheets-api/model/planner.js +0 -3
  42. package/dist/sheets/providers/google-sheets-api/model/planner.js.map +1 -1
  43. package/dist/sheets/providers/google-sheets-api/operations/fastAppend.d.ts.map +1 -1
  44. package/dist/sheets/providers/google-sheets-api/operations/fastAppend.js +3 -13
  45. package/dist/sheets/providers/google-sheets-api/operations/fastAppend.js.map +1 -1
  46. package/dist/sheets/providers/google-sheets-api/operations/rowChecks.js +9 -10
  47. package/dist/sheets/providers/google-sheets-api/operations/rowChecks.js.map +1 -1
  48. package/package.json +1 -1
@@ -59,11 +59,20 @@
59
59
  * unsafe types or permission failures reject the run without touching
60
60
  * foreign entries. The key create runs gcloud with a RELATIVE `key.json`
61
61
  * destination from the staging directory as the subprocess working
62
- * directory (runner `cwd`), with the staging directory re-verified
63
- * (type/mode/identity through a no-follow descriptor) IMMEDIATELY before
64
- * the spawn; the credential write is thereby bound to the validated
65
- * private staging directory for cooperating setup actors, and a
66
- * replacement at the staging pathname is never silently trusted. The same parent validation runs BEFORE the staged key is ever read during reconciliation: `key.json` is never inspected,
62
+ * directory (runner `cwd`, expected identity, and a pinned directory fd),
63
+ * with the staging directory re-verified through a no-follow descriptor
64
+ * immediately before spawn and its identity checked again immediately
65
+ * after spawn, before the result is recorded or staged output is trusted.
66
+ * The isolated Node child (`process.execPath`, parent CWD untouched)
67
+ * compares its `statSync('.')` with `fstatSync(3)` for the inherited pinned
68
+ * fd. A replacement cannot pass by reusing the old inode, and gcloud
69
+ * inherits the verified object-bound CWD — the
70
+ * staging-cwd write window (#673) is closed with portable built-ins, no
71
+ * native binding. The post-spawn identity re-check stays as defense in
72
+ * depth. A transient swap-back (the
73
+ * replacement removed before the post-spawn check) leaves no key in the
74
+ * real directory, so the bounded settlement below ends `uncertain`
75
+ * without ever trusting the foreign directory. The same parent validation runs BEFORE the staged key is ever read during reconciliation: `key.json` is never inspected,
67
76
  * opened, chmod'ed, or read unless its deterministic parent is absent or a
68
77
  * plain directory verified/secured to 0700 through the no-follow
69
78
  * descriptor, and a symlinked or non-directory stage parent fails closed
@@ -306,8 +315,7 @@ export type KeySettleOutcome = {
306
315
  */
307
316
  export declare function settleServiceAccountKey(runner: GcloudRunner, executed: PlannedCommand[], input: KeySettleInput): Promise<KeySettleOutcome>;
308
317
  /**
309
- * Creates the deterministic staging directory (mode 0700) and returns its
310
- * identity.
318
+ * Creates or secures the deterministic staging directory and pins it open.
311
319
  *
312
320
  * A pre-existing entry is accepted only when it is a real directory (a
313
321
  * crash between the checkpoint write and the create leaves an empty owned
@@ -317,23 +325,23 @@ export declare function settleServiceAccountKey(runner: GcloudRunner, executed:
317
325
  * platform supports it (the mode is applied and verified on the open
318
326
  * descriptor, never via a check-then-chmod path race), and any unsafe
319
327
  * type/permission failure rejects the run without touching foreign
320
- * entries. The returned device/inode is the identity the pre-subprocess
321
- * re-verification compares against. Exported so tests can exercise the
322
- * pre-existing-directory security path directly (it is only reachable
323
- * from the one fresh create).
328
+ * entries. Its open descriptor pins the directory object through process
329
+ * launch, preventing inode reuse from making a replacement appear identical.
330
+ * Exported so tests can exercise the pre-existing-directory path directly.
324
331
  */
325
332
  export declare function prepareStageDir(keyPath: string, keyMarker: string): PreparedStageDir;
326
333
  /**
327
334
  * Result of preparing the deterministic staging directory.
328
335
  *
329
- * `ok` carries the prepared directory's device/inode so the subprocess
330
- * pre-verification can prove the path still names the exact directory this
331
- * invocation created/secured.
336
+ * `ok` carries a pinned descriptor and its device/inode identity so the
337
+ * subprocess wrapper can verify its working directory against the exact
338
+ * open filesystem object.
332
339
  */
333
340
  export type PreparedStageDir = {
334
341
  readonly status: "ok";
335
342
  readonly dev: number;
336
343
  readonly ino: number;
344
+ readonly fd: number;
337
345
  } | {
338
346
  readonly status: "error";
339
347
  readonly error: SetupErrorResult;
@@ -354,36 +362,6 @@ export interface KeyCleanupFs {
354
362
  fstatSync(fd: number): Stats;
355
363
  closeSync(fd: number): void;
356
364
  }
357
- /**
358
- * Ownership-bound, crash-resumable staged cleanup, only after a verified
359
- * installation.
360
- *
361
- * The final key path is verified with lstat (never following a symlink) and
362
- * must be a regular file; a final symlink is refused and the staged
363
- * credential is retained. Before any deletion the deterministic stage
364
- * directory is atomically quarantined: renamed to the deterministic private
365
- * cleanup sibling path derived from the SAME persisted key marker (never an
366
- * unrecoverable random name), with the device/inode captured before the
367
- * rename re-verified after it — a source replaced between the capture and
368
- * the rename fails closed and nothing is deleted. Inside the quarantined
369
- * 0700 directory the staged entry is re-checked as a regular file with the
370
- * same device/inode as the no-follow final key immediately before its
371
- * unlink; any mismatch, foreign, or non-empty entry is preserved (the
372
- * directory is moved back to the stage path so the user finds the state
373
- * where the docs say it is). The empty quarantined directory is then
374
- * rmdir'd. Never recursive-delete.
375
- *
376
- * Crash-resume boundaries: a crash-left cleanup directory with the matching
377
- * staged hardlink is finished (verify against the no-follow final, unlink,
378
- * rmdir); an empty cleanup directory (crash after the staged unlink) is
379
- * rmdir'd; an absent stage + cleanup pair means the cleanup already
380
- * finished (success); both existing at once fails closed. The setup lock is
381
- * held for the whole run — a second cooperating setup process cannot
382
- * acquire it concurrently, so no other run can be touching these
383
- * deterministic paths — and Node offers no literal unlinkat without a raw
384
- * libuv binding, so this quarantine + post-rename identity design is the
385
- * permitted boundary for the pathname unlink race.
386
- */
387
365
  export declare function cleanupOwnedStage(keyPath: string, keyMarker: string, fs?: KeyCleanupFs): SetupErrorResult | null;
388
366
  export {};
389
367
  //# sourceMappingURL=keyProvision.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"keyProvision.d.ts","sourceRoot":"","sources":["../../../../../../src/keyProvision.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"keyProvision.d.ts","sourceRoot":"","sources":["../../../../../../src/keyProvision.ts"],"names":[],"mappings":"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"}
@@ -59,11 +59,20 @@
59
59
  * unsafe types or permission failures reject the run without touching
60
60
  * foreign entries. The key create runs gcloud with a RELATIVE `key.json`
61
61
  * destination from the staging directory as the subprocess working
62
- * directory (runner `cwd`), with the staging directory re-verified
63
- * (type/mode/identity through a no-follow descriptor) IMMEDIATELY before
64
- * the spawn; the credential write is thereby bound to the validated
65
- * private staging directory for cooperating setup actors, and a
66
- * replacement at the staging pathname is never silently trusted. The same parent validation runs BEFORE the staged key is ever read during reconciliation: `key.json` is never inspected,
62
+ * directory (runner `cwd`, expected identity, and a pinned directory fd),
63
+ * with the staging directory re-verified through a no-follow descriptor
64
+ * immediately before spawn and its identity checked again immediately
65
+ * after spawn, before the result is recorded or staged output is trusted.
66
+ * The isolated Node child (`process.execPath`, parent CWD untouched)
67
+ * compares its `statSync('.')` with `fstatSync(3)` for the inherited pinned
68
+ * fd. A replacement cannot pass by reusing the old inode, and gcloud
69
+ * inherits the verified object-bound CWD — the
70
+ * staging-cwd write window (#673) is closed with portable built-ins, no
71
+ * native binding. The post-spawn identity re-check stays as defense in
72
+ * depth. A transient swap-back (the
73
+ * replacement removed before the post-spawn check) leaves no key in the
74
+ * real directory, so the bounded settlement below ends `uncertain`
75
+ * without ever trusting the foreign directory. The same parent validation runs BEFORE the staged key is ever read during reconciliation: `key.json` is never inspected,
67
76
  * opened, chmod'ed, or read unless its deterministic parent is absent or a
68
77
  * plain directory verified/secured to 0700 through the no-follow
69
78
  * descriptor, and a symlinked or non-directory stage parent fails closed
@@ -657,15 +666,20 @@ async function settleKeyPass(runner, executed, input, createIssued) {
657
666
  return { status: "error", error: prepared.error };
658
667
  }
659
668
  // The gcloud create runs with the staging directory as the subprocess
660
- // working directory and a RELATIVE `key.json` destination, so the
661
- // credential write is bound to the validated private directory instead
662
- // of a pathname that could be swapped mid-run. Immediately before the
663
- // spawn, the directory is re-verified (no-follow descriptor, type,
664
- // identity, owner-only mode) against the identity captured by
665
- // prepareStageDir: a replacement is never silently trusted.
669
+ // working directory and a RELATIVE `key.json` destination. Immediately
670
+ // before the spawn, the directory is re-verified (no-follow
671
+ // descriptor, type, identity, owner-only mode) against the identity
672
+ // captured by prepareStageDir; the runner then binds the child CWD to
673
+ // that verified object through an isolated Node wrapper (chdir +
674
+ // statSync('.') identity check, gcloud inherits the bound directory
675
+ // with no `cwd` option), so a replacement at the staging pathname
676
+ // cannot redirect the credential write. Immediately AFTER the spawn
677
+ // returns the identity is re-checked again before the result is
678
+ // recorded, as defense in depth.
666
679
  const stageDir = keyStageDir(input.keyPath, input.keyMarker);
667
680
  const stillSecure = verifyStageDirBeforeSubprocess(stageDir, prepared);
668
681
  if (stillSecure !== null) {
682
+ closePreparedStageDir(prepared.fd);
669
683
  return { status: "error", error: stillSecure };
670
684
  }
671
685
  const keyCreateCommand = [
@@ -681,15 +695,35 @@ async function settleKeyPass(runner, executed, input, createIssued) {
681
695
  input.projectId,
682
696
  ];
683
697
  let keyCreate;
698
+ let afterSpawn = null;
684
699
  try {
685
- keyCreate = await runner.run(keyCreateCommand, { cwd: stageDir });
700
+ try {
701
+ keyCreate = await runner.run(keyCreateCommand, {
702
+ cwd: stageDir,
703
+ cwdIdentity: { dev: prepared.dev, ino: prepared.ino },
704
+ cwdFd: prepared.fd,
705
+ });
706
+ }
707
+ catch {
708
+ // The invocation threw (spawn/transport failure) after gcloud may or
709
+ // may not have written the staged key. Treat it as a lost result and
710
+ // poll the deterministic stage + current key list below instead of
711
+ // bubbling an unexpected error; the thrown text is never forwarded.
712
+ keyCreate = { status: "failed", code: null, stdout: "", stderr: "" };
713
+ }
714
+ // Keep the prepared directory descriptor open through this path check:
715
+ // otherwise an unlinked inode could be reused before dev/ino comparison.
716
+ afterSpawn = verifyStageDirIdentityAfterSpawn(stageDir, prepared);
686
717
  }
687
- catch {
688
- // The invocation threw (spawn/transport failure) after gcloud may or
689
- // may not have written the staged key. Treat it as a lost result and
690
- // poll the deterministic stage + current key list below instead of
691
- // bubbling an unexpected error; the thrown text is never forwarded.
692
- keyCreate = { status: "failed", code: null, stdout: "", stderr: "" };
718
+ finally {
719
+ closePreparedStageDir(prepared.fd);
720
+ }
721
+ // Before the result is recorded or any staged output is trusted, a
722
+ // mismatch — or a vanished directory — fails closed with nothing from
723
+ // the replacement read, chmod'ed, installed, or cleaned up, and the
724
+ // `key_create_started` checkpoint is retained for a reconcile-only resume.
725
+ if (afterSpawn !== null) {
726
+ return { status: "error", error: afterSpawn };
693
727
  }
694
728
  executed.push({
695
729
  kind: "gcloud",
@@ -739,8 +773,7 @@ function uncertainResult(message) {
739
773
  /** Owner-only permission of the key staging directory. */
740
774
  const KEY_STAGE_DIR_MODE = 0o700;
741
775
  /**
742
- * Creates the deterministic staging directory (mode 0700) and returns its
743
- * identity.
776
+ * Creates or secures the deterministic staging directory and pins it open.
744
777
  *
745
778
  * A pre-existing entry is accepted only when it is a real directory (a
746
779
  * crash between the checkpoint write and the create leaves an empty owned
@@ -750,10 +783,9 @@ const KEY_STAGE_DIR_MODE = 0o700;
750
783
  * platform supports it (the mode is applied and verified on the open
751
784
  * descriptor, never via a check-then-chmod path race), and any unsafe
752
785
  * type/permission failure rejects the run without touching foreign
753
- * entries. The returned device/inode is the identity the pre-subprocess
754
- * re-verification compares against. Exported so tests can exercise the
755
- * pre-existing-directory security path directly (it is only reachable
756
- * from the one fresh create).
786
+ * entries. Its open descriptor pins the directory object through process
787
+ * launch, preventing inode reuse from making a replacement appear identical.
788
+ * Exported so tests can exercise the pre-existing-directory path directly.
757
789
  */
758
790
  export function prepareStageDir(keyPath, keyMarker) {
759
791
  const stageDir = keyStageDir(keyPath, keyMarker);
@@ -767,33 +799,19 @@ export function prepareStageDir(keyPath, keyMarker) {
767
799
  error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not create a private staging directory for the service-account key: ${messageOf(error)}`),
768
800
  };
769
801
  }
770
- const secured = secureExistingStageDir(stageDir, defaultKeyCleanupFs, "staging directory");
771
- return secured.status === "ok"
772
- ? { status: "ok", dev: secured.dev, ino: secured.ino }
773
- : { status: "error", error: secured.error };
774
- }
775
- // Freshly created: capture the identity (device/inode) so the
776
- // pre-subprocess verification can prove the directory at the staging
777
- // path is still the one this invocation created.
802
+ }
803
+ const secured = secureExistingStageDir(stageDir, defaultKeyCleanupFs, "staging directory");
804
+ return secured.status === "ok"
805
+ ? { status: "ok", dev: secured.dev, ino: secured.ino, fd: secured.fd }
806
+ : { status: "error", error: secured.error };
807
+ }
808
+ /** Closes the pinned staging descriptor after the subprocess identity check. */
809
+ function closePreparedStageDir(fd) {
778
810
  try {
779
- const stat = lstatSync(stageDir);
780
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
781
- // The directory we just created was replaced before it could be
782
- // verified: fail closed and never touch the replacement.
783
- return {
784
- status: "error",
785
- error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `the staging directory ${stageDir} is not a plain directory; remove it after ` +
786
- `confirming it is not needed and rerun setup`),
787
- };
788
- }
789
- return { status: "ok", dev: stat.dev, ino: stat.ino };
811
+ closeSync(fd);
790
812
  }
791
- catch (error) {
792
- return {
793
- status: "error",
794
- error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not verify the staging directory ${stageDir}: ${messageOf(error)}; remove it ` +
795
- `after confirming it is not needed and rerun setup`),
796
- };
813
+ catch {
814
+ // Preserve the setup result; the descriptor is no longer in use.
797
815
  }
798
816
  }
799
817
  /**
@@ -803,12 +821,14 @@ export function prepareStageDir(keyPath, keyMarker) {
803
821
  * The directory is opened WITHOUT following symlinks and its type,
804
822
  * device/inode identity, and owner-only mode are verified THROUGH the
805
823
  * descriptor against the identity captured by `prepareStageDir`; only an
806
- * exact match is allowed to receive an `fchmod` (the directory is ours),
807
- * and the subprocess runs with that directory as its working directory and
808
- * a RELATIVE `key.json` destination, so the credential write is bound to
809
- * the validated private directory. A replacement — symlink, non-directory,
810
- * foreign directory, or mode failure — fails closed BEFORE the spawn and
811
- * is never chmod'ed, read, or written through.
824
+ * exact match is allowed to receive an `fchmod` (the directory is ours).
825
+ * The pinned prepared descriptor is inherited by the wrapper, which
826
+ * compares `statSync('.')` with `fstatSync(3)` before gcloud runs; a path
827
+ * replacement cannot pass merely by reusing the inode number. The
828
+ * post-spawn identity re-check stays as defense in depth. A replacement
829
+ * — symlink, non-directory, foreign directory, or mode failure — fails
830
+ * closed BEFORE the spawn and is never chmod'ed, read, or written
831
+ * through.
812
832
  */
813
833
  function verifyStageDirBeforeSubprocess(stageDir, expected) {
814
834
  let fd;
@@ -855,20 +875,58 @@ function verifyStageDirBeforeSubprocess(stageDir, expected) {
855
875
  }
856
876
  }
857
877
  /**
858
- * Verifies and secures a pre-existing deterministic directory.
878
+ * Re-checks the staging directory identity immediately AFTER the gcloud
879
+ * key-create subprocess returns, before its result is recorded or any
880
+ * staged output is trusted (defense in depth behind the wrapper's
881
+ * object-bound CWD, which already prevents the write from landing in a
882
+ * replacement).
883
+ *
884
+ * Only the exact device/inode captured by `prepareStageDir` passes;
885
+ * anything else fails closed with `key_create_failed` and nothing from
886
+ * the replacement is ever read, chmod'ed, installed, or cleaned up.
887
+ * Deliberately chmod-free (unlike the pre-spawn check): the entry may be
888
+ * foreign, so its mode is never touched. A transient swap-back — the
889
+ * replacement removed before this check — leaves no key in the real
890
+ * directory, so the bounded settlement ends `uncertain` without ever
891
+ * trusting the foreign directory.
892
+ */
893
+ function verifyStageDirIdentityAfterSpawn(stageDir, expected) {
894
+ let lst;
895
+ try {
896
+ lst = lstatSync(stageDir);
897
+ }
898
+ catch (error) {
899
+ if (isNodeError(error) && error.code === "ENOENT") {
900
+ return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `the staging directory ${stageDir} disappeared while the key was being created; ` +
901
+ `nothing was trusted — rerun setup`);
902
+ }
903
+ return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not re-verify the staging directory ${stageDir}: ${messageOf(error)}; nothing ` +
904
+ `was trusted — rerun setup`);
905
+ }
906
+ if (lst === undefined || lst.isSymbolicLink() || !lst.isDirectory()) {
907
+ return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `the staging directory ${stageDir} is not a plain directory; nothing was trusted — ` +
908
+ `remove it after confirming it is not needed and rerun setup`);
909
+ }
910
+ if (lst.dev !== expected.dev || lst.ino !== expected.ino) {
911
+ return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `the staging directory ${stageDir} was replaced while the key was being created; ` +
912
+ `nothing was trusted — remove the replacement after confirming it is not needed and rerun setup`);
913
+ }
914
+ return null;
915
+ }
916
+ /**
917
+ * Verifies and secures a deterministic directory through a no-follow fd.
859
918
  *
860
919
  * The entry must be a plain directory (a symlink or non-directory is
861
- * refused and left untouched). The directory is opened with `O_NOFOLLOW`
862
- * where the platform defines it, its type is confirmed on the descriptor,
863
- * and its mode is enforced to owner-only 0700 through `fchmod` on that
864
- * descriptor with the resulting mode verified before close — so a swap
865
- * between the type check and the chmod cannot redirect the chmod onto a
866
- * foreign target. Any open/type/chmod failure fails closed; entries inside
867
- * the directory are never touched. `label` names the directory kind in
868
- * messages ("staging directory" or "cleanup directory").
920
+ * refused and left untouched). Descriptor and pathname identities are
921
+ * matched before any mode change; the mode is enforced through `fchmod`
922
+ * and verified on the descriptor. Success returns the still-open fd so a
923
+ * caller can pin the object across process launch; other callers close it
924
+ * after this check. Entries inside the directory are never touched.
925
+ * `label` names the directory kind in messages.
869
926
  */
870
927
  function secureExistingStageDir(dirPath, fs, label) {
871
928
  let fd;
929
+ let keepOpen = false;
872
930
  try {
873
931
  fd = fs.openSync(dirPath, constants.O_RDONLY | noFollowFlag() | directoryFlag());
874
932
  }
@@ -888,11 +946,16 @@ function secureExistingStageDir(dirPath, fs, label) {
888
946
  }
889
947
  try {
890
948
  let stat = fs.fstatSync(fd);
891
- if (!stat.isDirectory()) {
949
+ const pathStat = fs.lstatSync(dirPath);
950
+ if (!stat.isDirectory() ||
951
+ pathStat.isSymbolicLink() ||
952
+ !pathStat.isDirectory() ||
953
+ pathStat.dev !== stat.dev ||
954
+ pathStat.ino !== stat.ino) {
892
955
  return {
893
956
  status: "error",
894
- error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `the ${label} ${dirPath} is not a plain directory; remove it after confirming it ` +
895
- `is not needed and rerun setup`),
957
+ error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `the ${label} ${dirPath} is not the verified plain directory; remove it after confirming ` +
958
+ `it is not needed and rerun setup`),
896
959
  };
897
960
  }
898
961
  if ((Number(stat.mode) & 0o777) !== KEY_STAGE_DIR_MODE) {
@@ -914,7 +977,8 @@ function secureExistingStageDir(dirPath, fs, label) {
914
977
  };
915
978
  }
916
979
  }
917
- return { status: "ok", dev: stat.dev, ino: stat.ino };
980
+ keepOpen = true;
981
+ return { status: "ok", dev: stat.dev, ino: stat.ino, fd };
918
982
  }
919
983
  catch (error) {
920
984
  return {
@@ -923,11 +987,13 @@ function secureExistingStageDir(dirPath, fs, label) {
923
987
  };
924
988
  }
925
989
  finally {
926
- try {
927
- fs.closeSync(fd);
928
- }
929
- catch {
930
- // The security verdict is the one to report.
990
+ if (!keepOpen) {
991
+ try {
992
+ fs.closeSync(fd);
993
+ }
994
+ catch {
995
+ // The security verdict is the one to report.
996
+ }
931
997
  }
932
998
  }
933
999
  }
@@ -975,6 +1041,12 @@ function inspectStagedKeyParent(keyPath, keyMarker) {
975
1041
  if (secured.status === "error") {
976
1042
  return { status: "error", error: secured.error };
977
1043
  }
1044
+ try {
1045
+ defaultKeyCleanupFs.closeSync(secured.fd);
1046
+ }
1047
+ catch {
1048
+ // Keep the validation result; the descriptor is no longer needed here.
1049
+ }
978
1050
  return { status: "present", dev: secured.dev, ino: secured.ino };
979
1051
  }
980
1052
  /**
@@ -1052,35 +1124,42 @@ const defaultKeyCleanupFs = {
1052
1124
  closeSync,
1053
1125
  };
1054
1126
  /**
1055
- * Ownership-bound, crash-resumable staged cleanup, only after a verified
1056
- * installation.
1057
- *
1058
- * The final key path is verified with lstat (never following a symlink) and
1059
- * must be a regular file; a final symlink is refused and the staged
1060
- * credential is retained. Before any deletion the deterministic stage
1061
- * directory is atomically quarantined: renamed to the deterministic private
1062
- * cleanup sibling path derived from the SAME persisted key marker (never an
1063
- * unrecoverable random name), with the device/inode captured before the
1064
- * rename re-verified after it — a source replaced between the capture and
1065
- * the rename fails closed and nothing is deleted. Inside the quarantined
1066
- * 0700 directory the staged entry is re-checked as a regular file with the
1067
- * same device/inode as the no-follow final key immediately before its
1068
- * unlink; any mismatch, foreign, or non-empty entry is preserved (the
1069
- * directory is moved back to the stage path so the user finds the state
1070
- * where the docs say it is). The empty quarantined directory is then
1071
- * rmdir'd. Never recursive-delete.
1127
+ * Re-reads both quarantine paths after `renameSync` reports ENOENT.
1072
1128
  *
1073
- * Crash-resume boundaries: a crash-left cleanup directory with the matching
1074
- * staged hardlink is finished (verify against the no-follow final, unlink,
1075
- * rmdir); an empty cleanup directory (crash after the staged unlink) is
1076
- * rmdir'd; an absent stage + cleanup pair means the cleanup already
1077
- * finished (success); both existing at once fails closed. The setup lock is
1078
- * held for the whole run — a second cooperating setup process cannot
1079
- * acquire it concurrently, so no other run can be touching these
1080
- * deterministic paths — and Node offers no literal unlinkat without a raw
1081
- * libuv binding, so this quarantine + post-rename identity design is the
1082
- * permitted boundary for the pathname unlink race.
1129
+ * Either entry may have appeared, vanished, or been replaced since the
1130
+ * pre-rename inspection, so the stale pair must not decide the outcome.
1131
+ * A non-ENOENT inspection failure fails closed; otherwise the fresh pair
1132
+ * is returned (an entry confirmed absent is `undefined`).
1083
1133
  */
1134
+ function reinspectStageAndCleanup(stageDir, cleanupDir, fs) {
1135
+ let stage;
1136
+ try {
1137
+ stage = fs.lstatSync(stageDir);
1138
+ }
1139
+ catch (error) {
1140
+ if (!isNodeError(error) || error.code !== "ENOENT") {
1141
+ return {
1142
+ status: "error",
1143
+ error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not inspect the staging directory ${stageDir}: ${messageOf(error)}; rerun setup to finish`),
1144
+ };
1145
+ }
1146
+ stage = undefined;
1147
+ }
1148
+ let cleanup;
1149
+ try {
1150
+ cleanup = fs.lstatSync(cleanupDir);
1151
+ }
1152
+ catch (error) {
1153
+ if (!isNodeError(error) || error.code !== "ENOENT") {
1154
+ return {
1155
+ status: "error",
1156
+ error: errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not inspect the cleanup directory ${cleanupDir}: ${messageOf(error)}; rerun setup to finish`),
1157
+ };
1158
+ }
1159
+ cleanup = undefined;
1160
+ }
1161
+ return { status: "ok", stage, cleanup };
1162
+ }
1084
1163
  export function cleanupOwnedStage(keyPath, keyMarker, fs = defaultKeyCleanupFs) {
1085
1164
  const stageDir = keyStageDir(keyPath, keyMarker);
1086
1165
  const cleanupDir = keyCleanupDir(keyPath, keyMarker);
@@ -1148,10 +1227,34 @@ export function cleanupOwnedStage(keyPath, keyMarker, fs = defaultKeyCleanupFs)
1148
1227
  }
1149
1228
  catch (error) {
1150
1229
  if (isNodeError(error) && error.code === "ENOENT") {
1151
- // The stage directory vanished between the check and the rename:
1152
- // nothing was quarantined; resume from whatever is at the cleanup
1153
- // path below.
1230
+ // The rename lost a source-or-destination race (the stage vanished
1231
+ // or the cleanup parent changed under us): the pre-rename
1232
+ // `cleanupLst` is now stale evidence and must not decide the
1233
+ // outcome. Re-inspect BOTH paths fresh — only a freshly confirmed
1234
+ // absent/absent pair is already-clean.
1235
+ const fresh = reinspectStageAndCleanup(stageDir, cleanupDir, fs);
1236
+ if (fresh.status === "error") {
1237
+ return fresh.error;
1238
+ }
1239
+ if (fresh.stage === undefined && fresh.cleanup === undefined) {
1240
+ return null;
1241
+ }
1242
+ if (fresh.stage !== undefined && fresh.cleanup !== undefined) {
1243
+ return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `both the staging directory ${stageDir} and the cleanup directory ${cleanupDir} exist; ` +
1244
+ `remove one after confirming it is not needed and rerun setup`);
1245
+ }
1246
+ if (fresh.stage !== undefined) {
1247
+ // The stage is (still) there but the rename reported ENOENT, so
1248
+ // the quarantine never happened: fail closed and let the next
1249
+ // run retry it instead of deleting through a path we never
1250
+ // quarantined.
1251
+ return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not quarantine the staging directory ${stageDir}: the directory changed ` +
1252
+ `during the rename; rerun setup to finish`);
1253
+ }
1254
+ // Stage confirmed absent with a freshly present cleanup directory:
1255
+ // finish that directory below (it is re-validated there).
1154
1256
  stageLst = undefined;
1257
+ cleanupLst = fresh.cleanup;
1155
1258
  }
1156
1259
  else {
1157
1260
  return errorResult(SETUP_ERROR_CODES.KEY_CREATE_FAILED, `could not quarantine the staging directory ${stageDir}: ${messageOf(error)}; rerun setup to finish`);
@@ -1198,6 +1301,12 @@ export function cleanupOwnedStage(keyPath, keyMarker, fs = defaultKeyCleanupFs)
1198
1301
  if (secured.status === "error") {
1199
1302
  return secured.error;
1200
1303
  }
1304
+ try {
1305
+ fs.closeSync(secured.fd);
1306
+ }
1307
+ catch {
1308
+ // Keep the validation result; the descriptor is no longer needed here.
1309
+ }
1201
1310
  let entries;
1202
1311
  try {
1203
1312
  entries = fs.readdirSync(cleanupDir);