@stacksjs/defaults 0.75.361 → 0.75.362

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 (208) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +2 -2
  3. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  4. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  5. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  8. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  9. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  10. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  11. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  16. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  17. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  21. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  22. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  24. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  27. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  28. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  37. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  38. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  39. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  40. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  42. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  43. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  56. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  57. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  58. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  59. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  69. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  70. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  111. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  120. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  121. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  122. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  124. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  125. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  126. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  127. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  128. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  129. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  130. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  134. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  141. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  147. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  149. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  175. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  186. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  187. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  188. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  189. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  190. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  191. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  192. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  194. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  195. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  196. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  199. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  201. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  205. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  206. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  207. package/ide/vscode/package.json +1 -1
  208. package/package.json +2 -2
@@ -0,0 +1,1982 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { canonicalFuturePath } from './output-path.mjs';
6
+
7
+ function relation(status, code, details = {}) {
8
+ return { status, reason: { code, ...details } };
9
+ }
10
+
11
+ function filesystemFailure(code, error, details = {}) {
12
+ return relation('unknown', code, {
13
+ ...details,
14
+ ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
15
+ ...(typeof error?.message === 'string' ? { message: error.message } : {}),
16
+ });
17
+ }
18
+
19
+ function entryType(metadata) {
20
+ if (metadata.isFile()) return 'file';
21
+ if (metadata.isSymbolicLink()) return 'symbolic-link';
22
+ if (metadata.isDirectory()) return 'directory';
23
+ if (metadata.isFIFO()) return 'fifo';
24
+ if (metadata.isSocket()) return 'socket';
25
+ if (metadata.isBlockDevice()) return 'block-device';
26
+ if (metadata.isCharacterDevice()) return 'character-device';
27
+ return 'other';
28
+ }
29
+
30
+ // Windows lstat can fall back to directory enumeration when a file handle
31
+ // cannot be opened, and that fallback reports a synthetic link count of one.
32
+ // Treat handle-backed fstat as authoritative and fail closed when it cannot be
33
+ // reconciled with the no-follow path snapshots on either side of the open.
34
+ function captureRegularFileHandle(filePath, initial, subject) {
35
+ const pathKey = subject === 'target'
36
+ ? 'commitPath'
37
+ : subject === 'requested-entry'
38
+ ? 'requestedPath'
39
+ : 'candidatePath';
40
+ if (initial.ino === 0n) {
41
+ return relation('unknown', `${subject}-identity-unavailable`, { [pathKey]: filePath });
42
+ }
43
+ let descriptor;
44
+ let handle;
45
+ let current;
46
+ let closeError;
47
+ try {
48
+ const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
49
+ const nonBlock = fs.constants.O_NONBLOCK || 0;
50
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonBlock);
51
+ handle = fs.fstatSync(descriptor, { bigint: true });
52
+ current = fs.lstatSync(filePath, { bigint: true });
53
+ } catch (error) {
54
+ return filesystemFailure(`${subject}-handle-inspection-failed`, error, {
55
+ [pathKey]: filePath,
56
+ });
57
+ } finally {
58
+ if (descriptor !== undefined) {
59
+ try {
60
+ fs.closeSync(descriptor);
61
+ } catch (error) {
62
+ closeError = error;
63
+ }
64
+ }
65
+ }
66
+ if (closeError) {
67
+ return filesystemFailure(`${subject}-handle-close-failed`, closeError, {
68
+ [pathKey]: filePath,
69
+ });
70
+ }
71
+ if (!handle.isFile() || !current.isFile()
72
+ || handle.ino === 0n || current.ino === 0n
73
+ || handle.dev !== initial.dev || handle.ino !== initial.ino
74
+ || current.dev !== handle.dev || current.ino !== handle.ino) {
75
+ return relation('unknown', `${subject}-changed-during-inspection`, {
76
+ [pathKey]: filePath,
77
+ });
78
+ }
79
+ if (handle.nlink === 0n) {
80
+ return relation('unknown', `${subject}-link-count-unavailable`, {
81
+ [pathKey]: filePath,
82
+ });
83
+ }
84
+ if (handle.nlink !== 1n) {
85
+ return relation('unsupported', `${subject}-hardlinked`, {
86
+ [pathKey]: filePath,
87
+ links: handle.nlink.toString(),
88
+ });
89
+ }
90
+ return { status: 'captured', metadata: handle };
91
+ }
92
+
93
+ const regularFileBindings = new WeakMap();
94
+ const regularFileBindingGroups = new Map();
95
+ const digestChunkBytes = 64 * 1024;
96
+ const removalCleanupSignal = new Int32Array(new SharedArrayBuffer(4));
97
+ const removalCleanupAttempts = 10;
98
+ const publicationRecoveryRecordName = 'publication-recovery-v1.json';
99
+ const publicationRecoveryRecordVersion = 1;
100
+ const publicationRecoveryRecordMaxBytes = 16 * 1024;
101
+
102
+ export function removeEmptyDirectoryWithRetry(directory, { retry = true } = {}) {
103
+ const attempts = retry ? removalCleanupAttempts : 1;
104
+ let failure;
105
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
106
+ try {
107
+ fs.rmdirSync(directory);
108
+ return;
109
+ } catch (error) {
110
+ failure = error;
111
+ // SMB can acknowledge unlink before a following directory removal sees
112
+ // the deleted entry disappear. Retrying rmdir is claimant-safe: a real
113
+ // entry keeps the directory non-empty and is never removed recursively.
114
+ if (error?.code !== 'ENOTEMPTY' || attempt === attempts - 1) break;
115
+ Atomics.wait(
116
+ removalCleanupSignal,
117
+ 0,
118
+ 0,
119
+ Math.min(5 * (2 ** attempt), 250),
120
+ );
121
+ }
122
+ }
123
+ throw failure;
124
+ }
125
+
126
+ function validExpectedLinks(value) {
127
+ return Number.isSafeInteger(value) && value > 0;
128
+ }
129
+
130
+ function validateBindingExpectations(options) {
131
+ const hasSha256 = options.expectedSha256 !== undefined;
132
+ const hasBytes = options.expectedBytes !== undefined;
133
+ const expectedIdentity = options.expectedIdentity;
134
+ if (hasSha256 !== hasBytes
135
+ || (hasSha256 && !/^[a-f\d]{64}$/i.test(options.expectedSha256))
136
+ || (hasBytes && (!Number.isSafeInteger(options.expectedBytes) || options.expectedBytes < 0))
137
+ || (options.expectedMode !== undefined
138
+ && (!Number.isSafeInteger(options.expectedMode)
139
+ || options.expectedMode < 0
140
+ || options.expectedMode > 0o777))
141
+ || (expectedIdentity !== undefined
142
+ && (typeof expectedIdentity?.device !== 'bigint'
143
+ || typeof expectedIdentity?.inode !== 'bigint'
144
+ || expectedIdentity.inode === 0n))
145
+ || typeof options.includeContent !== 'boolean'
146
+ || !validExpectedLinks(options.expectedLinks)) {
147
+ return relation('unknown', 'invalid-regular-file-binding-expectation');
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function descriptorDigest(descriptor, expectedSize, subject, filePath, includeContent) {
153
+ if (expectedSize < 0n || expectedSize > BigInt(Number.MAX_SAFE_INTEGER)) {
154
+ return relation('unknown', `${subject}-size-unavailable`, { filePath });
155
+ }
156
+ const expectedBytes = Number(expectedSize);
157
+ const digest = createHash('sha256');
158
+ let capturedContent;
159
+ let chunk;
160
+ try {
161
+ capturedContent = includeContent ? Buffer.allocUnsafe(expectedBytes) : null;
162
+ chunk = capturedContent
163
+ || Buffer.allocUnsafe(Math.min(digestChunkBytes, Math.max(1, expectedBytes)));
164
+ } catch (error) {
165
+ return filesystemFailure(`${subject}-content-inspection-failed`, error, { filePath });
166
+ }
167
+ let bytes = 0;
168
+ try {
169
+ while (bytes < expectedBytes) {
170
+ const offset = includeContent ? bytes : 0;
171
+ const read = fs.readSync(
172
+ descriptor,
173
+ chunk,
174
+ offset,
175
+ Math.min(digestChunkBytes, chunk.byteLength - offset, expectedBytes - bytes),
176
+ bytes,
177
+ );
178
+ if (read === 0) break;
179
+ digest.update(chunk.subarray(offset, offset + read));
180
+ bytes += read;
181
+ }
182
+ } catch (error) {
183
+ return filesystemFailure(`${subject}-content-inspection-failed`, error, { filePath });
184
+ }
185
+ return {
186
+ status: 'inspected',
187
+ sha256: digest.digest('hex'),
188
+ bytes,
189
+ ...(includeContent ? { buffer: capturedContent.subarray(0, bytes) } : {}),
190
+ };
191
+ }
192
+
193
+ function sameHandleState(left, right) {
194
+ return left.dev === right.dev
195
+ && left.ino === right.ino
196
+ && left.nlink === right.nlink
197
+ && left.mode === right.mode
198
+ && left.size === right.size
199
+ && left.mtimeNs === right.mtimeNs
200
+ && left.ctimeNs === right.ctimeNs;
201
+ }
202
+
203
+ function inspectRegularFileDescriptor({
204
+ descriptor,
205
+ filePath,
206
+ identity,
207
+ subject,
208
+ expectedLinks,
209
+ phase,
210
+ includeContent = false,
211
+ }) {
212
+ let beforeHandle;
213
+ let beforePath;
214
+ let afterHandle;
215
+ let afterPath;
216
+ let content;
217
+ try {
218
+ beforeHandle = fs.fstatSync(descriptor, { bigint: true });
219
+ beforePath = fs.lstatSync(filePath, { bigint: true });
220
+ } catch (error) {
221
+ return filesystemFailure(`${subject}-handle-${phase}-failed`, error, { filePath });
222
+ }
223
+ if (!beforeHandle.isFile() || !beforePath.isFile()) {
224
+ return relation('unsupported', `${subject}-not-regular-file`, {
225
+ filePath,
226
+ entryType: entryType(beforePath),
227
+ });
228
+ }
229
+ if (beforeHandle.ino === 0n || beforePath.ino === 0n) {
230
+ return relation('unknown', `${subject}-identity-unavailable`, { filePath });
231
+ }
232
+ if (beforeHandle.dev !== identity.device
233
+ || beforeHandle.ino !== identity.inode
234
+ || beforePath.dev !== beforeHandle.dev
235
+ || beforePath.ino !== beforeHandle.ino) {
236
+ return relation(phase === 'inspection' ? 'unknown' : 'different',
237
+ `${subject}-${phase === 'inspection' ? 'changed-during-inspection' : 'identity-changed'}`,
238
+ { filePath });
239
+ }
240
+ if (beforeHandle.nlink === 0n) {
241
+ return relation('unknown', `${subject}-link-count-unavailable`, { filePath });
242
+ }
243
+ if (beforeHandle.nlink !== BigInt(expectedLinks)) {
244
+ if (beforeHandle.nlink > BigInt(expectedLinks)) {
245
+ return relation('unsupported', `${subject}-hardlinked`, {
246
+ filePath,
247
+ links: beforeHandle.nlink.toString(),
248
+ });
249
+ }
250
+ return relation('different', `${subject}-link-count-changed`, {
251
+ filePath,
252
+ expectedLinks,
253
+ currentLinks: beforeHandle.nlink.toString(),
254
+ });
255
+ }
256
+ content = descriptorDigest(
257
+ descriptor,
258
+ beforeHandle.size,
259
+ subject,
260
+ filePath,
261
+ includeContent,
262
+ );
263
+ if (content.status !== 'inspected') return content;
264
+ try {
265
+ afterHandle = fs.fstatSync(descriptor, { bigint: true });
266
+ afterPath = fs.lstatSync(filePath, { bigint: true });
267
+ } catch (error) {
268
+ return filesystemFailure(`${subject}-handle-${phase}-failed`, error, { filePath });
269
+ }
270
+ if (!afterHandle.isFile() || !afterPath.isFile()
271
+ || afterHandle.ino === 0n || afterPath.ino === 0n
272
+ || afterHandle.dev !== identity.device || afterHandle.ino !== identity.inode
273
+ || afterPath.dev !== afterHandle.dev || afterPath.ino !== afterHandle.ino) {
274
+ return relation(phase === 'inspection' ? 'unknown' : 'different',
275
+ `${subject}-${phase === 'inspection' ? 'changed-during-inspection' : 'identity-changed'}`,
276
+ { filePath });
277
+ }
278
+ if (!sameHandleState(beforeHandle, afterHandle)) {
279
+ return relation('unknown', `${subject}-changed-during-${phase}`, { filePath });
280
+ }
281
+ if (content.bytes !== Number(afterHandle.size)) {
282
+ return relation('unknown', `${subject}-changed-during-${phase}`, { filePath });
283
+ }
284
+ return { status: 'inspected', metadata: afterHandle, content };
285
+ }
286
+
287
+ function closeCapturedDescriptor(descriptor, subject, filePath) {
288
+ try {
289
+ fs.closeSync(descriptor);
290
+ return null;
291
+ } catch (error) {
292
+ return filesystemFailure(`${subject}-handle-close-failed`, error, { filePath });
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Capture a regular file through an open descriptor. The opaque binding keeps
298
+ * the descriptor alive so callers can prove that the same inode, mode and
299
+ * bytes are still named by either the original path or a deliberate hard-link
300
+ * publication path. Callers must release every successfully captured binding.
301
+ */
302
+ export function captureRegularFileBinding(filePath, {
303
+ subject = 'file',
304
+ expectedSha256,
305
+ expectedBytes,
306
+ expectedMode,
307
+ expectedIdentity,
308
+ expectedLinks = 1,
309
+ includeContent = false,
310
+ } = {}) {
311
+ const options = {
312
+ expectedSha256,
313
+ expectedBytes,
314
+ expectedMode,
315
+ expectedIdentity,
316
+ expectedLinks,
317
+ includeContent,
318
+ };
319
+ const invalid = validateBindingExpectations(options);
320
+ if (invalid) return invalid;
321
+ const resolvedPath = path.resolve(filePath);
322
+ let initial;
323
+ try {
324
+ initial = fs.lstatSync(resolvedPath, { bigint: true });
325
+ } catch (error) {
326
+ return filesystemFailure(`${subject}-inspection-failed`, error, { filePath: resolvedPath });
327
+ }
328
+ const type = entryType(initial);
329
+ if (type !== 'file') {
330
+ return relation('unsupported', `${subject}-not-regular-file`, {
331
+ filePath: resolvedPath,
332
+ entryType: type,
333
+ });
334
+ }
335
+ if (initial.ino === 0n) {
336
+ return relation('unknown', `${subject}-identity-unavailable`, { filePath: resolvedPath });
337
+ }
338
+
339
+ let descriptor;
340
+ try {
341
+ const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
342
+ const nonBlock = fs.constants.O_NONBLOCK || 0;
343
+ descriptor = fs.openSync(resolvedPath, fs.constants.O_RDONLY | noFollow | nonBlock);
344
+ } catch (error) {
345
+ return filesystemFailure(`${subject}-handle-inspection-failed`, error, { filePath: resolvedPath });
346
+ }
347
+ const identity = { device: initial.dev, inode: initial.ino };
348
+ if (expectedIdentity !== undefined
349
+ && (identity.device !== expectedIdentity.device
350
+ || identity.inode !== expectedIdentity.inode)) {
351
+ const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
352
+ return closeFailure || relation('different', `${subject}-identity-changed`, {
353
+ filePath: resolvedPath,
354
+ });
355
+ }
356
+ const inspected = inspectRegularFileDescriptor({
357
+ descriptor,
358
+ filePath: resolvedPath,
359
+ identity,
360
+ subject,
361
+ expectedLinks,
362
+ phase: 'inspection',
363
+ includeContent,
364
+ });
365
+ if (inspected.status !== 'inspected') {
366
+ const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
367
+ return closeFailure || inspected;
368
+ }
369
+ const mode = Number(inspected.metadata.mode & 0o777n);
370
+ if (expectedMode !== undefined && mode !== expectedMode) {
371
+ const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
372
+ return closeFailure || relation('different', `${subject}-mode-changed`, {
373
+ filePath: resolvedPath,
374
+ expectedMode,
375
+ currentMode: mode,
376
+ });
377
+ }
378
+ if (expectedSha256 !== undefined
379
+ && (inspected.content.sha256.toLowerCase() !== expectedSha256.toLowerCase()
380
+ || inspected.content.bytes !== expectedBytes)) {
381
+ const closeFailure = closeCapturedDescriptor(descriptor, subject, resolvedPath);
382
+ return closeFailure || relation('different', `${subject}-content-changed`, {
383
+ filePath: resolvedPath,
384
+ expectedSha256,
385
+ currentSha256: inspected.content.sha256,
386
+ expectedBytes,
387
+ currentBytes: inspected.content.bytes,
388
+ });
389
+ }
390
+
391
+ const identityKey = `${identity.device}:${identity.inode}`;
392
+ let cleanupGroup = regularFileBindingGroups.get(identityKey);
393
+ if (!cleanupGroup) {
394
+ cleanupGroup = { identityKey, bindings: 0, deferredCleanupDirectories: new Set() };
395
+ regularFileBindingGroups.set(identityKey, cleanupGroup);
396
+ }
397
+ cleanupGroup.bindings += 1;
398
+ const binding = Object.freeze({});
399
+ regularFileBindings.set(binding, {
400
+ descriptor,
401
+ filePath: resolvedPath,
402
+ subject,
403
+ expectedLinks,
404
+ cleanupGroup,
405
+ identity,
406
+ mode,
407
+ sha256: inspected.content.sha256,
408
+ bytes: inspected.content.bytes,
409
+ });
410
+ return {
411
+ status: 'captured',
412
+ binding,
413
+ identity: { ...identity, mode, links: inspected.metadata.nlink },
414
+ content: {
415
+ sha256: inspected.content.sha256,
416
+ bytes: inspected.content.bytes,
417
+ ...(includeContent ? { buffer: inspected.content.buffer } : {}),
418
+ },
419
+ mode,
420
+ };
421
+ }
422
+
423
+ /** Verify a captured descriptor against its original or an alternate linked path. */
424
+ export function verifyRegularFileBinding(binding, { filePath, expectedLinks } = {}) {
425
+ const captured = regularFileBindings.get(binding);
426
+ if (!captured) return relation('unknown', 'regular-file-binding-unavailable');
427
+ const links = expectedLinks ?? captured.expectedLinks;
428
+ if (!validExpectedLinks(links)) {
429
+ return relation('unknown', 'invalid-regular-file-binding-expectation');
430
+ }
431
+ const verificationPath = path.resolve(filePath ?? captured.filePath);
432
+ const inspected = inspectRegularFileDescriptor({
433
+ descriptor: captured.descriptor,
434
+ filePath: verificationPath,
435
+ identity: captured.identity,
436
+ subject: captured.subject,
437
+ expectedLinks: links,
438
+ phase: 'verification',
439
+ });
440
+ if (inspected.status !== 'inspected') return inspected;
441
+ const mode = Number(inspected.metadata.mode & 0o777n);
442
+ if (mode !== captured.mode) {
443
+ return relation('different', `${captured.subject}-mode-changed`, {
444
+ filePath: verificationPath,
445
+ previousMode: captured.mode,
446
+ currentMode: mode,
447
+ });
448
+ }
449
+ if (inspected.content.sha256 !== captured.sha256
450
+ || inspected.content.bytes !== captured.bytes) {
451
+ return relation('different', `${captured.subject}-content-changed`, {
452
+ filePath: verificationPath,
453
+ previousSha256: captured.sha256,
454
+ currentSha256: inspected.content.sha256,
455
+ previousBytes: captured.bytes,
456
+ currentBytes: inspected.content.bytes,
457
+ });
458
+ }
459
+ return relation('match', `${captured.subject}-binding-match`, {
460
+ filePath: verificationPath,
461
+ links,
462
+ });
463
+ }
464
+
465
+ /** Close and invalidate a captured regular-file binding. */
466
+ export function releaseRegularFileBinding(binding) {
467
+ const captured = regularFileBindings.get(binding);
468
+ if (!captured) return relation('unknown', 'regular-file-binding-unavailable');
469
+ regularFileBindings.delete(binding);
470
+ const failure = closeCapturedDescriptor(
471
+ captured.descriptor,
472
+ captured.subject,
473
+ captured.filePath,
474
+ );
475
+ const { cleanupGroup } = captured;
476
+ cleanupGroup.bindings -= 1;
477
+ let cleanupFailure;
478
+ if (cleanupGroup.bindings === 0) {
479
+ regularFileBindingGroups.delete(cleanupGroup.identityKey);
480
+ // A staging registry and its publication transaction can bind the same
481
+ // inode independently. SMB may keep a retired name visible until every
482
+ // descriptor closes, so cleanup belongs to the inode's last binding.
483
+ for (const directory of cleanupGroup.deferredCleanupDirectories) {
484
+ const cleanup = removeEmptyQuarantine(directory, { retry: true });
485
+ cleanupFailure ||= cleanup;
486
+ }
487
+ }
488
+ if (failure || cleanupFailure) return failure || cleanupFailure;
489
+ return relation('released', `${captured.subject}-binding-released`, {
490
+ filePath: captured.filePath,
491
+ });
492
+ }
493
+
494
+ function createRemovalQuarantine(parentPath) {
495
+ for (let attempt = 0; attempt < 8; attempt += 1) {
496
+ const directory = path.join(
497
+ parentPath,
498
+ `.archify-remove-${randomBytes(16).toString('hex')}`,
499
+ );
500
+ try {
501
+ fs.mkdirSync(directory, { mode: 0o700 });
502
+ return { status: 'created', directory };
503
+ } catch (error) {
504
+ if (error?.code !== 'EEXIST') {
505
+ return filesystemFailure('removal-quarantine-create-failed', error, {
506
+ parentPath,
507
+ });
508
+ }
509
+ }
510
+ }
511
+ return relation('unknown', 'removal-quarantine-name-exhausted', { parentPath });
512
+ }
513
+
514
+ function removeEmptyQuarantine(directory, { retry = false } = {}) {
515
+ let failure;
516
+ try {
517
+ removeEmptyDirectoryWithRetry(directory, { retry });
518
+ return null;
519
+ } catch (error) {
520
+ failure = error;
521
+ }
522
+ return filesystemFailure('removal-quarantine-cleanup-failed', failure, {
523
+ recoveryDirectory: directory,
524
+ });
525
+ }
526
+
527
+ function deferEmptyQuarantineCleanup(binding, cleanup) {
528
+ if (cleanup?.reason?.code !== 'removal-quarantine-cleanup-failed'
529
+ || cleanup.reason.systemCode !== 'ENOTEMPTY') return false;
530
+ const captured = regularFileBindings.get(binding);
531
+ if (!captured) return false;
532
+ captured.cleanupGroup.deferredCleanupDirectories.add(cleanup.reason.recoveryDirectory);
533
+ return true;
534
+ }
535
+
536
+ function removalRecovery(subject, code, filePath, quarantineDirectory, quarantineFile, error) {
537
+ return {
538
+ status: 'recovery-required',
539
+ reason: {
540
+ code: `${subject}-${code}`,
541
+ filePath,
542
+ ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
543
+ ...(error?.message ? { message: error.message } : {}),
544
+ },
545
+ recoveryDirectory: quarantineDirectory,
546
+ recoveryFile: quarantineFile,
547
+ target: filePath,
548
+ };
549
+ }
550
+
551
+ function decimal(value) {
552
+ return value.toString();
553
+ }
554
+
555
+ function parseDecimal(value) {
556
+ if (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value)) return null;
557
+ try {
558
+ return BigInt(value);
559
+ } catch {
560
+ return null;
561
+ }
562
+ }
563
+
564
+ function parseSafeInteger(value) {
565
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
566
+ }
567
+
568
+ function serializeRequestedEntry(entry) {
569
+ if (entry?.kind === 'absent') return { kind: 'absent' };
570
+ if (entry?.kind !== 'existing' || typeof entry.entryType !== 'string'
571
+ || typeof entry.device !== 'bigint' || typeof entry.inode !== 'bigint') {
572
+ return null;
573
+ }
574
+ return {
575
+ kind: 'existing',
576
+ entryType: entry.entryType,
577
+ device: decimal(entry.device),
578
+ inode: decimal(entry.inode),
579
+ ...(entry.entryType === 'symbolic-link' ? {
580
+ changedAtNs: decimal(entry.changedAtNs),
581
+ createdAtNs: decimal(entry.createdAtNs),
582
+ } : {}),
583
+ };
584
+ }
585
+
586
+ function createPublicationRecoveryRecord(directory, snapshot, previous) {
587
+ const requestedEntry = serializeRequestedEntry(snapshot?.requestedEntry);
588
+ const slot = snapshot?.slot;
589
+ if (!requestedEntry || !slot || typeof snapshot?.requestedPath !== 'string'
590
+ || typeof snapshot?.requestedEntryPolicy !== 'string'
591
+ || typeof slot.commitPath !== 'string' || typeof slot.parentPath !== 'string'
592
+ || typeof slot.name !== 'string' || typeof slot.parentDevice !== 'bigint'
593
+ || typeof slot.parentInode !== 'bigint' || !previous?.identity
594
+ || !previous?.content || !Number.isSafeInteger(previous.mode)) {
595
+ return relation('unknown', 'publication-recovery-record-input-invalid');
596
+ }
597
+ const recordPath = path.join(directory, publicationRecoveryRecordName);
598
+ let directoryMetadata;
599
+ let parentMetadata;
600
+ try {
601
+ directoryMetadata = fs.lstatSync(directory, { bigint: true });
602
+ parentMetadata = fs.statSync(path.dirname(directory), { bigint: true });
603
+ } catch (error) {
604
+ return filesystemFailure('publication-recovery-directory-inspection-failed', error, {
605
+ recoveryDirectory: directory,
606
+ });
607
+ }
608
+ if (!directoryMetadata.isDirectory() || directoryMetadata.ino === 0n
609
+ || !parentMetadata.isDirectory()
610
+ || parentMetadata.dev !== slot.parentDevice || parentMetadata.ino !== slot.parentInode
611
+ || !/^\.archify-remove-[a-f\d]{32}$/.test(path.basename(directory))) {
612
+ return relation('unknown', 'publication-recovery-directory-invalid', {
613
+ recoveryDirectory: directory,
614
+ });
615
+ }
616
+ const record = {
617
+ version: publicationRecoveryRecordVersion,
618
+ kind: 'archify-retired-output-recovery',
619
+ backup: {
620
+ name: 'previous',
621
+ sha256: previous.content.sha256,
622
+ bytes: previous.content.bytes,
623
+ mode: previous.mode,
624
+ device: decimal(previous.identity.device),
625
+ inode: decimal(previous.identity.inode),
626
+ },
627
+ recovery: {
628
+ name: path.basename(directory),
629
+ device: decimal(directoryMetadata.dev),
630
+ inode: decimal(directoryMetadata.ino),
631
+ },
632
+ target: {
633
+ requestedPath: snapshot.requestedPath,
634
+ requestedEntryPolicy: snapshot.requestedEntryPolicy,
635
+ requestedEntry,
636
+ commitPath: slot.commitPath,
637
+ parentPath: slot.parentPath,
638
+ parentDevice: decimal(slot.parentDevice),
639
+ parentInode: decimal(slot.parentInode),
640
+ name: slot.name,
641
+ },
642
+ };
643
+ const bytes = Buffer.from(`${JSON.stringify(record)}\n`);
644
+ let descriptor;
645
+ let writeFailure;
646
+ try {
647
+ descriptor = fs.openSync(
648
+ recordPath,
649
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
650
+ 0o600,
651
+ );
652
+ let offset = 0;
653
+ while (offset < bytes.byteLength) {
654
+ const wrote = fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
655
+ if (wrote <= 0) throw Object.assign(new Error('short recovery record write'), { code: 'EIO' });
656
+ offset += wrote;
657
+ }
658
+ fs.fsyncSync(descriptor);
659
+ } catch (error) {
660
+ writeFailure = error;
661
+ } finally {
662
+ if (descriptor !== undefined) {
663
+ try {
664
+ fs.closeSync(descriptor);
665
+ } catch (error) {
666
+ writeFailure ||= error;
667
+ }
668
+ }
669
+ }
670
+ if (writeFailure) {
671
+ return filesystemFailure('publication-recovery-record-write-failed', writeFailure, {
672
+ recoveryDirectory: directory,
673
+ recoveryFile: recordPath,
674
+ });
675
+ }
676
+ // POSIX requires the parent directory entry to be synced separately. Do not
677
+ // claim a kill-recovery record is durable where the platform cannot do that.
678
+ if (process.platform !== 'win32') {
679
+ let directoryDescriptor;
680
+ let directoryFailure;
681
+ try {
682
+ directoryDescriptor = fs.openSync(directory, fs.constants.O_RDONLY);
683
+ fs.fsyncSync(directoryDescriptor);
684
+ } catch (error) {
685
+ directoryFailure = error;
686
+ } finally {
687
+ if (directoryDescriptor !== undefined) {
688
+ try {
689
+ fs.closeSync(directoryDescriptor);
690
+ } catch (error) {
691
+ directoryFailure ||= error;
692
+ }
693
+ }
694
+ }
695
+ if (directoryFailure) {
696
+ return filesystemFailure('publication-recovery-record-directory-sync-failed', directoryFailure, {
697
+ recoveryDirectory: directory,
698
+ recoveryFile: recordPath,
699
+ });
700
+ }
701
+ }
702
+ const captured = captureRegularFileBinding(recordPath, {
703
+ subject: 'publication-recovery-record',
704
+ expectedSha256: createHash('sha256').update(bytes).digest('hex'),
705
+ expectedBytes: bytes.byteLength,
706
+ // Windows reports a synthetic POSIX mode whose read bits cannot represent
707
+ // the private ACL established by the exclusive create above.
708
+ expectedMode: process.platform === 'win32' ? undefined : 0o600,
709
+ expectedLinks: 1,
710
+ });
711
+ if (captured.status !== 'captured') {
712
+ return {
713
+ ...captured,
714
+ recoveryDirectory: directory,
715
+ recoveryFile: recordPath,
716
+ };
717
+ }
718
+ return {
719
+ status: 'created',
720
+ recordPath,
721
+ binding: captured.binding,
722
+ };
723
+ }
724
+
725
+ function discardPublicationRecoveryRecord(record) {
726
+ if (!record?.binding || typeof record.recordPath !== 'string') return null;
727
+ let removed;
728
+ let released;
729
+ try {
730
+ removed = quarantineRemoveRegularFileBinding(record.binding, record.recordPath, {
731
+ subject: 'publication-recovery-record',
732
+ expectedLinks: 1,
733
+ });
734
+ } finally {
735
+ released = releaseRegularFileBinding(record.binding);
736
+ }
737
+ if (released.status !== 'released') return released;
738
+ return removed.status === 'removed' ? null : removed;
739
+ }
740
+
741
+ function releasePublicationRecoveryRecord(record) {
742
+ if (!record?.binding) return null;
743
+ const released = releaseRegularFileBinding(record.binding);
744
+ return released.status === 'released' ? null : released;
745
+ }
746
+
747
+ /**
748
+ * Remove a public regular-file name without a verify-to-unlink race. The
749
+ * public entry is first moved into an unpredictable private directory and is
750
+ * deleted only after the caller's still-open binding proves that the moved
751
+ * inode, mode and bytes are the owned entry. If the move captured a successor,
752
+ * restore it with a no-clobber hard link or retain it as recovery material.
753
+ * The caller retains ownership of `binding` and must release it separately.
754
+ */
755
+ export function quarantineRemoveRegularFileBinding(binding, filePath, {
756
+ subject = 'public-entry',
757
+ expectedLinks = 1,
758
+ } = {}) {
759
+ if (!validExpectedLinks(expectedLinks)) {
760
+ return relation('unknown', 'invalid-regular-file-binding-expectation');
761
+ }
762
+ const resolvedPath = path.resolve(filePath);
763
+ const beforeMove = verifyRegularFileBinding(binding, {
764
+ filePath: resolvedPath,
765
+ expectedLinks,
766
+ });
767
+ if (beforeMove.status !== 'match') return beforeMove;
768
+
769
+ const quarantine = createRemovalQuarantine(path.dirname(resolvedPath));
770
+ if (quarantine.status !== 'created') return quarantine;
771
+ const quarantineFile = path.join(quarantine.directory, path.basename(resolvedPath));
772
+ const removeEmpty = () => removeEmptyQuarantine(quarantine.directory);
773
+ const removeEmptyAfterFileRemoval = () => {
774
+ const cleanup = removeEmpty();
775
+ return cleanup && deferEmptyQuarantineCleanup(binding, cleanup) ? null : cleanup;
776
+ };
777
+
778
+ try {
779
+ fs.renameSync(resolvedPath, quarantineFile);
780
+ } catch (error) {
781
+ const cleanup = removeEmpty();
782
+ return cleanup || filesystemFailure(`${subject}-quarantine-move-failed`, error, {
783
+ filePath: resolvedPath,
784
+ });
785
+ }
786
+
787
+ const moved = verifyRegularFileBinding(binding, {
788
+ filePath: quarantineFile,
789
+ expectedLinks,
790
+ });
791
+ if (moved.status === 'match') {
792
+ try {
793
+ fs.unlinkSync(quarantineFile);
794
+ } catch (error) {
795
+ return removalRecovery(
796
+ subject,
797
+ 'quarantine-cleanup-failed',
798
+ resolvedPath,
799
+ quarantine.directory,
800
+ quarantineFile,
801
+ error,
802
+ );
803
+ }
804
+ const cleanup = removeEmptyAfterFileRemoval();
805
+ return cleanup || relation('removed', `${subject}-removed`, { filePath: resolvedPath });
806
+ }
807
+
808
+ let displaced;
809
+ try {
810
+ displaced = fs.lstatSync(quarantineFile, { bigint: true });
811
+ } catch (error) {
812
+ return removalRecovery(
813
+ subject,
814
+ 'replacement-inspection-failed',
815
+ resolvedPath,
816
+ quarantine.directory,
817
+ quarantineFile,
818
+ error,
819
+ );
820
+ }
821
+ if (displaced.ino === 0n || displaced.nlink === 0n) {
822
+ return removalRecovery(
823
+ subject,
824
+ 'replacement-identity-unavailable',
825
+ resolvedPath,
826
+ quarantine.directory,
827
+ quarantineFile,
828
+ );
829
+ }
830
+ try {
831
+ fs.linkSync(quarantineFile, resolvedPath);
832
+ } catch (error) {
833
+ return removalRecovery(
834
+ subject,
835
+ error?.code === 'EEXIST' ? 'replacement-restore-blocked' : 'replacement-restore-failed',
836
+ resolvedPath,
837
+ quarantine.directory,
838
+ quarantineFile,
839
+ error,
840
+ );
841
+ }
842
+
843
+ let restored;
844
+ let retained;
845
+ try {
846
+ restored = fs.lstatSync(resolvedPath, { bigint: true });
847
+ retained = fs.lstatSync(quarantineFile, { bigint: true });
848
+ } catch (error) {
849
+ return removalRecovery(
850
+ subject,
851
+ 'replacement-restore-verification-failed',
852
+ resolvedPath,
853
+ quarantine.directory,
854
+ quarantineFile,
855
+ error,
856
+ );
857
+ }
858
+ if (restored.dev !== displaced.dev || restored.ino !== displaced.ino
859
+ || retained.dev !== displaced.dev || retained.ino !== displaced.ino
860
+ || restored.nlink !== displaced.nlink + 1n
861
+ || retained.nlink !== displaced.nlink + 1n) {
862
+ return removalRecovery(
863
+ subject,
864
+ 'replacement-restore-identity-changed',
865
+ resolvedPath,
866
+ quarantine.directory,
867
+ quarantineFile,
868
+ );
869
+ }
870
+ try {
871
+ fs.unlinkSync(quarantineFile);
872
+ } catch (error) {
873
+ return removalRecovery(
874
+ subject,
875
+ 'replacement-quarantine-cleanup-failed',
876
+ resolvedPath,
877
+ quarantine.directory,
878
+ quarantineFile,
879
+ error,
880
+ );
881
+ }
882
+ try {
883
+ restored = fs.lstatSync(resolvedPath, { bigint: true });
884
+ } catch (error) {
885
+ const cleanup = removeEmptyAfterFileRemoval();
886
+ return cleanup || filesystemFailure(
887
+ `${subject}-replacement-final-verification-failed`,
888
+ error,
889
+ { filePath: resolvedPath },
890
+ );
891
+ }
892
+ if (restored.dev !== displaced.dev || restored.ino !== displaced.ino
893
+ || restored.nlink !== displaced.nlink) {
894
+ const cleanup = removeEmptyAfterFileRemoval();
895
+ return cleanup || relation('different', `${subject}-replacement-final-identity-changed`, {
896
+ filePath: resolvedPath,
897
+ });
898
+ }
899
+ const cleanup = removeEmptyAfterFileRemoval();
900
+ return cleanup || relation('preserved', `${subject}-replacement-restored`, {
901
+ filePath: resolvedPath,
902
+ priorState: moved.reason,
903
+ });
904
+ }
905
+
906
+ /**
907
+ * Create a no-clobber hard-link backup of a bound public file, then remove the
908
+ * public name through the quarantine protocol above. The backup is reported as
909
+ * verified only after both names have been reconciled with the open binding.
910
+ */
911
+ export function backupPublicRegularFileBinding(binding, filePath, backupPath, {
912
+ subject = 'public-entry',
913
+ } = {}) {
914
+ const resolvedPath = path.resolve(filePath);
915
+ const resolvedBackup = path.resolve(backupPath);
916
+ const beforeLink = verifyRegularFileBinding(binding, {
917
+ filePath: resolvedPath,
918
+ expectedLinks: 1,
919
+ });
920
+ if (beforeLink.status !== 'match') {
921
+ return { ...beforeLink, backupCreated: false, backupVerified: false };
922
+ }
923
+ try {
924
+ fs.linkSync(resolvedPath, resolvedBackup);
925
+ } catch (error) {
926
+ return {
927
+ ...filesystemFailure(`${subject}-backup-link-failed`, error, {
928
+ filePath: resolvedPath,
929
+ backupPath: resolvedBackup,
930
+ }),
931
+ backupCreated: false,
932
+ backupVerified: false,
933
+ };
934
+ }
935
+ for (const linkedPath of [resolvedPath, resolvedBackup]) {
936
+ const linked = verifyRegularFileBinding(binding, {
937
+ filePath: linkedPath,
938
+ expectedLinks: 2,
939
+ });
940
+ if (linked.status !== 'match') {
941
+ return {
942
+ status: 'recovery-required',
943
+ reason: {
944
+ code: `${subject}-backup-binding-mismatch`,
945
+ filePath: resolvedPath,
946
+ backupPath: resolvedBackup,
947
+ linkedState: linked.reason,
948
+ },
949
+ recoveryDirectory: path.dirname(resolvedBackup),
950
+ recoveryFile: resolvedBackup,
951
+ target: resolvedPath,
952
+ backupCreated: true,
953
+ backupVerified: false,
954
+ };
955
+ }
956
+ }
957
+ const removed = quarantineRemoveRegularFileBinding(binding, resolvedPath, {
958
+ subject,
959
+ expectedLinks: 2,
960
+ });
961
+ if (removed.status !== 'removed') {
962
+ return {
963
+ ...removed,
964
+ backupPath: resolvedBackup,
965
+ backupCreated: true,
966
+ backupVerified: true,
967
+ };
968
+ }
969
+ const finalized = verifyRegularFileBinding(binding, {
970
+ filePath: resolvedBackup,
971
+ expectedLinks: 1,
972
+ });
973
+ if (finalized.status !== 'match') {
974
+ return {
975
+ status: 'recovery-required',
976
+ reason: {
977
+ code: `${subject}-backup-finalization-failed`,
978
+ filePath: resolvedPath,
979
+ backupPath: resolvedBackup,
980
+ backupState: finalized.reason,
981
+ },
982
+ recoveryDirectory: path.dirname(resolvedBackup),
983
+ recoveryFile: resolvedBackup,
984
+ target: resolvedPath,
985
+ backupCreated: true,
986
+ backupVerified: false,
987
+ };
988
+ }
989
+ return {
990
+ status: 'backed-up',
991
+ reason: {
992
+ code: `${subject}-backed-up`,
993
+ filePath: resolvedPath,
994
+ backupPath: resolvedBackup,
995
+ },
996
+ backupPath: resolvedBackup,
997
+ backupCreated: true,
998
+ backupVerified: true,
999
+ };
1000
+ }
1001
+
1002
+ /**
1003
+ * Remove a hard-link name that this process just introduced while preserving
1004
+ * the source claimant. This is the rollback path when a staged pathname was
1005
+ * swapped after its expected binding was verified but before `linkSync` used
1006
+ * that pathname.
1007
+ */
1008
+ export function quarantineRemoveLinkedRegularFileAlias(sourcePath, aliasPath, {
1009
+ subject = 'linked-claimant',
1010
+ } = {}) {
1011
+ const source = path.resolve(sourcePath);
1012
+ const alias = path.resolve(aliasPath);
1013
+ const captured = captureRegularFileBinding(source, {
1014
+ subject,
1015
+ expectedLinks: 2,
1016
+ });
1017
+ if (captured.status !== 'captured') return captured;
1018
+ let result;
1019
+ let released;
1020
+ try {
1021
+ const aliasMatches = verifyRegularFileBinding(captured.binding, {
1022
+ filePath: alias,
1023
+ expectedLinks: 2,
1024
+ });
1025
+ if (aliasMatches.status !== 'match') {
1026
+ result = aliasMatches;
1027
+ } else {
1028
+ const removal = quarantineRemoveRegularFileBinding(captured.binding, alias, {
1029
+ subject,
1030
+ expectedLinks: 2,
1031
+ });
1032
+ if (removal.status !== 'removed') {
1033
+ result = removal;
1034
+ } else {
1035
+ const sourceRemains = verifyRegularFileBinding(captured.binding, {
1036
+ filePath: source,
1037
+ expectedLinks: 1,
1038
+ });
1039
+ result = sourceRemains.status === 'match' ? removal : sourceRemains;
1040
+ }
1041
+ }
1042
+ } finally {
1043
+ released = releaseRegularFileBinding(captured.binding);
1044
+ }
1045
+ if (released.status !== 'released') {
1046
+ return {
1047
+ ...released,
1048
+ removalStatus: result?.status,
1049
+ ...(result?.recoveryDirectory ? {
1050
+ recoveryDirectory: result.recoveryDirectory,
1051
+ recoveryFile: result.recoveryFile,
1052
+ target: result.target,
1053
+ } : {}),
1054
+ };
1055
+ }
1056
+ return result;
1057
+ }
1058
+
1059
+ function removePublishedBindingName(binding, filePath, subject) {
1060
+ for (const expectedLinks of [2, 1]) {
1061
+ const matches = verifyRegularFileBinding(binding, { filePath, expectedLinks });
1062
+ if (matches.status === 'match') {
1063
+ return quarantineRemoveRegularFileBinding(binding, filePath, {
1064
+ subject,
1065
+ expectedLinks,
1066
+ });
1067
+ }
1068
+ }
1069
+ return relation('preserved', `${subject}-identity-changed`, { filePath });
1070
+ }
1071
+
1072
+ function restorePublicationBackup(binding, backupPath, commitPath, subject) {
1073
+ const backupMatches = verifyRegularFileBinding(binding, {
1074
+ filePath: backupPath,
1075
+ expectedLinks: 1,
1076
+ });
1077
+ if (backupMatches.status !== 'match') return backupMatches;
1078
+ try {
1079
+ fs.linkSync(backupPath, commitPath);
1080
+ } catch (error) {
1081
+ return filesystemFailure(
1082
+ error?.code === 'EEXIST'
1083
+ ? `${subject}-restore-blocked`
1084
+ : `${subject}-restore-failed`,
1085
+ error,
1086
+ { backupPath, commitPath },
1087
+ );
1088
+ }
1089
+ for (const linkedPath of [backupPath, commitPath]) {
1090
+ const linked = verifyRegularFileBinding(binding, {
1091
+ filePath: linkedPath,
1092
+ expectedLinks: 2,
1093
+ });
1094
+ if (linked.status !== 'match') return linked;
1095
+ }
1096
+ const retired = quarantineRemoveRegularFileBinding(binding, backupPath, {
1097
+ subject: `${subject}-backup`,
1098
+ expectedLinks: 2,
1099
+ });
1100
+ if (retired.status !== 'removed') return retired;
1101
+ return verifyRegularFileBinding(binding, {
1102
+ filePath: commitPath,
1103
+ expectedLinks: 1,
1104
+ });
1105
+ }
1106
+
1107
+ /**
1108
+ * Publish a captured candidate without ever replacing an unverified public
1109
+ * entry. Existing output is retained through a bound private backup; the new
1110
+ * name is created with a no-clobber hard link while the candidate descriptor
1111
+ * remains open. Both the staged and public names are then verified through
1112
+ * that descriptor before the staged name is retired via quarantine.
1113
+ * Replacing an existing entry is recoverable but not crash-atomic: the public
1114
+ * name is absent between retiring the verified previous name and linking the
1115
+ * candidate. Portable Node.js exposes no replacement compare-and-swap that
1116
+ * also preserves a late claimant.
1117
+ *
1118
+ * The caller owns `binding` and must release it after this function returns.
1119
+ */
1120
+ export function publishRegularFileBinding(binding, candidatePath, snapshot, {
1121
+ subject = 'candidate',
1122
+ } = {}) {
1123
+ const candidate = path.resolve(candidatePath);
1124
+ const commitPath = snapshot?.slot?.commitPath;
1125
+ if (typeof commitPath !== 'string') {
1126
+ return relation('unknown', `${subject}-publication-snapshot-unavailable`);
1127
+ }
1128
+
1129
+ const beforePublish = verifyAtomicOutput(snapshot);
1130
+ if (beforePublish.status !== 'match') return beforePublish;
1131
+ const candidateMatches = verifyRegularFileBinding(binding, {
1132
+ filePath: candidate,
1133
+ expectedLinks: 1,
1134
+ });
1135
+ if (candidateMatches.status !== 'match') return candidateMatches;
1136
+
1137
+ let previousBinding;
1138
+ let recoveryDirectory;
1139
+ let backupPath;
1140
+ let recoveryRecord;
1141
+ let previousBackedUp = false;
1142
+ const finishPrevious = () => {
1143
+ if (!previousBinding) return null;
1144
+ const released = releaseRegularFileBinding(previousBinding);
1145
+ previousBinding = undefined;
1146
+ return released.status === 'released' ? null : released;
1147
+ };
1148
+ const cleanupRecoveryDirectory = () => {
1149
+ if (!recoveryDirectory) return null;
1150
+ const cleanup = removeEmptyQuarantine(recoveryDirectory);
1151
+ if (!cleanup) recoveryDirectory = undefined;
1152
+ return cleanup;
1153
+ };
1154
+ const restorePrevious = () => {
1155
+ if (!previousBinding || !previousBackedUp) return null;
1156
+ return restorePublicationBackup(
1157
+ previousBinding,
1158
+ backupPath,
1159
+ commitPath,
1160
+ 'previous-output',
1161
+ );
1162
+ };
1163
+ const rollback = (failure) => {
1164
+ let publicRemoval = removePublishedBindingName(
1165
+ binding,
1166
+ commitPath,
1167
+ `${subject}-publication`,
1168
+ );
1169
+ if (publicRemoval.status === 'preserved') {
1170
+ const claimantRemoval = quarantineRemoveLinkedRegularFileAlias(
1171
+ candidate,
1172
+ commitPath,
1173
+ { subject: `${subject}-publication-claimant` },
1174
+ );
1175
+ if (claimantRemoval.status === 'removed') publicRemoval = claimantRemoval;
1176
+ }
1177
+ const restored = restorePrevious();
1178
+ const recordCleanup = restored?.status === 'match'
1179
+ ? discardPublicationRecoveryRecord(recoveryRecord)
1180
+ : releasePublicationRecoveryRecord(recoveryRecord);
1181
+ recoveryRecord = undefined;
1182
+ const released = finishPrevious();
1183
+ const directoryCleanup = cleanupRecoveryDirectory();
1184
+ return {
1185
+ ...failure,
1186
+ ...(publicRemoval.status !== 'removed' && publicRemoval.status !== 'absent'
1187
+ ? { publicState: publicRemoval.reason } : {}),
1188
+ ...(restored && restored.status !== 'match' ? { restoreState: restored.reason } : {}),
1189
+ ...(recordCleanup ? { recoveryRecordState: recordCleanup.reason } : {}),
1190
+ ...(released ? { releaseState: released.reason } : {}),
1191
+ ...(directoryCleanup ? {
1192
+ recoveryDirectory: directoryCleanup.reason?.recoveryDirectory || recoveryDirectory,
1193
+ } : {}),
1194
+ ...((restored && restored.status !== 'match') || recordCleanup || directoryCleanup
1195
+ ? { recoveryFile: backupPath } : {}),
1196
+ };
1197
+ };
1198
+
1199
+ if (snapshot.target.kind === 'file') {
1200
+ const previous = captureRegularFileBinding(commitPath, {
1201
+ subject: 'previous-output',
1202
+ expectedIdentity: {
1203
+ device: snapshot.target.device,
1204
+ inode: snapshot.target.inode,
1205
+ },
1206
+ expectedMode: snapshot.target.mode,
1207
+ expectedLinks: 1,
1208
+ });
1209
+ if (previous.status !== 'captured') return previous;
1210
+ previousBinding = previous.binding;
1211
+ const quarantine = createRemovalQuarantine(path.dirname(commitPath));
1212
+ if (quarantine.status !== 'created') {
1213
+ const released = finishPrevious();
1214
+ return released || quarantine;
1215
+ }
1216
+ recoveryDirectory = quarantine.directory;
1217
+ backupPath = path.join(recoveryDirectory, 'previous');
1218
+ const recovery = createPublicationRecoveryRecord(recoveryDirectory, snapshot, previous);
1219
+ if (recovery.status !== 'created') {
1220
+ const released = finishPrevious();
1221
+ const directoryCleanup = cleanupRecoveryDirectory();
1222
+ return {
1223
+ ...recovery,
1224
+ ...(recoveryDirectory ? { recoveryDirectory } : {}),
1225
+ ...(released ? { releaseState: released.reason } : {}),
1226
+ ...(directoryCleanup ? { directoryCleanupState: directoryCleanup.reason } : {}),
1227
+ };
1228
+ }
1229
+ recoveryRecord = recovery;
1230
+ const backup = backupPublicRegularFileBinding(
1231
+ previousBinding,
1232
+ commitPath,
1233
+ backupPath,
1234
+ { subject: 'previous-output' },
1235
+ );
1236
+ previousBackedUp = backup.status === 'backed-up';
1237
+ if (!previousBackedUp) {
1238
+ const recordCleanup = backup.backupCreated
1239
+ ? releasePublicationRecoveryRecord(recoveryRecord)
1240
+ : discardPublicationRecoveryRecord(recoveryRecord);
1241
+ recoveryRecord = undefined;
1242
+ const released = finishPrevious();
1243
+ const directoryCleanup = backup.backupCreated ? null : cleanupRecoveryDirectory();
1244
+ return {
1245
+ ...backup,
1246
+ ...(recordCleanup ? { recoveryRecordState: recordCleanup.reason } : {}),
1247
+ ...(released ? { releaseState: released.reason } : {}),
1248
+ ...(directoryCleanup ? { directoryCleanupState: directoryCleanup.reason } : {}),
1249
+ };
1250
+ }
1251
+ }
1252
+
1253
+ try {
1254
+ fs.linkSync(candidate, commitPath);
1255
+ } catch (error) {
1256
+ return rollback(filesystemFailure(
1257
+ error?.code === 'EEXIST'
1258
+ ? 'target-created-during-commit'
1259
+ : `${subject}-publication-link-failed`,
1260
+ error,
1261
+ { candidatePath: candidate, commitPath },
1262
+ ));
1263
+ }
1264
+
1265
+ const published = verifyRegularFileBinding(binding, {
1266
+ filePath: commitPath,
1267
+ expectedLinks: 2,
1268
+ });
1269
+ const staged = verifyRegularFileBinding(binding, {
1270
+ filePath: candidate,
1271
+ expectedLinks: 2,
1272
+ });
1273
+ if (published.status !== 'match' || staged.status !== 'match') {
1274
+ return rollback(relation('different', `${subject}-identity-changed`, {
1275
+ candidatePath: candidate,
1276
+ commitPath,
1277
+ publishedState: published.reason,
1278
+ stagedState: staged.reason,
1279
+ }));
1280
+ }
1281
+
1282
+ const retired = quarantineRemoveRegularFileBinding(binding, candidate, {
1283
+ subject: `${subject}-staging`,
1284
+ expectedLinks: 2,
1285
+ });
1286
+ if (retired.status !== 'removed' && retired.status !== 'preserved') {
1287
+ return rollback(retired);
1288
+ }
1289
+ const final = verifyRegularFileBinding(binding, {
1290
+ filePath: commitPath,
1291
+ expectedLinks: 1,
1292
+ });
1293
+ if (final.status !== 'match') return rollback(final);
1294
+
1295
+ let cleanupWarning = retired.status === 'preserved' ? retired : undefined;
1296
+ if (previousBinding) {
1297
+ const backupRemoval = quarantineRemoveRegularFileBinding(
1298
+ previousBinding,
1299
+ backupPath,
1300
+ { subject: 'previous-output-backup', expectedLinks: 1 },
1301
+ );
1302
+ if (backupRemoval.status !== 'removed') cleanupWarning = backupRemoval;
1303
+ const recordCleanup = backupRemoval.status === 'removed'
1304
+ ? discardPublicationRecoveryRecord(recoveryRecord)
1305
+ : releasePublicationRecoveryRecord(recoveryRecord);
1306
+ recoveryRecord = undefined;
1307
+ cleanupWarning ||= recordCleanup;
1308
+ }
1309
+ const released = finishPrevious();
1310
+ const directoryCleanup = cleanupRecoveryDirectory();
1311
+ cleanupWarning ||= released || directoryCleanup;
1312
+ const recoveryFile = cleanupWarning?.recoveryFile
1313
+ || (retired.status === 'preserved' ? candidate : undefined)
1314
+ || (recoveryDirectory ? backupPath : undefined);
1315
+ return {
1316
+ status: cleanupWarning ? 'committed-with-warning' : 'committed',
1317
+ reason: {
1318
+ code: `${subject}-published`,
1319
+ candidatePath: candidate,
1320
+ commitPath,
1321
+ ...(retired.status === 'preserved' ? { stagedSuccessorPreserved: true } : {}),
1322
+ },
1323
+ ...(cleanupWarning ? {
1324
+ cleanupState: cleanupWarning.reason,
1325
+ ...(recoveryDirectory ? { recoveryDirectory } : {}),
1326
+ ...(recoveryFile ? { recoveryFile } : {}),
1327
+ } : {}),
1328
+ };
1329
+ }
1330
+
1331
+ /** Remove only the directory entry that still names a caller-owned inode. */
1332
+ export function removeOwnedRegularFile(filePath, identity, { subject = 'candidate' } = {}) {
1333
+ const resolvedPath = path.resolve(filePath);
1334
+ let expectedLinks = 1;
1335
+ let captured = captureRegularFileBinding(resolvedPath, {
1336
+ subject,
1337
+ expectedIdentity: identity,
1338
+ expectedLinks,
1339
+ });
1340
+ const reportedLinks = Number(captured.reason?.links);
1341
+ if (captured.status === 'unsupported'
1342
+ && captured.reason?.code === `${subject}-hardlinked`
1343
+ && Number.isSafeInteger(reportedLinks)
1344
+ && reportedLinks > 1) {
1345
+ expectedLinks = reportedLinks;
1346
+ captured = captureRegularFileBinding(resolvedPath, {
1347
+ subject,
1348
+ expectedIdentity: identity,
1349
+ expectedLinks,
1350
+ });
1351
+ }
1352
+ if (captured.status !== 'captured') {
1353
+ if (captured.reason?.systemCode === 'ENOENT') {
1354
+ return relation('absent', `${subject}-already-absent`, { filePath: resolvedPath });
1355
+ }
1356
+ if (captured.status === 'different' || captured.status === 'unsupported') {
1357
+ return relation('preserved', `${subject}-identity-changed`, {
1358
+ filePath: resolvedPath,
1359
+ priorState: captured.reason,
1360
+ });
1361
+ }
1362
+ return captured;
1363
+ }
1364
+
1365
+ let removal;
1366
+ let released;
1367
+ try {
1368
+ removal = quarantineRemoveRegularFileBinding(captured.binding, resolvedPath, {
1369
+ subject,
1370
+ expectedLinks,
1371
+ });
1372
+ } finally {
1373
+ released = releaseRegularFileBinding(captured.binding);
1374
+ }
1375
+ if (released.status !== 'released') {
1376
+ return {
1377
+ ...released,
1378
+ removalStatus: removal?.status,
1379
+ ...(removal?.recoveryDirectory ? {
1380
+ recoveryDirectory: removal.recoveryDirectory,
1381
+ recoveryFile: removal.recoveryFile,
1382
+ target: removal.target,
1383
+ } : {}),
1384
+ };
1385
+ }
1386
+ return removal;
1387
+ }
1388
+
1389
+ function captureWriteSlot(requestedPath) {
1390
+ let commitPath;
1391
+ try {
1392
+ commitPath = canonicalFuturePath(requestedPath);
1393
+ } catch (error) {
1394
+ const diagnostic = error?.archifyDiagnostics?.[0];
1395
+ return relation('unknown', 'canonicalization-failed', {
1396
+ ...(diagnostic?.code ? { diagnosticCode: diagnostic.code } : {}),
1397
+ ...(diagnostic?.evidence?.relation ? { relation: diagnostic.evidence.relation } : {}),
1398
+ });
1399
+ }
1400
+
1401
+ const parentPath = path.dirname(commitPath);
1402
+ let parent;
1403
+ try {
1404
+ parent = fs.statSync(parentPath, { bigint: true });
1405
+ } catch (error) {
1406
+ return filesystemFailure('parent-inspection-failed', error, { commitPath, parentPath });
1407
+ }
1408
+ if (!parent.isDirectory()) {
1409
+ return relation('unknown', 'parent-not-directory', { commitPath, parentPath });
1410
+ }
1411
+ if (parent.ino === 0n) {
1412
+ return relation('unknown', 'parent-identity-unavailable', { commitPath, parentPath });
1413
+ }
1414
+ return {
1415
+ status: 'captured',
1416
+ slot: {
1417
+ commitPath,
1418
+ parentPath,
1419
+ name: path.basename(commitPath),
1420
+ parentDevice: parent.dev,
1421
+ parentInode: parent.ino,
1422
+ },
1423
+ };
1424
+ }
1425
+
1426
+ function captureTarget(commitPath) {
1427
+ let metadata;
1428
+ try {
1429
+ // The canonical commit path names the directory entry that rename will
1430
+ // replace. Never follow a link introduced after canonicalization.
1431
+ metadata = fs.lstatSync(commitPath, { bigint: true });
1432
+ } catch (error) {
1433
+ if (error.code === 'ENOENT') {
1434
+ return { status: 'captured', target: { kind: 'absent' }, mode: null };
1435
+ }
1436
+ return filesystemFailure('target-inspection-failed', error, { commitPath });
1437
+ }
1438
+
1439
+ const type = entryType(metadata);
1440
+ if (type !== 'file') {
1441
+ return relation('unsupported', 'target-not-regular-file', { commitPath, entryType: type });
1442
+ }
1443
+ const inspected = captureRegularFileHandle(commitPath, metadata, 'target');
1444
+ if (inspected.status !== 'captured') return inspected;
1445
+ metadata = inspected.metadata;
1446
+ return {
1447
+ status: 'captured',
1448
+ target: {
1449
+ kind: 'file',
1450
+ device: metadata.dev,
1451
+ inode: metadata.ino,
1452
+ mode: Number(metadata.mode & 0o777n),
1453
+ },
1454
+ mode: Number(metadata.mode & 0o777n),
1455
+ };
1456
+ }
1457
+
1458
+ function captureRequestedEntry(requestedPath, policy) {
1459
+ let metadata;
1460
+ try {
1461
+ metadata = fs.lstatSync(requestedPath, { bigint: true });
1462
+ } catch (error) {
1463
+ if (error.code === 'ENOENT') {
1464
+ return { status: 'captured', entry: { kind: 'absent' } };
1465
+ }
1466
+ return filesystemFailure('requested-entry-inspection-failed', error, { requestedPath });
1467
+ }
1468
+ const type = entryType(metadata);
1469
+ if (policy === 'regular-or-absent' && type !== 'file') {
1470
+ return relation('unsupported', type === 'symbolic-link'
1471
+ ? 'requested-entry-symbolic-link'
1472
+ : 'requested-entry-not-regular-file', {
1473
+ requestedPath,
1474
+ entryType: type,
1475
+ });
1476
+ }
1477
+ if (metadata.ino === 0n) {
1478
+ return relation('unknown', 'requested-entry-identity-unavailable', { requestedPath });
1479
+ }
1480
+ if (policy === 'regular-or-absent') {
1481
+ const inspected = captureRegularFileHandle(requestedPath, metadata, 'requested-entry');
1482
+ if (inspected.status !== 'captured') return inspected;
1483
+ metadata = inspected.metadata;
1484
+ }
1485
+ return {
1486
+ status: 'captured',
1487
+ entry: {
1488
+ kind: 'existing',
1489
+ entryType: type,
1490
+ device: metadata.dev,
1491
+ inode: metadata.ino,
1492
+ // An unlinked symlink's inode can be reused immediately. Its target can
1493
+ // stay the same while the requested directory entry is a new object.
1494
+ // Reading the link only changes atime, so these no-follow timestamps
1495
+ // distinguish that replacement without tracking target-file writes.
1496
+ ...(type === 'symbolic-link' ? {
1497
+ changedAtNs: metadata.ctimeNs,
1498
+ createdAtNs: metadata.birthtimeNs,
1499
+ } : {}),
1500
+ },
1501
+ };
1502
+ }
1503
+
1504
+ function requestedEntryMatches(left, right) {
1505
+ if (left.kind !== right.kind) return false;
1506
+ return left.kind === 'absent'
1507
+ || (left.entryType === right.entryType
1508
+ && left.device === right.device
1509
+ && left.inode === right.inode
1510
+ && (left.entryType !== 'symbolic-link'
1511
+ || (left.changedAtNs === right.changedAtNs
1512
+ && left.createdAtNs === right.createdAtNs)));
1513
+ }
1514
+
1515
+ /**
1516
+ * Capture the physical directory-entry slot and target identity used by a
1517
+ * verified publication. Hard links are intentionally unsupported: replacing
1518
+ * one name cannot safely update its unknown sibling names.
1519
+ * `regular-or-absent` additionally rejects requested-entry symlinks and special
1520
+ * nodes for sidecars whose public name itself must never be followed.
1521
+ */
1522
+ export function captureAtomicOutput(outputPath, { requestedEntryPolicy = 'followable-alias' } = {}) {
1523
+ if (!['followable-alias', 'regular-or-absent'].includes(requestedEntryPolicy)) {
1524
+ return relation('unknown', 'invalid-requested-entry-policy', { requestedEntryPolicy });
1525
+ }
1526
+ const requestedPath = path.resolve(outputPath);
1527
+ const requestedEntry = captureRequestedEntry(requestedPath, requestedEntryPolicy);
1528
+ if (requestedEntry.status !== 'captured') return requestedEntry;
1529
+ const slot = captureWriteSlot(requestedPath);
1530
+ if (slot.status !== 'captured') return slot;
1531
+ const target = captureTarget(slot.slot.commitPath);
1532
+ if (target.status !== 'captured') return target;
1533
+ const confirmedRequestedEntry = captureRequestedEntry(requestedPath, requestedEntryPolicy);
1534
+ if (confirmedRequestedEntry.status !== 'captured') return confirmedRequestedEntry;
1535
+ if (!requestedEntryMatches(confirmedRequestedEntry.entry, requestedEntry.entry)) {
1536
+ return relation('unknown', 'requested-entry-changed-during-inspection', { requestedPath });
1537
+ }
1538
+ return {
1539
+ status: 'captured',
1540
+ commitPath: slot.slot.commitPath,
1541
+ mode: target.mode,
1542
+ snapshot: {
1543
+ requestedPath,
1544
+ requestedEntryPolicy,
1545
+ requestedEntry: requestedEntry.entry,
1546
+ slot: slot.slot,
1547
+ target: target.target,
1548
+ },
1549
+ };
1550
+ }
1551
+
1552
+ /** Re-resolve and verify the exact write slot plus its absent/file identity. */
1553
+ export function verifyAtomicOutput(snapshot) {
1554
+ const currentRequestedEntry = captureRequestedEntry(
1555
+ snapshot.requestedPath,
1556
+ snapshot.requestedEntryPolicy || 'followable-alias',
1557
+ );
1558
+ if (currentRequestedEntry.status !== 'captured') return currentRequestedEntry;
1559
+ if (!requestedEntryMatches(currentRequestedEntry.entry, snapshot.requestedEntry)) {
1560
+ return relation('different', 'requested-entry-changed', {
1561
+ requestedPath: snapshot.requestedPath,
1562
+ previousKind: snapshot.requestedEntry.kind === 'absent'
1563
+ ? 'absent'
1564
+ : snapshot.requestedEntry.entryType,
1565
+ currentKind: currentRequestedEntry.entry.kind === 'absent'
1566
+ ? 'absent'
1567
+ : currentRequestedEntry.entry.entryType,
1568
+ });
1569
+ }
1570
+ const currentSlot = captureWriteSlot(snapshot.requestedPath);
1571
+ if (currentSlot.status !== 'captured') return currentSlot;
1572
+ const previousSlot = snapshot.slot;
1573
+ if (currentSlot.slot.parentDevice !== previousSlot.parentDevice
1574
+ || currentSlot.slot.parentInode !== previousSlot.parentInode
1575
+ || currentSlot.slot.name !== previousSlot.name) {
1576
+ return relation('different', 'write-slot-changed', {
1577
+ previousCommitPath: previousSlot.commitPath,
1578
+ currentCommitPath: currentSlot.slot.commitPath,
1579
+ });
1580
+ }
1581
+
1582
+ const currentTarget = captureTarget(previousSlot.commitPath);
1583
+ if (currentTarget.status !== 'captured') return currentTarget;
1584
+ const previousTarget = snapshot.target;
1585
+ if (currentTarget.target.kind !== previousTarget.kind) {
1586
+ return relation('different', 'target-existence-changed', {
1587
+ commitPath: previousSlot.commitPath,
1588
+ previousKind: previousTarget.kind,
1589
+ currentKind: currentTarget.target.kind,
1590
+ });
1591
+ }
1592
+ if (previousTarget.kind === 'file'
1593
+ && (currentTarget.target.device !== previousTarget.device
1594
+ || currentTarget.target.inode !== previousTarget.inode)) {
1595
+ return relation('different', 'target-identity-changed', {
1596
+ commitPath: previousSlot.commitPath,
1597
+ });
1598
+ }
1599
+ if (previousTarget.kind === 'file'
1600
+ && currentTarget.target.mode !== previousTarget.mode) {
1601
+ return relation('different', 'target-mode-changed', {
1602
+ commitPath: previousSlot.commitPath,
1603
+ previousMode: previousTarget.mode,
1604
+ currentMode: currentTarget.target.mode,
1605
+ });
1606
+ }
1607
+ return relation('match', 'atomic-output-match', { commitPath: previousSlot.commitPath });
1608
+ }
1609
+
1610
+ function parsePublicationRecoveryRecord(buffer) {
1611
+ if (!Buffer.isBuffer(buffer) || buffer.byteLength > publicationRecoveryRecordMaxBytes) {
1612
+ return relation('unknown', 'publication-recovery-record-too-large');
1613
+ }
1614
+ let parsed;
1615
+ try {
1616
+ parsed = JSON.parse(buffer.toString('utf8'));
1617
+ } catch (error) {
1618
+ return filesystemFailure('publication-recovery-record-invalid-json', error);
1619
+ }
1620
+ const backup = parsed?.backup;
1621
+ const recovery = parsed?.recovery;
1622
+ const target = parsed?.target;
1623
+ const requestedEntry = target?.requestedEntry;
1624
+ const validEntry = requestedEntry?.kind === 'absent'
1625
+ || (requestedEntry?.kind === 'existing'
1626
+ && typeof requestedEntry.entryType === 'string'
1627
+ && parseDecimal(requestedEntry.device) !== null
1628
+ && parseDecimal(requestedEntry.inode) !== null
1629
+ && (requestedEntry.entryType !== 'symbolic-link'
1630
+ || (parseDecimal(requestedEntry.changedAtNs) !== null
1631
+ && parseDecimal(requestedEntry.createdAtNs) !== null)));
1632
+ const valid = parsed?.version === publicationRecoveryRecordVersion
1633
+ && parsed?.kind === 'archify-retired-output-recovery'
1634
+ && backup?.name === 'previous'
1635
+ && typeof backup.sha256 === 'string' && /^[a-f\d]{64}$/i.test(backup.sha256)
1636
+ && parseSafeInteger(backup.bytes) !== null
1637
+ && Number.isSafeInteger(backup.mode) && backup.mode >= 0 && backup.mode <= 0o777
1638
+ && parseDecimal(backup.device) !== null && parseDecimal(backup.inode) !== null
1639
+ && typeof recovery?.name === 'string' && /^\.archify-remove-[a-f\d]{32}$/.test(recovery.name)
1640
+ && parseDecimal(recovery.device) !== null
1641
+ && parseDecimal(recovery.inode) !== null
1642
+ && typeof target?.requestedPath === 'string' && path.isAbsolute(target.requestedPath)
1643
+ && typeof target.requestedEntryPolicy === 'string'
1644
+ && ['followable-alias', 'regular-or-absent'].includes(target.requestedEntryPolicy)
1645
+ && validEntry
1646
+ && typeof target.commitPath === 'string' && path.isAbsolute(target.commitPath)
1647
+ && typeof target.parentPath === 'string' && path.isAbsolute(target.parentPath)
1648
+ // path-contract-allow: lexical-capability -- A record must exactly decompose the captured canonical target spelling.
1649
+ && typeof target.name === 'string' && target.name === path.basename(target.commitPath)
1650
+ // path-contract-allow: lexical-capability -- A record must exactly decompose the captured canonical target spelling.
1651
+ && target.parentPath === path.dirname(target.commitPath)
1652
+ && parseDecimal(target.parentDevice) !== null && parseDecimal(target.parentInode) !== null;
1653
+ if (!valid) return relation('unknown', 'publication-recovery-record-invalid');
1654
+ return {
1655
+ status: 'parsed',
1656
+ record: {
1657
+ backup: {
1658
+ ...backup,
1659
+ device: parseDecimal(backup.device),
1660
+ inode: parseDecimal(backup.inode),
1661
+ },
1662
+ recovery: {
1663
+ ...recovery,
1664
+ device: parseDecimal(recovery.device),
1665
+ inode: parseDecimal(recovery.inode),
1666
+ },
1667
+ target: {
1668
+ ...target,
1669
+ parentDevice: parseDecimal(target.parentDevice),
1670
+ parentInode: parseDecimal(target.parentInode),
1671
+ requestedEntry: requestedEntry.kind === 'absent' ? requestedEntry : {
1672
+ ...requestedEntry,
1673
+ device: parseDecimal(requestedEntry.device),
1674
+ inode: parseDecimal(requestedEntry.inode),
1675
+ ...(requestedEntry.entryType === 'symbolic-link' ? {
1676
+ changedAtNs: parseDecimal(requestedEntry.changedAtNs),
1677
+ createdAtNs: parseDecimal(requestedEntry.createdAtNs),
1678
+ } : {}),
1679
+ },
1680
+ },
1681
+ },
1682
+ };
1683
+ }
1684
+
1685
+ function verifyPublicationRecoveryDirectory(directory, metadata, record) {
1686
+ let parentMetadata;
1687
+ try {
1688
+ parentMetadata = fs.statSync(path.dirname(directory), { bigint: true });
1689
+ } catch (error) {
1690
+ return filesystemFailure('publication-recovery-parent-inspection-failed', error, {
1691
+ recoveryDirectory: directory,
1692
+ });
1693
+ }
1694
+ // path-contract-allow: lexical-capability -- The generated private-directory component is an exact record-bound capability.
1695
+ if (path.basename(directory) !== record.recovery.name
1696
+ || !parentMetadata.isDirectory()
1697
+ || parentMetadata.dev !== record.target.parentDevice
1698
+ || parentMetadata.ino !== record.target.parentInode) {
1699
+ return relation('different', 'publication-recovery-directory-not-target-child', {
1700
+ recoveryDirectory: directory,
1701
+ expectedParentPath: record.target.parentPath,
1702
+ });
1703
+ }
1704
+ if (metadata.dev !== record.recovery.device || metadata.ino !== record.recovery.inode) {
1705
+ return relation('different', 'publication-recovery-directory-identity-changed', {
1706
+ recoveryDirectory: directory,
1707
+ });
1708
+ }
1709
+ return relation('match', 'publication-recovery-directory-match', {
1710
+ recoveryDirectory: directory,
1711
+ });
1712
+ }
1713
+
1714
+ function verifyRetiredPublicationSlot(record) {
1715
+ const { target } = record;
1716
+ const slot = captureWriteSlot(target.requestedPath);
1717
+ if (slot.status !== 'captured') return slot;
1718
+ // path-contract-allow: lexical-capability -- The recaptured canonical commit spelling must exactly match the recorded capability.
1719
+ const commitPathChanged = slot.slot.commitPath !== target.commitPath;
1720
+ // path-contract-allow: lexical-capability -- The recaptured canonical parent spelling must exactly match the recorded capability.
1721
+ const parentPathChanged = slot.slot.parentPath !== target.parentPath;
1722
+ if (commitPathChanged
1723
+ || parentPathChanged
1724
+ || slot.slot.name !== target.name
1725
+ || slot.slot.parentDevice !== target.parentDevice
1726
+ || slot.slot.parentInode !== target.parentInode) {
1727
+ return relation('different', 'publication-recovery-slot-changed', {
1728
+ requestedPath: target.requestedPath,
1729
+ commitPath: target.commitPath,
1730
+ });
1731
+ }
1732
+ try {
1733
+ fs.lstatSync(target.commitPath, { bigint: true });
1734
+ return relation('preserved', 'publication-recovery-target-present', {
1735
+ commitPath: target.commitPath,
1736
+ });
1737
+ } catch (error) {
1738
+ if (error?.code !== 'ENOENT') {
1739
+ return filesystemFailure('publication-recovery-target-inspection-failed', error, {
1740
+ commitPath: target.commitPath,
1741
+ });
1742
+ }
1743
+ }
1744
+ let requested;
1745
+ try {
1746
+ requested = fs.lstatSync(target.requestedPath, { bigint: true });
1747
+ } catch (error) {
1748
+ if (error?.code !== 'ENOENT') {
1749
+ return filesystemFailure('publication-recovery-requested-entry-inspection-failed', error, {
1750
+ requestedPath: target.requestedPath,
1751
+ });
1752
+ }
1753
+ }
1754
+ if (target.requestedEntry.kind === 'existing'
1755
+ && target.requestedEntry.entryType === 'symbolic-link') {
1756
+ if (!requested?.isSymbolicLink()
1757
+ || requested.dev !== target.requestedEntry.device
1758
+ || requested.ino !== target.requestedEntry.inode
1759
+ || requested.ctimeNs !== target.requestedEntry.changedAtNs
1760
+ || requested.birthtimeNs !== target.requestedEntry.createdAtNs) {
1761
+ return relation('different', 'publication-recovery-requested-alias-changed', {
1762
+ requestedPath: target.requestedPath,
1763
+ });
1764
+ }
1765
+ } else if (requested) {
1766
+ return relation('different', 'publication-recovery-requested-entry-reclaimed', {
1767
+ requestedPath: target.requestedPath,
1768
+ });
1769
+ }
1770
+ return relation('match', 'publication-recovery-slot-match', {
1771
+ commitPath: target.commitPath,
1772
+ });
1773
+ }
1774
+
1775
+ function releaseRecoveryBindings(result, recordBinding, backupBinding) {
1776
+ const failures = [];
1777
+ if (backupBinding) {
1778
+ const released = releaseRegularFileBinding(backupBinding);
1779
+ if (released.status !== 'released') failures.push(released.reason);
1780
+ }
1781
+ if (recordBinding) {
1782
+ const released = releaseRegularFileBinding(recordBinding);
1783
+ if (released.status !== 'released') failures.push(released.reason);
1784
+ }
1785
+ if (failures.length === 0) return result;
1786
+ return {
1787
+ ...result,
1788
+ status: 'recovery-required',
1789
+ releaseState: failures,
1790
+ };
1791
+ }
1792
+
1793
+ /**
1794
+ * Explicitly restore one retired output after a process crash. This is not a
1795
+ * directory scanner: callers must name the private recovery directory they
1796
+ * intend to recover. A record is only a claim; the restored byte stream must
1797
+ * still match its recorded inode, mode, SHA-256 and size, the original parent
1798
+ * slot and aliases must still resolve identically, and the public name must be
1799
+ * absent. `linkSync` is deliberately no-clobber, so a later claimant wins.
1800
+ */
1801
+ export function recoverRetiredPublication(recoveryDirectory) {
1802
+ const directory = path.resolve(recoveryDirectory);
1803
+ let directoryMetadata;
1804
+ try {
1805
+ directoryMetadata = fs.lstatSync(directory, { bigint: true });
1806
+ } catch (error) {
1807
+ if (error?.code === 'ENOENT') {
1808
+ return relation('absent', 'publication-recovery-already-complete', { recoveryDirectory: directory });
1809
+ }
1810
+ return filesystemFailure('publication-recovery-directory-inspection-failed', error, {
1811
+ recoveryDirectory: directory,
1812
+ });
1813
+ }
1814
+ if (!directoryMetadata.isDirectory()) {
1815
+ return relation('unsupported', 'publication-recovery-directory-not-directory', {
1816
+ recoveryDirectory: directory,
1817
+ entryType: entryType(directoryMetadata),
1818
+ });
1819
+ }
1820
+ if (process.platform !== 'win32' && Number(directoryMetadata.mode & 0o077n) !== 0) {
1821
+ return relation('unsupported', 'publication-recovery-directory-not-private', {
1822
+ recoveryDirectory: directory,
1823
+ });
1824
+ }
1825
+ const recordPath = path.join(directory, publicationRecoveryRecordName);
1826
+ let recordMetadata;
1827
+ try {
1828
+ recordMetadata = fs.lstatSync(recordPath, { bigint: true });
1829
+ } catch (error) {
1830
+ return filesystemFailure('publication-recovery-record-inspection-failed', error, {
1831
+ recoveryDirectory: directory,
1832
+ recoveryFile: recordPath,
1833
+ });
1834
+ }
1835
+ if (!recordMetadata.isFile() || recordMetadata.nlink !== 1n
1836
+ || recordMetadata.size > BigInt(publicationRecoveryRecordMaxBytes)) {
1837
+ return relation('unsupported', 'publication-recovery-record-not-private-regular-file', {
1838
+ recoveryDirectory: directory,
1839
+ recoveryFile: recordPath,
1840
+ });
1841
+ }
1842
+ const capturedRecord = captureRegularFileBinding(recordPath, {
1843
+ subject: 'publication-recovery-record',
1844
+ expectedLinks: 1,
1845
+ includeContent: true,
1846
+ });
1847
+ if (capturedRecord.status !== 'captured') return capturedRecord;
1848
+ const parsed = parsePublicationRecoveryRecord(capturedRecord.content.buffer);
1849
+ if (parsed.status !== 'parsed') {
1850
+ return releaseRecoveryBindings(parsed, capturedRecord.binding);
1851
+ }
1852
+ const recoveryDirectoryState = verifyPublicationRecoveryDirectory(
1853
+ directory,
1854
+ directoryMetadata,
1855
+ parsed.record,
1856
+ );
1857
+ if (recoveryDirectoryState.status !== 'match') {
1858
+ return releaseRecoveryBindings({
1859
+ ...recoveryDirectoryState,
1860
+ recoveryDirectory: directory,
1861
+ recoveryFile: recordPath,
1862
+ }, capturedRecord.binding);
1863
+ }
1864
+ const slot = verifyRetiredPublicationSlot(parsed.record);
1865
+ if (slot.status !== 'match') {
1866
+ return releaseRecoveryBindings({
1867
+ ...slot,
1868
+ recoveryDirectory: directory,
1869
+ recoveryFile: recordPath,
1870
+ }, capturedRecord.binding);
1871
+ }
1872
+ const backupPath = path.join(directory, parsed.record.backup.name);
1873
+ const capturedBackup = captureRegularFileBinding(backupPath, {
1874
+ subject: 'publication-recovery-backup',
1875
+ expectedSha256: parsed.record.backup.sha256,
1876
+ expectedBytes: parsed.record.backup.bytes,
1877
+ expectedMode: parsed.record.backup.mode,
1878
+ expectedIdentity: {
1879
+ device: parsed.record.backup.device,
1880
+ inode: parsed.record.backup.inode,
1881
+ },
1882
+ expectedLinks: 1,
1883
+ });
1884
+ if (capturedBackup.status !== 'captured') {
1885
+ return releaseRecoveryBindings({
1886
+ ...capturedBackup,
1887
+ recoveryDirectory: directory,
1888
+ recoveryFile: backupPath,
1889
+ }, capturedRecord.binding);
1890
+ }
1891
+ // This second check narrows the remaining path-based link window. It cannot
1892
+ // make `linkSync` descriptor-bound, but it avoids linking after an ordinary
1893
+ // observed parent, alias, target, or backup change.
1894
+ const beforeLinkSlot = verifyRetiredPublicationSlot(parsed.record);
1895
+ const beforeLinkBackup = verifyRegularFileBinding(capturedBackup.binding, {
1896
+ filePath: backupPath,
1897
+ expectedLinks: 1,
1898
+ });
1899
+ if (beforeLinkSlot.status !== 'match' || beforeLinkBackup.status !== 'match') {
1900
+ return releaseRecoveryBindings({
1901
+ status: beforeLinkSlot.status === 'match' ? beforeLinkBackup.status : beforeLinkSlot.status,
1902
+ reason: {
1903
+ code: 'publication-recovery-pre-link-verification-failed',
1904
+ slotState: beforeLinkSlot.reason,
1905
+ backupState: beforeLinkBackup.reason,
1906
+ },
1907
+ recoveryDirectory: directory,
1908
+ recoveryFile: backupPath,
1909
+ }, capturedRecord.binding, capturedBackup.binding);
1910
+ }
1911
+ try {
1912
+ fs.linkSync(backupPath, parsed.record.target.commitPath);
1913
+ } catch (error) {
1914
+ return releaseRecoveryBindings(relation(
1915
+ error?.code === 'EEXIST' ? 'preserved' : 'unknown',
1916
+ error?.code === 'EEXIST'
1917
+ ? 'publication-recovery-target-created'
1918
+ : 'publication-recovery-link-failed',
1919
+ {
1920
+ recoveryDirectory: directory,
1921
+ recoveryFile: backupPath,
1922
+ commitPath: parsed.record.target.commitPath,
1923
+ ...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
1924
+ },
1925
+ ), capturedRecord.binding, capturedBackup.binding);
1926
+ }
1927
+ const publicState = verifyRegularFileBinding(capturedBackup.binding, {
1928
+ filePath: parsed.record.target.commitPath,
1929
+ expectedLinks: 2,
1930
+ });
1931
+ const backupState = verifyRegularFileBinding(capturedBackup.binding, {
1932
+ filePath: backupPath,
1933
+ expectedLinks: 2,
1934
+ });
1935
+ if (publicState.status !== 'match' || backupState.status !== 'match') {
1936
+ // We may have introduced an alias to a pathname that was swapped after the
1937
+ // pre-link checks. Remove only that alias when it is still a verified
1938
+ // two-link pair; otherwise preserve every uncertain name for inspection.
1939
+ const linkedAliasCleanup = quarantineRemoveLinkedRegularFileAlias(
1940
+ backupPath,
1941
+ parsed.record.target.commitPath,
1942
+ { subject: 'publication-recovery-unverified-link' },
1943
+ );
1944
+ return releaseRecoveryBindings(relation('recovery-required', 'publication-recovery-link-verification-failed', {
1945
+ recoveryDirectory: directory,
1946
+ recoveryFile: backupPath,
1947
+ commitPath: parsed.record.target.commitPath,
1948
+ publicState: publicState.reason,
1949
+ backupState: backupState.reason,
1950
+ linkedAliasCleanup: linkedAliasCleanup.reason,
1951
+ }), capturedRecord.binding, capturedBackup.binding);
1952
+ }
1953
+ const backupRemoval = quarantineRemoveRegularFileBinding(capturedBackup.binding, backupPath, {
1954
+ subject: 'publication-recovery-backup',
1955
+ expectedLinks: 2,
1956
+ });
1957
+ if (backupRemoval.status !== 'removed') {
1958
+ return releaseRecoveryBindings({
1959
+ ...backupRemoval,
1960
+ status: 'recovered-with-warning',
1961
+ recoveryDirectory: directory,
1962
+ recoveryFile: backupPath,
1963
+ target: parsed.record.target.commitPath,
1964
+ }, capturedRecord.binding, capturedBackup.binding);
1965
+ }
1966
+ const recordCleanup = discardPublicationRecoveryRecord({
1967
+ recordPath,
1968
+ binding: capturedRecord.binding,
1969
+ });
1970
+ const directoryCleanup = removeEmptyQuarantine(directory);
1971
+ const result = {
1972
+ status: recordCleanup || directoryCleanup ? 'recovered-with-warning' : 'recovered',
1973
+ reason: {
1974
+ code: 'publication-recovery-restored',
1975
+ recoveryDirectory: directory,
1976
+ commitPath: parsed.record.target.commitPath,
1977
+ },
1978
+ ...(recordCleanup ? { recoveryRecordState: recordCleanup.reason } : {}),
1979
+ ...(directoryCleanup ? { directoryCleanupState: directoryCleanup.reason } : {}),
1980
+ };
1981
+ return releaseRecoveryBindings(result, undefined, capturedBackup.binding);
1982
+ }