@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.1

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 (345) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -2
  3. package/dist/brief/cli.d.ts +30 -0
  4. package/dist/brief/cli.d.ts.map +1 -0
  5. package/dist/brief/cli.js +85 -0
  6. package/dist/brief/collect.d.ts +17 -0
  7. package/dist/brief/collect.d.ts.map +1 -0
  8. package/dist/brief/collect.js +342 -0
  9. package/dist/brief/config.d.ts +40 -0
  10. package/dist/brief/config.d.ts.map +1 -0
  11. package/dist/brief/config.js +85 -0
  12. package/dist/brief/constants.d.ts +12 -0
  13. package/dist/brief/constants.d.ts.map +1 -0
  14. package/dist/brief/constants.js +11 -0
  15. package/dist/brief/docs.d.ts +53 -0
  16. package/dist/brief/docs.d.ts.map +1 -0
  17. package/dist/brief/docs.js +229 -0
  18. package/dist/brief/freshness.d.ts +104 -0
  19. package/dist/brief/freshness.d.ts.map +1 -0
  20. package/dist/brief/freshness.js +263 -0
  21. package/dist/brief/git-signals.d.ts +17 -0
  22. package/dist/brief/git-signals.d.ts.map +1 -0
  23. package/dist/brief/git-signals.js +101 -0
  24. package/dist/brief/index.d.ts +11 -0
  25. package/dist/brief/index.d.ts.map +1 -0
  26. package/dist/brief/index.js +5 -0
  27. package/dist/brief/reconcile.d.ts +75 -0
  28. package/dist/brief/reconcile.d.ts.map +1 -0
  29. package/dist/brief/reconcile.js +259 -0
  30. package/dist/brief/render.d.ts +67 -0
  31. package/dist/brief/render.d.ts.map +1 -0
  32. package/dist/brief/render.js +458 -0
  33. package/dist/brief/sanitize.d.ts +38 -0
  34. package/dist/brief/sanitize.d.ts.map +1 -0
  35. package/dist/brief/sanitize.js +48 -0
  36. package/dist/brief/selectors.d.ts +36 -0
  37. package/dist/brief/selectors.d.ts.map +1 -0
  38. package/dist/brief/selectors.js +75 -0
  39. package/dist/brief/story.d.ts +18 -0
  40. package/dist/brief/story.d.ts.map +1 -0
  41. package/dist/brief/story.js +59 -0
  42. package/dist/brief/types.d.ts +126 -0
  43. package/dist/brief/types.d.ts.map +1 -0
  44. package/dist/brief/types.js +11 -0
  45. package/dist/cli/index.d.ts +3 -0
  46. package/dist/cli/index.d.ts.map +1 -0
  47. package/dist/cli/index.js +199 -0
  48. package/dist/cli/init-args.d.ts +16 -0
  49. package/dist/cli/init-args.d.ts.map +1 -0
  50. package/dist/cli/init-args.js +24 -0
  51. package/dist/cli/ui-args.d.ts +23 -0
  52. package/dist/cli/ui-args.d.ts.map +1 -0
  53. package/dist/cli/ui-args.js +66 -0
  54. package/dist/cli/ui.d.ts +40 -0
  55. package/dist/cli/ui.d.ts.map +1 -0
  56. package/dist/cli/ui.js +180 -0
  57. package/dist/coordinatorium/checkpoint.d.ts +44 -0
  58. package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
  59. package/dist/coordinatorium/checkpoint.js +124 -0
  60. package/dist/coordinatorium/cli.d.ts +39 -0
  61. package/dist/coordinatorium/cli.d.ts.map +1 -0
  62. package/dist/coordinatorium/cli.js +203 -0
  63. package/dist/coordinatorium/errors.d.ts +112 -0
  64. package/dist/coordinatorium/errors.d.ts.map +1 -0
  65. package/dist/coordinatorium/errors.js +145 -0
  66. package/dist/coordinatorium/index.d.ts +11 -0
  67. package/dist/coordinatorium/index.d.ts.map +1 -0
  68. package/dist/coordinatorium/index.js +5 -0
  69. package/dist/coordinatorium/lock.d.ts +187 -0
  70. package/dist/coordinatorium/lock.d.ts.map +1 -0
  71. package/dist/coordinatorium/lock.js +659 -0
  72. package/dist/coordinatorium/mission.d.ts +32 -0
  73. package/dist/coordinatorium/mission.d.ts.map +1 -0
  74. package/dist/coordinatorium/mission.js +87 -0
  75. package/dist/coordinatorium/state-keeper.d.ts +115 -0
  76. package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
  77. package/dist/coordinatorium/state-keeper.js +396 -0
  78. package/dist/coordinatorium/types.d.ts +69 -0
  79. package/dist/coordinatorium/types.d.ts.map +1 -0
  80. package/dist/coordinatorium/types.js +27 -0
  81. package/dist/discovery/compose.d.ts +28 -0
  82. package/dist/discovery/compose.d.ts.map +1 -0
  83. package/dist/discovery/compose.js +159 -0
  84. package/dist/discovery/contracts.d.ts +211 -0
  85. package/dist/discovery/contracts.d.ts.map +1 -0
  86. package/dist/discovery/contracts.js +555 -0
  87. package/dist/discovery/docs.d.ts +278 -0
  88. package/dist/discovery/docs.d.ts.map +1 -0
  89. package/dist/discovery/docs.js +1182 -0
  90. package/dist/discovery/facts.d.ts +49 -0
  91. package/dist/discovery/facts.d.ts.map +1 -0
  92. package/dist/discovery/facts.js +541 -0
  93. package/dist/discovery/index.d.ts +8 -0
  94. package/dist/discovery/index.d.ts.map +1 -0
  95. package/dist/discovery/index.js +9 -0
  96. package/dist/discovery/orchestrate.d.ts +35 -0
  97. package/dist/discovery/orchestrate.d.ts.map +1 -0
  98. package/dist/discovery/orchestrate.js +32 -0
  99. package/dist/discovery/render.d.ts +36 -0
  100. package/dist/discovery/render.d.ts.map +1 -0
  101. package/dist/discovery/render.js +278 -0
  102. package/dist/discovery/traverse.d.ts +197 -0
  103. package/dist/discovery/traverse.d.ts.map +1 -0
  104. package/dist/discovery/traverse.js +510 -0
  105. package/dist/durable/assess.d.ts +105 -0
  106. package/dist/durable/assess.d.ts.map +1 -0
  107. package/dist/durable/assess.js +239 -0
  108. package/dist/durable/canonical.d.ts +25 -0
  109. package/dist/durable/canonical.d.ts.map +1 -0
  110. package/dist/durable/canonical.js +78 -0
  111. package/dist/durable/cli.d.ts +37 -0
  112. package/dist/durable/cli.d.ts.map +1 -0
  113. package/dist/durable/cli.js +141 -0
  114. package/dist/durable/errors.d.ts +192 -0
  115. package/dist/durable/errors.d.ts.map +1 -0
  116. package/dist/durable/errors.js +323 -0
  117. package/dist/durable/fs-store.d.ts +254 -0
  118. package/dist/durable/fs-store.d.ts.map +1 -0
  119. package/dist/durable/fs-store.js +1493 -0
  120. package/dist/durable/index.d.ts +20 -0
  121. package/dist/durable/index.d.ts.map +1 -0
  122. package/dist/durable/index.js +13 -0
  123. package/dist/durable/log.d.ts +33 -0
  124. package/dist/durable/log.d.ts.map +1 -0
  125. package/dist/durable/log.js +157 -0
  126. package/dist/durable/memory-store.d.ts +51 -0
  127. package/dist/durable/memory-store.d.ts.map +1 -0
  128. package/dist/durable/memory-store.js +302 -0
  129. package/dist/durable/projection.d.ts +35 -0
  130. package/dist/durable/projection.d.ts.map +1 -0
  131. package/dist/durable/projection.js +328 -0
  132. package/dist/durable/prune-check.d.ts +34 -0
  133. package/dist/durable/prune-check.d.ts.map +1 -0
  134. package/dist/durable/prune-check.js +72 -0
  135. package/dist/durable/register.d.ts +100 -0
  136. package/dist/durable/register.d.ts.map +1 -0
  137. package/dist/durable/register.js +532 -0
  138. package/dist/durable/retention.d.ts +49 -0
  139. package/dist/durable/retention.d.ts.map +1 -0
  140. package/dist/durable/retention.js +59 -0
  141. package/dist/durable/runtime.d.ts +499 -0
  142. package/dist/durable/runtime.d.ts.map +1 -0
  143. package/dist/durable/runtime.js +1288 -0
  144. package/dist/durable/seams.d.ts +44 -0
  145. package/dist/durable/seams.d.ts.map +1 -0
  146. package/dist/durable/seams.js +21 -0
  147. package/dist/durable/testing.d.ts +11 -0
  148. package/dist/durable/testing.d.ts.map +1 -0
  149. package/dist/durable/testing.js +25 -0
  150. package/dist/durable/types.d.ts +442 -0
  151. package/dist/durable/types.d.ts.map +1 -0
  152. package/dist/durable/types.js +75 -0
  153. package/dist/graph/builder.d.ts +11 -0
  154. package/dist/graph/builder.d.ts.map +1 -0
  155. package/dist/graph/builder.js +153 -0
  156. package/dist/graph/identity.d.ts +37 -0
  157. package/dist/graph/identity.d.ts.map +1 -0
  158. package/dist/graph/identity.js +46 -0
  159. package/dist/graph/index.d.ts +7 -0
  160. package/dist/graph/index.d.ts.map +1 -0
  161. package/dist/graph/index.js +3 -0
  162. package/dist/graph/resolver.d.ts +41 -0
  163. package/dist/graph/resolver.d.ts.map +1 -0
  164. package/dist/graph/resolver.js +36 -0
  165. package/dist/graph/types.d.ts +42 -0
  166. package/dist/graph/types.d.ts.map +1 -0
  167. package/dist/graph/types.js +1 -0
  168. package/dist/http/index.d.ts +5 -0
  169. package/dist/http/index.d.ts.map +1 -0
  170. package/dist/http/index.js +2 -0
  171. package/dist/http/server.d.ts +76 -0
  172. package/dist/http/server.d.ts.map +1 -0
  173. package/dist/http/server.js +195 -0
  174. package/dist/http/ui-assets.d.ts +22 -0
  175. package/dist/http/ui-assets.d.ts.map +1 -0
  176. package/dist/http/ui-assets.js +100 -0
  177. package/dist/ideas/cli.d.ts +33 -0
  178. package/dist/ideas/cli.d.ts.map +1 -0
  179. package/dist/ideas/cli.js +158 -0
  180. package/dist/ideas/format.d.ts +38 -0
  181. package/dist/ideas/format.d.ts.map +1 -0
  182. package/dist/ideas/format.js +194 -0
  183. package/dist/ideas/index.d.ts +6 -0
  184. package/dist/ideas/index.d.ts.map +1 -0
  185. package/dist/ideas/index.js +3 -0
  186. package/dist/ideas/store.d.ts +55 -0
  187. package/dist/ideas/store.d.ts.map +1 -0
  188. package/dist/ideas/store.js +262 -0
  189. package/dist/ideas/types.d.ts +54 -0
  190. package/dist/ideas/types.d.ts.map +1 -0
  191. package/dist/ideas/types.js +26 -0
  192. package/dist/index.d.ts +32 -0
  193. package/dist/index.d.ts.map +1 -0
  194. package/dist/index.js +28 -0
  195. package/dist/init/index.d.ts +12 -0
  196. package/dist/init/index.d.ts.map +1 -0
  197. package/dist/init/index.js +90 -0
  198. package/dist/init/prompts.d.ts +3 -0
  199. package/dist/init/prompts.d.ts.map +1 -0
  200. package/dist/init/prompts.js +47 -0
  201. package/dist/init/scaffold.d.ts +29 -0
  202. package/dist/init/scaffold.d.ts.map +1 -0
  203. package/dist/init/scaffold.js +208 -0
  204. package/dist/init/types.d.ts +14 -0
  205. package/dist/init/types.d.ts.map +1 -0
  206. package/dist/init/types.js +1 -0
  207. package/dist/managed/index.d.ts +73 -0
  208. package/dist/managed/index.d.ts.map +1 -0
  209. package/dist/managed/index.js +222 -0
  210. package/dist/readmodel/dashboard.d.ts +40 -0
  211. package/dist/readmodel/dashboard.d.ts.map +1 -0
  212. package/dist/readmodel/dashboard.js +236 -0
  213. package/dist/readmodel/index.d.ts +3 -0
  214. package/dist/readmodel/index.d.ts.map +1 -0
  215. package/dist/readmodel/index.js +1 -0
  216. package/dist/readmodel/types.d.ts +104 -0
  217. package/dist/readmodel/types.d.ts.map +1 -0
  218. package/dist/readmodel/types.js +15 -0
  219. package/dist/root/index.d.ts +186 -0
  220. package/dist/root/index.d.ts.map +1 -0
  221. package/dist/root/index.js +430 -0
  222. package/dist/root/safe-write.d.ts +268 -0
  223. package/dist/root/safe-write.d.ts.map +1 -0
  224. package/dist/root/safe-write.js +966 -0
  225. package/dist/root/write-artifacts.d.ts +20 -0
  226. package/dist/root/write-artifacts.d.ts.map +1 -0
  227. package/dist/root/write-artifacts.js +42 -0
  228. package/dist/scriptorium/compile.d.ts +4 -0
  229. package/dist/scriptorium/compile.d.ts.map +1 -0
  230. package/dist/scriptorium/compile.js +185 -0
  231. package/dist/scriptorium/frontmatter.d.ts +13 -0
  232. package/dist/scriptorium/frontmatter.d.ts.map +1 -0
  233. package/dist/scriptorium/frontmatter.js +90 -0
  234. package/dist/scriptorium/index.d.ts +3 -0
  235. package/dist/scriptorium/index.d.ts.map +1 -0
  236. package/dist/scriptorium/index.js +1 -0
  237. package/dist/scriptorium/markdown.d.ts +31 -0
  238. package/dist/scriptorium/markdown.d.ts.map +1 -0
  239. package/dist/scriptorium/markdown.js +88 -0
  240. package/dist/scriptorium/types.d.ts +78 -0
  241. package/dist/scriptorium/types.d.ts.map +1 -0
  242. package/dist/scriptorium/types.js +1 -0
  243. package/dist/types/enforcement.d.ts +11 -0
  244. package/dist/types/enforcement.d.ts.map +1 -0
  245. package/dist/types/enforcement.js +1 -0
  246. package/dist/types/integrity.d.ts +33 -0
  247. package/dist/types/integrity.d.ts.map +1 -0
  248. package/dist/types/integrity.js +1 -0
  249. package/dist/types/project-definition.d.ts +30 -0
  250. package/dist/types/project-definition.d.ts.map +1 -0
  251. package/dist/types/project-definition.js +1 -0
  252. package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
  253. package/dist/ui/assets/index-C4NhMaq7.js +28 -0
  254. package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
  255. package/dist/ui/index.html +13 -0
  256. package/dist/update/cli.d.ts +8 -0
  257. package/dist/update/cli.d.ts.map +1 -0
  258. package/dist/update/cli.js +115 -0
  259. package/dist/update/index.d.ts +78 -0
  260. package/dist/update/index.d.ts.map +1 -0
  261. package/dist/update/index.js +285 -0
  262. package/dist/validate/index.d.ts +6 -0
  263. package/dist/validate/index.d.ts.map +1 -0
  264. package/dist/validate/index.js +214 -0
  265. package/dist/version.d.ts +2 -0
  266. package/dist/version.d.ts.map +1 -0
  267. package/dist/version.js +1 -0
  268. package/package.json +67 -4
  269. package/templates/.gitkeep +0 -0
  270. package/templates/default-instance/instruction/.cursorrules +59 -0
  271. package/templates/default-instance/instruction/.windsurfrules +59 -0
  272. package/templates/default-instance/instruction/CLAUDE.md +57 -0
  273. package/templates/default-instance/managed/agent.md +20 -0
  274. package/templates/default-instance/managed/bootstrap.md +7 -0
  275. package/templates/default-instance/managed/index-idea.md +5 -0
  276. package/templates/default-instance/managed/routing-idea.md +14 -0
  277. package/templates/default-instance/monolith/AGENT.md +1 -0
  278. package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
  279. package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
  280. package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
  281. package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
  282. package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
  283. package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
  284. package/templates/default-instance/monolith/context/maps/routing-map.md +151 -0
  285. package/templates/default-instance/monolith/context/maps/semantic-map.md +75 -0
  286. package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
  287. package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
  288. package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
  289. package/templates/default-instance/monolith/governance/README.md +29 -0
  290. package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
  291. package/templates/default-instance/monolith/learning/INDEX.md +48 -0
  292. package/templates/default-instance/monolith/learning/injection-map.md +49 -0
  293. package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
  294. package/templates/default-instance/monolith/memory/errors/README.md +26 -0
  295. package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
  296. package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
  297. package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
  298. package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
  299. package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
  300. package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
  301. package/templates/default-instance/monolith/memory/working/active.md +41 -0
  302. package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
  303. package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
  304. package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
  305. package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
  306. package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
  307. package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
  308. package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
  309. package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
  310. package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
  311. package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
  312. package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
  313. package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
  314. package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
  315. package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
  316. package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
  317. package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
  318. package/templates/default-instance/monolith/skills/INDEX.md +32 -0
  319. package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
  320. package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
  321. package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
  322. package/templates/default-instance/monolith/skills/idea-capture/SKILL.md +157 -0
  323. package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
  324. package/templates/default-instance/monolith/state.json +11 -0
  325. package/templates/default-instance/monolith/tests/INDEX.md +57 -0
  326. package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
  327. package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
  328. package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
  329. package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
  330. package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
  331. package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
  332. package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
  333. package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
  334. package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
  335. package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
  336. package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
  337. package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
  338. package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
  339. package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
  340. package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
  341. package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
  342. package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
  343. package/templates/default-instance/projectroot/AGENTS.md +111 -0
  344. package/templates/default-instance/projectroot/monolith.config.json +32 -0
  345. package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
@@ -0,0 +1,966 @@
1
+ /**
2
+ * Safe write primitive (SEC-001B.2, ADR-012).
3
+ *
4
+ * The one code path that PUBLISHES project files (creates a new one or replaces
5
+ * an existing one). It adds, for every write:
6
+ *
7
+ * - an explicit WRITE BOUNDARY (`projectBoundary` / `trustedDirectoryBoundary`);
8
+ * there is no boundary-less write;
9
+ * - refusal of symlinks anywhere below the boundary, including symlinks that
10
+ * stay inside the project (W-SYM), of non-regular destinations (FIFO,
11
+ * device, directory) and of hardlinked destinations (`nlink > 1`);
12
+ * - a temp file opened `O_CREAT|O_EXCL|O_NOFOLLOW` (where the platform has
13
+ * `O_NOFOLLOW`) and published atomically: `rename()` over a regular file
14
+ * (`replaceFile`) or `link()` to a name that must not exist (`createFile`);
15
+ * - explicit PUBLICATION STATES. A failed write is NOT_PUBLISHED or UNKNOWN; a
16
+ * write that reached the final name is PUBLISHED and never throws. The state
17
+ * is decided from the identity (`dev/ino`) of the destination and of the
18
+ * temp name, never from an error code. UNKNOWN is never retried and never
19
+ * silently downgraded;
20
+ * - identity-checked cleanup: only a temp file THIS call created, and only if
21
+ * the name still shows the `dev/ino` recorded from the descriptor.
22
+ *
23
+ * Not routed through here: lock release and stale-lock recovery (`lock.ts`)
24
+ * unlink the lock path directly, and `trusted-directory` scope creates its
25
+ * directory with `mkdirSync`; see ADR-012 "Single primitive".
26
+ *
27
+ * What this module does NOT do — and must not be described as doing: it does
28
+ * not defend against a local actor who swaps directories or symlinks
29
+ * concurrently with an operation (SEC-001B.3). `ParentRef` is a path plus a
30
+ * `dev/ino` snapshot; re-checking it before an operation is a diagnostic
31
+ * tripwire, not protection, because the operation resolves the path again.
32
+ * Likewise the final `unlink` of an owned temp file cannot be made conditional
33
+ * without `unlinkat`/dirfd: a name swapped between the identity check and the
34
+ * unlink is a residual race.
35
+ *
36
+ * Platform: guarantees are stated for POSIX. Where `O_NOFOLLOW` or directory
37
+ * fsync is missing (Windows) the checks that can still be done with
38
+ * `lstat`/`fstat` are done, and a PLATFORM_BEST_EFFORT warning is returned.
39
+ */
40
+ import * as fs from 'fs';
41
+ import { randomBytes } from 'crypto';
42
+ import { isAbsolute, join, relative, resolve, sep } from 'path';
43
+ export class SafeWriteError extends Error {
44
+ code;
45
+ phase;
46
+ publication;
47
+ path;
48
+ tmp;
49
+ cause;
50
+ constructor(message, init) {
51
+ super(message);
52
+ this.name = 'SafeWriteError';
53
+ this.code = init.code;
54
+ this.phase = init.phase;
55
+ this.publication = init.publication;
56
+ this.path = init.path;
57
+ this.tmp = init.tmp;
58
+ this.cause = init.cause;
59
+ }
60
+ }
61
+ export function detectPlatformCaps() {
62
+ const posix = process.platform !== 'win32';
63
+ return {
64
+ // `typeof` on purpose: on Windows the constant is `undefined` and
65
+ // `flags | undefined` would silently drop it.
66
+ noFollow: typeof fs.constants.O_NOFOLLOW === 'number',
67
+ dirFsync: posix,
68
+ ownerCheck: typeof process.geteuid === 'function',
69
+ posix,
70
+ };
71
+ }
72
+ export const realFsOps = {
73
+ openSync: fs.openSync,
74
+ writeSync: fs.writeSync,
75
+ fchmodSync: fs.fchmodSync,
76
+ fstatSync: fs.fstatSync,
77
+ fsyncSync: fs.fsyncSync,
78
+ closeSync: fs.closeSync,
79
+ renameSync: fs.renameSync,
80
+ linkSync: fs.linkSync,
81
+ unlinkSync: fs.unlinkSync,
82
+ lstatSync: fs.lstatSync,
83
+ mkdirSync: fs.mkdirSync,
84
+ };
85
+ function ctxOf(o) {
86
+ return {
87
+ ops: { ...realFsOps, ...(o?.ops ?? {}) },
88
+ caps: { ...detectPlatformCaps(), ...(o?.caps ?? {}) },
89
+ };
90
+ }
91
+ function errnoOf(err) {
92
+ return err?.code;
93
+ }
94
+ function errMsg(err) {
95
+ return err instanceof Error ? err.message : String(err);
96
+ }
97
+ function bigLstat(ctx, p) {
98
+ return ctx.ops.lstatSync(p, { bigint: true });
99
+ }
100
+ /** `null` when the path does not exist; any other error propagates. */
101
+ function bigLstatOrNull(ctx, p) {
102
+ try {
103
+ return bigLstat(ctx, p);
104
+ }
105
+ catch (err) {
106
+ if (errnoOf(err) === 'ENOENT')
107
+ return null;
108
+ throw err;
109
+ }
110
+ }
111
+ function sameId(st, dev, ino) {
112
+ return st.dev === dev && st.ino === ino;
113
+ }
114
+ function platformWarnings(ctx) {
115
+ if (ctx.caps.posix && ctx.caps.noFollow)
116
+ return [];
117
+ return [
118
+ {
119
+ code: 'PLATFORM_BEST_EFFORT',
120
+ message: 'Write-protection guarantees are stated for POSIX only; on this platform symlink/special-file/hardlink checks ' +
121
+ 'are best-effort and unverified (ADR-012).',
122
+ },
123
+ ];
124
+ }
125
+ function codeForErrno(err, fallback = 'WRITE_FAILED') {
126
+ switch (errnoOf(err)) {
127
+ case 'EROFS':
128
+ return 'WRITE_READONLY';
129
+ case 'EACCES':
130
+ case 'EPERM':
131
+ return 'WRITE_PERMISSION';
132
+ case 'ENOSPC':
133
+ case 'EDQUOT':
134
+ return 'WRITE_NO_SPACE';
135
+ default:
136
+ return fallback;
137
+ }
138
+ }
139
+ const boundaryData = new WeakMap();
140
+ /**
141
+ * Trust boundary of a write. Created only by `projectBoundary` /
142
+ * `trustedDirectoryBoundary`. In both modes the ANCESTORS of the boundary are
143
+ * trusted (in `project` mode: the root the user pointed at; in
144
+ * `trusted-directory` mode: because the caller asserts it); every component
145
+ * below the boundary is checked.
146
+ */
147
+ export class WriteBoundary {
148
+ constructor() {
149
+ /* created via the factory functions only */
150
+ }
151
+ get mode() {
152
+ return dataOf(this).mode;
153
+ }
154
+ /** @internal */
155
+ static create(mode, dir) {
156
+ if (typeof dir !== 'string' || dir.length === 0) {
157
+ throw new SafeWriteError('write boundary requires a non-empty directory path.', {
158
+ code: 'WRITE_BOUNDARY_INVALID',
159
+ phase: 'boundary',
160
+ publication: 'NOT_PUBLISHED',
161
+ path: String(dir),
162
+ });
163
+ }
164
+ const lexical = resolve(dir);
165
+ let realDir;
166
+ try {
167
+ realDir = fs.realpathSync(lexical);
168
+ if (!fs.statSync(realDir).isDirectory())
169
+ throw new Error('not a directory');
170
+ }
171
+ catch (err) {
172
+ throw new SafeWriteError(`write boundary "${lexical}" cannot be resolved to a directory: ${errMsg(err)}`, {
173
+ code: 'WRITE_BOUNDARY_INVALID',
174
+ phase: 'boundary',
175
+ publication: 'NOT_PUBLISHED',
176
+ path: lexical,
177
+ cause: err,
178
+ });
179
+ }
180
+ const b = new WriteBoundary();
181
+ boundaryData.set(b, { mode, lexical, realDir });
182
+ return b;
183
+ }
184
+ }
185
+ function dataOf(b) {
186
+ const d = boundaryData.get(b);
187
+ if (!d)
188
+ throw new TypeError('not a WriteBoundary; use projectBoundary() or trustedDirectoryBoundary()');
189
+ return d;
190
+ }
191
+ /** Project scope: `projectRoot` is the boundary; target directories must lie inside it. */
192
+ export function projectBoundary(projectRoot) {
193
+ return WriteBoundary.create('project', projectRoot);
194
+ }
195
+ /** Trusted-directory scope: the caller asserts `dir` and ALL its ancestors are trusted. */
196
+ export function trustedDirectoryBoundary(dir) {
197
+ return WriteBoundary.create('trusted-directory', dir);
198
+ }
199
+ const parentData = new WeakMap();
200
+ /**
201
+ * A verified directory below a boundary. Opaque on purpose: code outside this
202
+ * module must not read its path. Today it is a path with a `dev/ino` snapshot;
203
+ * SEC-001B.3 may replace it with a directory descriptor. It is NOT a security
204
+ * handle (see the module comment).
205
+ */
206
+ export class ParentRef {
207
+ constructor() {
208
+ /* created by openParent only */
209
+ }
210
+ /** @internal */
211
+ static create(data) {
212
+ const p = new ParentRef();
213
+ parentData.set(p, data);
214
+ return p;
215
+ }
216
+ }
217
+ /**
218
+ * Absolute path of the boundary a verified parent lies below, for the READ side
219
+ * of the same trust decision (SEC-001B.1-R): a lock file is read under the
220
+ * boundary its staging files are written under. Not a handle; a path.
221
+ */
222
+ export function boundaryPathOf(p) {
223
+ return dataOf(pdata(p).boundary).lexical;
224
+ }
225
+ function pdata(p) {
226
+ const d = parentData.get(p);
227
+ if (!d)
228
+ throw new TypeError('not a ParentRef');
229
+ return d;
230
+ }
231
+ /** Relative segments of `dirAbs` below the boundary, or `null` if outside. */
232
+ function segmentsBelow(b, dirAbs) {
233
+ const abs = resolve(dirAbs);
234
+ for (const base of [b.lexical, b.realDir]) {
235
+ const rel = relative(base, abs);
236
+ if (rel === '')
237
+ return [];
238
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
239
+ continue;
240
+ return rel.split(sep).filter((s) => s.length > 0);
241
+ }
242
+ return null;
243
+ }
244
+ function outsideError(b, abs) {
245
+ return new SafeWriteError(`"${abs}" is outside the ${b.mode} write boundary "${b.lexical}"; refusing to write.`, { code: 'WRITE_OUTSIDE_BOUNDARY', phase: 'boundary', publication: 'NOT_PUBLISHED', path: abs });
246
+ }
247
+ /**
248
+ * Walk `dirAbs` below the boundary, `lstat`-ing every component: it must be a
249
+ * real directory, never a symlink (W-SYM) — including a symlink that stays
250
+ * inside the project. Missing components are created only with `create`.
251
+ */
252
+ export function openParent(boundary, dirAbs, opts = {}) {
253
+ const ctx = ctxOf(opts);
254
+ const b = dataOf(boundary);
255
+ const abs = resolve(dirAbs);
256
+ const segs = segmentsBelow(b, abs);
257
+ if (segs === null)
258
+ throw outsideError(b, abs);
259
+ let current = b.realDir;
260
+ for (const seg of segs) {
261
+ const next = join(current, seg);
262
+ let st = bigLstatOrNullSafe(ctx, next, abs);
263
+ if (st === null) {
264
+ if (!opts.create) {
265
+ throw new SafeWriteError(`directory "${next}" does not exist.`, {
266
+ code: 'WRITE_PARENT_MISSING',
267
+ phase: 'parent',
268
+ publication: 'NOT_PUBLISHED',
269
+ path: next,
270
+ });
271
+ }
272
+ try {
273
+ ctx.ops.mkdirSync(next);
274
+ }
275
+ catch (err) {
276
+ if (errnoOf(err) !== 'EEXIST') {
277
+ throw new SafeWriteError(`cannot create directory "${next}": ${errMsg(err)}`, {
278
+ code: codeForErrno(err),
279
+ phase: 'mkdir',
280
+ publication: 'NOT_PUBLISHED',
281
+ path: next,
282
+ cause: err,
283
+ });
284
+ }
285
+ }
286
+ // Re-inspect: whatever is there now (ours, or planted concurrently) is judged the same way.
287
+ st = bigLstatOrNullSafe(ctx, next, abs);
288
+ if (st === null) {
289
+ throw new SafeWriteError(`directory "${next}" disappeared right after it was created.`, {
290
+ code: 'WRITE_PARENT_CHANGED',
291
+ phase: 'parent',
292
+ publication: 'NOT_PUBLISHED',
293
+ path: next,
294
+ });
295
+ }
296
+ }
297
+ assertDirectoryEntry(st, next);
298
+ current = next;
299
+ }
300
+ const st = bigLstatOrNullSafe(ctx, current, abs);
301
+ if (st === null) {
302
+ throw new SafeWriteError(`directory "${current}" does not exist.`, {
303
+ code: 'WRITE_PARENT_MISSING',
304
+ phase: 'parent',
305
+ publication: 'NOT_PUBLISHED',
306
+ path: current,
307
+ });
308
+ }
309
+ assertDirectoryEntry(st, current);
310
+ return ParentRef.create({ path: current, dev: st.dev, ino: st.ino, boundary });
311
+ }
312
+ function bigLstatOrNullSafe(ctx, p, whole) {
313
+ try {
314
+ return bigLstatOrNull(ctx, p);
315
+ }
316
+ catch (err) {
317
+ throw new SafeWriteError(`cannot inspect "${p}" (while resolving "${whole}"): ${errMsg(err)}`, {
318
+ code: codeForErrno(err),
319
+ phase: 'parent',
320
+ publication: 'NOT_PUBLISHED',
321
+ path: p,
322
+ cause: err,
323
+ });
324
+ }
325
+ }
326
+ function assertDirectoryEntry(st, p) {
327
+ if (st.isSymbolicLink()) {
328
+ throw new SafeWriteError(`"${p}" is a symbolic link. Writes refuse symlinks below the write boundary, even ones that stay inside the ` +
329
+ 'project (W-SYM, ADR-012); replace the link with a real directory.', { code: 'WRITE_PARENT_SYMLINK', phase: 'parent', publication: 'NOT_PUBLISHED', path: p });
330
+ }
331
+ if (!st.isDirectory()) {
332
+ throw new SafeWriteError(`"${p}" exists but is not a directory.`, {
333
+ code: 'WRITE_PARENT_NOT_DIRECTORY',
334
+ phase: 'parent',
335
+ publication: 'NOT_PUBLISHED',
336
+ path: p,
337
+ });
338
+ }
339
+ }
340
+ /**
341
+ * Tripwire, not protection: detects a parent that was swapped BEFORE this
342
+ * check. The operation that follows resolves the path again.
343
+ */
344
+ function assertParentUnchanged(parent, ctx) {
345
+ const d = pdata(parent);
346
+ let st;
347
+ try {
348
+ st = bigLstatOrNull(ctx, d.path);
349
+ }
350
+ catch (err) {
351
+ throw new SafeWriteError(`cannot re-inspect directory "${d.path}": ${errMsg(err)}`, {
352
+ code: codeForErrno(err, 'WRITE_PARENT_CHANGED'),
353
+ phase: 'parent-recheck',
354
+ publication: 'NOT_PUBLISHED',
355
+ path: d.path,
356
+ cause: err,
357
+ });
358
+ }
359
+ if (st === null || !st.isDirectory() || !sameId(st, d.dev, d.ino)) {
360
+ throw new SafeWriteError(`directory "${d.path}" changed while the write was being prepared.`, {
361
+ code: 'WRITE_PARENT_CHANGED',
362
+ phase: 'parent-recheck',
363
+ publication: 'NOT_PUBLISHED',
364
+ path: d.path,
365
+ });
366
+ }
367
+ }
368
+ /**
369
+ * Read-only: classify what is at `fileAbs` without writing. Missing parent
370
+ * directories make the target `free` (they would be created). A dangling
371
+ * symlink is a `symlink`, never `free`.
372
+ */
373
+ export function probeTarget(boundary, fileAbs, opts = {}) {
374
+ const ctx = ctxOf(opts);
375
+ const b = dataOf(boundary);
376
+ const abs = resolve(fileAbs);
377
+ const segs = segmentsBelow(b, abs);
378
+ if (segs === null || segs.length === 0)
379
+ return { state: 'outside', path: abs };
380
+ let current = b.realDir;
381
+ for (let i = 0; i < segs.length; i++) {
382
+ const next = join(current, segs[i]);
383
+ let st;
384
+ try {
385
+ st = bigLstatOrNull(ctx, next);
386
+ }
387
+ catch (err) {
388
+ return { state: 'unreadable', path: next, detail: errMsg(err) };
389
+ }
390
+ if (st === null)
391
+ return { state: 'free', path: abs };
392
+ const last = i === segs.length - 1;
393
+ if (!last) {
394
+ if (st.isSymbolicLink())
395
+ return { state: 'parent-symlink', path: next };
396
+ if (!st.isDirectory())
397
+ return { state: 'parent-not-directory', path: next };
398
+ current = next;
399
+ continue;
400
+ }
401
+ if (st.isSymbolicLink())
402
+ return { state: 'symlink', path: next };
403
+ if (!st.isFile())
404
+ return { state: 'special', path: next };
405
+ return { state: 'exists', path: next };
406
+ }
407
+ return { state: 'free', path: abs };
408
+ }
409
+ function openFlags(ctx) {
410
+ const c = fs.constants;
411
+ return c.O_WRONLY | c.O_CREAT | c.O_EXCL | (ctx.caps.noFollow ? c.O_NOFOLLOW : 0);
412
+ }
413
+ /**
414
+ * Create a brand-new file `name` in `parent`: `O_CREAT|O_EXCL|O_NOFOLLOW` at
415
+ * `mode`. Success proves this call created it; its identity is recorded
416
+ * immediately. If the identity cannot be recorded, ownership is NOT claimed:
417
+ * the descriptor is closed, the file is left alone and the error says so.
418
+ */
419
+ export function createExclusive(parent, name, mode, opts = {}) {
420
+ const ctx = ctxOf(opts);
421
+ const d = pdata(parent);
422
+ const path = join(d.path, name);
423
+ assertParentUnchanged(parent, ctx);
424
+ let fd;
425
+ try {
426
+ fd = ctx.ops.openSync(path, openFlags(ctx), mode);
427
+ }
428
+ catch (err) {
429
+ if (errnoOf(err) === 'EEXIST') {
430
+ throw new SafeWriteError(`temporary name "${path}" is already taken; refusing to reuse it.`, {
431
+ code: 'WRITE_TMP_COLLISION',
432
+ phase: 'create',
433
+ publication: 'NOT_PUBLISHED',
434
+ path,
435
+ cause: err,
436
+ });
437
+ }
438
+ if (errnoOf(err) === 'ELOOP') {
439
+ throw new SafeWriteError(`"${path}" is a symbolic link; refusing to write through it.`, {
440
+ code: 'WRITE_TARGET_SYMLINK',
441
+ phase: 'create',
442
+ publication: 'NOT_PUBLISHED',
443
+ path,
444
+ cause: err,
445
+ });
446
+ }
447
+ throw new SafeWriteError(`cannot create "${path}": ${errMsg(err)}`, {
448
+ code: codeForErrno(err),
449
+ phase: 'create',
450
+ publication: 'NOT_PUBLISHED',
451
+ path,
452
+ cause: err,
453
+ });
454
+ }
455
+ let st;
456
+ try {
457
+ st = ctx.ops.fstatSync(fd, { bigint: true });
458
+ if (!ctx.caps.noFollow) {
459
+ // No O_NOFOLLOW: the path must still be the very file we just created.
460
+ const at = bigLstat(ctx, path);
461
+ if (at.isSymbolicLink() || !sameId(at, st.dev, st.ino))
462
+ throw new Error('path no longer names the created file');
463
+ }
464
+ }
465
+ catch (err) {
466
+ safeClose(ctx, fd);
467
+ throw new SafeWriteError(`created "${path}" but could not confirm its identity, so it is left in place untouched: ${errMsg(err)}`, {
468
+ code: 'WRITE_VERIFY_FAILED',
469
+ phase: 'create',
470
+ publication: 'NOT_PUBLISHED',
471
+ path,
472
+ tmp: { name, owned: false, removed: null },
473
+ cause: err,
474
+ });
475
+ }
476
+ return { fd, token: { name, dev: st.dev, ino: st.ino } };
477
+ }
478
+ function safeClose(ctx, fd) {
479
+ try {
480
+ ctx.ops.closeSync(fd);
481
+ }
482
+ catch {
483
+ /* best effort */
484
+ }
485
+ }
486
+ /**
487
+ * Remove `token`'s file iff the name still shows the recorded identity. Never
488
+ * removes anything else. The unlink itself is not conditional (no `unlinkat`):
489
+ * a name swapped between the check and the unlink is a residual race.
490
+ */
491
+ export function disposeOwned(parent, token, opts = {}) {
492
+ const ctx = ctxOf(opts);
493
+ const path = join(pdata(parent).path, token.name);
494
+ let st;
495
+ try {
496
+ st = bigLstatOrNull(ctx, path);
497
+ }
498
+ catch {
499
+ return 'failed';
500
+ }
501
+ if (st === null)
502
+ return 'absent';
503
+ if (!st.isFile() || !sameId(st, token.dev, token.ino))
504
+ return 'not-owned';
505
+ try {
506
+ ctx.ops.unlinkSync(path);
507
+ return 'removed';
508
+ }
509
+ catch (err) {
510
+ return errnoOf(err) === 'ENOENT' ? 'absent' : 'failed';
511
+ }
512
+ }
513
+ function tmpInfo(res, name) {
514
+ switch (res) {
515
+ case 'removed':
516
+ case 'absent':
517
+ return { name, owned: true, removed: true };
518
+ case 'not-owned':
519
+ return { name, owned: false, removed: false };
520
+ case 'failed':
521
+ return { name, owned: true, removed: false };
522
+ }
523
+ }
524
+ function residueNote(info, dir) {
525
+ if (info.removed)
526
+ return '';
527
+ return info.owned
528
+ ? ` The temporary file "${info.name}" in "${dir}" could not be removed and was left in place.`
529
+ : ` The temporary file "${info.name}" in "${dir}" no longer matches the file this call created, so it was NOT removed.`;
530
+ }
531
+ function toBuffer(data) {
532
+ return typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
533
+ }
534
+ function tempName(finalName) {
535
+ return `${finalName}.tmp.${process.pid}.${randomBytes(8).toString('hex')}`;
536
+ }
537
+ function validName(name, parent) {
538
+ if (name.length === 0 || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0')) {
539
+ throw new SafeWriteError(`invalid file name "${name}".`, {
540
+ code: 'WRITE_OUTSIDE_BOUNDARY',
541
+ phase: 'validate',
542
+ publication: 'NOT_PUBLISHED',
543
+ path: join(pdata(parent).path, name),
544
+ });
545
+ }
546
+ }
547
+ function inspectTarget(parent, name, ctx, mode) {
548
+ const path = join(pdata(parent).path, name);
549
+ let st;
550
+ try {
551
+ st = bigLstatOrNull(ctx, path);
552
+ }
553
+ catch (err) {
554
+ throw new SafeWriteError(`cannot inspect "${path}": ${errMsg(err)}`, {
555
+ code: codeForErrno(err),
556
+ phase: 'validate',
557
+ publication: 'NOT_PUBLISHED',
558
+ path,
559
+ cause: err,
560
+ });
561
+ }
562
+ if (st === null)
563
+ return { existing: null };
564
+ if (st.isSymbolicLink()) {
565
+ throw new SafeWriteError(`"${path}" is a symbolic link (possibly dangling); writes refuse symlinks (W-SYM, ADR-012). Remove the link yourself.`, { code: 'WRITE_TARGET_SYMLINK', phase: 'validate', publication: 'NOT_PUBLISHED', path });
566
+ }
567
+ if (!st.isFile()) {
568
+ throw new SafeWriteError(`"${path}" exists but is not a regular file (FIFO, device, socket or directory).`, {
569
+ code: 'WRITE_TARGET_NOT_REGULAR',
570
+ phase: 'validate',
571
+ publication: 'NOT_PUBLISHED',
572
+ path,
573
+ });
574
+ }
575
+ if (mode === 'create') {
576
+ throw new SafeWriteError(`"${path}" already exists; it is never overwritten.`, {
577
+ code: 'WRITE_TARGET_EXISTS',
578
+ phase: 'validate',
579
+ publication: 'NOT_PUBLISHED',
580
+ path,
581
+ });
582
+ }
583
+ if (st.nlink > 1n) {
584
+ throw new SafeWriteError(`"${path}" has ${st.nlink} hard links; replacing it would silently detach them, so the write is refused. ` +
585
+ 'Make it a standalone copy (for example copy it to a new file and rename that over it), then retry.', { code: 'WRITE_TARGET_HARDLINKED', phase: 'validate', publication: 'NOT_PUBLISHED', path });
586
+ }
587
+ return { existing: st };
588
+ }
589
+ /**
590
+ * Steps 1-5b of the durability order: create, write, fchmod, verify the
591
+ * descriptor, fsync the file, close. Any failure leaves the destination
592
+ * untouched and removes the temp file only if ownership still verifies.
593
+ */
594
+ function stage(parent, finalName, data, finalMode, createMode, fsyncFile, ctx) {
595
+ const dir = pdata(parent).path;
596
+ const owned = createExclusive(parent, tempName(finalName), createMode, { ops: ctx.ops, caps: ctx.caps });
597
+ const { fd, token } = owned;
598
+ const tmpPath = join(dir, token.name);
599
+ let closed = false;
600
+ const abort = (code, phase, what, cause) => {
601
+ if (!closed)
602
+ safeClose(ctx, fd);
603
+ closed = true;
604
+ const info = tmpInfo(disposeOwned(parent, token, { ops: ctx.ops, caps: ctx.caps }), token.name);
605
+ throw new SafeWriteError(`${what}: ${errMsg(cause)}.${residueNote(info, dir)}`, {
606
+ code,
607
+ phase,
608
+ publication: 'NOT_PUBLISHED',
609
+ path: tmpPath,
610
+ tmp: info,
611
+ cause,
612
+ });
613
+ };
614
+ try {
615
+ let off = 0;
616
+ while (off < data.length) {
617
+ const n = ctx.ops.writeSync(fd, data, off, data.length - off);
618
+ if (!(n > 0))
619
+ throw new Error(`write returned ${n} with ${data.length - off} bytes unwritten`);
620
+ off += n;
621
+ }
622
+ }
623
+ catch (err) {
624
+ return abort(codeForErrno(err), 'write', `cannot write "${tmpPath}"`, err);
625
+ }
626
+ if (finalMode !== null) {
627
+ try {
628
+ ctx.ops.fchmodSync(fd, finalMode);
629
+ }
630
+ catch (err) {
631
+ return abort(codeForErrno(err), 'chmod', `cannot set the mode of "${tmpPath}"`, err);
632
+ }
633
+ }
634
+ try {
635
+ const st = ctx.ops.fstatSync(fd, { bigint: true });
636
+ if (!st.isFile())
637
+ throw new Error('not a regular file');
638
+ if (st.nlink !== 1n)
639
+ throw new Error(`link count is ${st.nlink}`);
640
+ if (st.size !== BigInt(data.length))
641
+ throw new Error(`size is ${st.size}, expected ${data.length}`);
642
+ if (!sameId(st, token.dev, token.ino))
643
+ throw new Error('identity changed');
644
+ if (finalMode !== null && (Number(st.mode) & 0o777) !== finalMode)
645
+ throw new Error('mode does not match');
646
+ if (ctx.caps.ownerCheck && Number(st.uid) !== process.geteuid())
647
+ throw new Error('not owned by the effective user');
648
+ }
649
+ catch (err) {
650
+ return abort('WRITE_VERIFY_FAILED', 'verify', `temporary file "${tmpPath}" failed verification`, err);
651
+ }
652
+ if (fsyncFile) {
653
+ try {
654
+ ctx.ops.fsyncSync(fd);
655
+ }
656
+ catch (err) {
657
+ // Never retried: after an fsync error the state of the pages is undefined.
658
+ return abort('WRITE_FSYNC_FAILED', 'fsync', `cannot fsync "${tmpPath}"`, err);
659
+ }
660
+ }
661
+ try {
662
+ closed = true;
663
+ ctx.ops.closeSync(fd);
664
+ }
665
+ catch (err) {
666
+ return abort(codeForErrno(err), 'close', `cannot close "${tmpPath}"`, err);
667
+ }
668
+ return { token, tmpPath };
669
+ }
670
+ function isOurs(st, staged) {
671
+ return st !== null && st.isFile() && sameId(st, staged.token.dev, staged.token.ino);
672
+ }
673
+ /**
674
+ * One read-only look at the two names after a publish call failed. NO errno is
675
+ * taken as proof that the destination is untouched: an error can be reported
676
+ * for an operation that took effect (a lost reply), and which errors can follow
677
+ * success depends on the filesystem, so the decision is made from identity
678
+ * (`dev/ino` recorded from the descriptor), never from the error code.
679
+ *
680
+ * - the destination is our file -> `published`;
681
+ * - the temp name is still our file and the destination is not
682
+ * (`link`+`EEXIST`: the name is held by something else) -> `lost-race`, else
683
+ * `not-published`;
684
+ * - anything else, or a failed inspection -> `unknown`.
685
+ *
686
+ * For `link`+`EEXIST` the link count of the temp file is deliberately NOT
687
+ * evidence: a second hard link to the temp name (a backup tool, another actor)
688
+ * would otherwise make a foreign destination look like ours.
689
+ * Never retries the publish and never removes anything.
690
+ */
691
+ function verifyPublication(kind, errno, targetPath, staged, ctx) {
692
+ try {
693
+ const t = bigLstatOrNull(ctx, targetPath);
694
+ if (isOurs(t, staged))
695
+ return 'published';
696
+ const m = bigLstatOrNull(ctx, staged.tmpPath);
697
+ if (isOurs(m, staged)) {
698
+ if (kind === 'link' && errno === 'EEXIST') {
699
+ // Held by another object, or it vanished again while our temp name was
700
+ // never linked (link count 1): either way nothing of ours was published.
701
+ if (t !== null || m.nlink === 1n)
702
+ return 'lost-race';
703
+ return 'unknown';
704
+ }
705
+ return 'not-published';
706
+ }
707
+ }
708
+ catch {
709
+ /* the verification itself failed: undecided */
710
+ }
711
+ return 'unknown';
712
+ }
713
+ function publishFailure(kind, err, parent, staged, targetPath, ctx, warnings) {
714
+ const dir = pdata(parent).path;
715
+ const errno = errnoOf(err);
716
+ const notPublished = (code, what) => {
717
+ const info = tmpInfo(disposeOwned(parent, staged.token, { ops: ctx.ops, caps: ctx.caps }), staged.token.name);
718
+ throw new SafeWriteError(`${what}: ${errMsg(err)}. The destination was not modified.${residueNote(info, dir)}`, {
719
+ code,
720
+ phase: 'publish',
721
+ publication: 'NOT_PUBLISHED',
722
+ path: targetPath,
723
+ tmp: info,
724
+ cause: err,
725
+ });
726
+ };
727
+ const verdict = verifyPublication(kind, errno, targetPath, staged, ctx);
728
+ if (verdict === 'published') {
729
+ warnings.push({
730
+ code: 'PUBLICATION_VERIFIED_AFTER_ERROR',
731
+ message: `${kind} reported "${errMsg(err)}" but the destination holds the new file; treated as published.`,
732
+ path: targetPath,
733
+ });
734
+ return 'published';
735
+ }
736
+ if (verdict === 'lost-race')
737
+ return 'lost-race';
738
+ if (verdict === 'not-published') {
739
+ if (kind === 'link' && (errno === 'EPERM' || errno === 'EXDEV')) {
740
+ return notPublished('WRITE_LINK_UNSUPPORTED', `the filesystem does not support hard links here`);
741
+ }
742
+ return notPublished(codeForErrno(err), `cannot publish "${targetPath}"`);
743
+ }
744
+ throw new SafeWriteError(`could not determine whether "${targetPath}" was written (${kind} failed with: ${errMsg(err)}). ` +
745
+ `Check its current state before repeating the operation; nothing was retried or removed` +
746
+ ` (temporary file: "${staged.tmpPath}").`, {
747
+ code: 'WRITE_PUBLICATION_UNKNOWN',
748
+ phase: 'publish',
749
+ publication: 'UNKNOWN',
750
+ path: targetPath,
751
+ tmp: { name: staged.token.name, owned: true, removed: false },
752
+ cause: err,
753
+ });
754
+ }
755
+ /**
756
+ * Publish an owned, completely written and closed temp file under `finalName`
757
+ * with `link()`, which fails if the name exists (symlink, FIFO or file alike).
758
+ * `lost-race`: somebody else owns the name — nothing of ours was published
759
+ * (the temp file is NOT removed here; the caller disposes it). Errors follow
760
+ * the publication-state rules in the module comment.
761
+ */
762
+ export function publishLink(parent, token, finalName, opts = {}) {
763
+ const ctx = ctxOf(opts);
764
+ validName(finalName, parent);
765
+ const dir = pdata(parent).path;
766
+ const staged = { token, tmpPath: join(dir, token.name) };
767
+ const targetPath = join(dir, finalName);
768
+ const warnings = [];
769
+ assertParentUnchanged(parent, ctx);
770
+ try {
771
+ ctx.ops.linkSync(staged.tmpPath, targetPath);
772
+ return { outcome: 'published', warnings };
773
+ }
774
+ catch (err) {
775
+ return { outcome: publishFailure('link', err, parent, staged, targetPath, ctx, warnings), warnings };
776
+ }
777
+ }
778
+ /** fsync the directory. Returns what was reached; failures become warnings. */
779
+ function syncDirectory(parent, ctx, warnings) {
780
+ const dir = pdata(parent).path;
781
+ if (!ctx.caps.dirFsync)
782
+ return 'file';
783
+ const c = fs.constants;
784
+ let fd = null;
785
+ try {
786
+ fd = ctx.ops.openSync(dir, c.O_RDONLY | (c.O_DIRECTORY ?? 0));
787
+ ctx.ops.fsyncSync(fd);
788
+ return 'full';
789
+ }
790
+ catch (err) {
791
+ const errno = errnoOf(err);
792
+ if (errno !== undefined && ['EINVAL', 'ENOTSUP', 'ENOSYS', 'EPERM', 'EACCES', 'EBADF', 'EISDIR', 'ENOTDIR'].includes(errno)) {
793
+ warnings.push({
794
+ code: 'DIR_FSYNC_UNSUPPORTED',
795
+ message: `directory fsync is not supported here (${errno}); the file is durable, the directory entry may not be.`,
796
+ path: dir,
797
+ });
798
+ return 'file';
799
+ }
800
+ warnings.push({
801
+ code: 'DIR_FSYNC_FAILED',
802
+ message: `directory fsync failed (${errMsg(err)}); the write is published but its durability is unknown.`,
803
+ path: dir,
804
+ });
805
+ return 'unknown';
806
+ }
807
+ finally {
808
+ if (fd !== null)
809
+ safeClose(ctx, fd);
810
+ }
811
+ }
812
+ function checkSize(path, data, max) {
813
+ if (max !== undefined && data.length > max) {
814
+ throw new SafeWriteError(`refusing to write ${data.length} bytes to "${path}": the limit is ${max}.`, {
815
+ code: 'WRITE_TOO_LARGE',
816
+ phase: 'validate',
817
+ publication: 'NOT_PUBLISHED',
818
+ path,
819
+ });
820
+ }
821
+ }
822
+ function plan(opts) {
823
+ const requested = opts.durability ?? 'full';
824
+ return {
825
+ finalExact: opts.exactMode !== false,
826
+ fsyncFile: requested !== 'none',
827
+ fsyncDir: requested === 'full',
828
+ requested,
829
+ };
830
+ }
831
+ function reached(requested, dir) {
832
+ if (requested === 'none')
833
+ return 'none';
834
+ if (requested === 'file')
835
+ return 'file';
836
+ return dir;
837
+ }
838
+ /**
839
+ * Atomically replace `name` in `parent` (or create it if absent): temp file ->
840
+ * fsync -> `rename`. The destination must be absent or a regular file with a
841
+ * single link; its mode is kept unless `preserveMode: false`. Throws
842
+ * `SafeWriteError` (NOT_PUBLISHED / UNKNOWN); once published it never throws.
843
+ */
844
+ export function replaceFile(parent, name, data, opts) {
845
+ const ctx = ctxOf(opts);
846
+ validName(name, parent);
847
+ const buf = toBuffer(data);
848
+ const dir = pdata(parent).path;
849
+ const targetPath = join(dir, name);
850
+ const p = plan(opts);
851
+ checkSize(targetPath, buf, opts.maxBytes);
852
+ assertParentUnchanged(parent, ctx);
853
+ const { existing } = inspectTarget(parent, name, ctx, 'replace');
854
+ let finalMode;
855
+ if (existing && opts.preserveMode !== false)
856
+ finalMode = Number(existing.mode) & 0o777;
857
+ else
858
+ finalMode = p.finalExact ? opts.mode : null;
859
+ const warnings = platformWarnings(ctx);
860
+ const staged = stage(parent, name, buf, finalMode, p.finalExact || existing ? 0o600 : opts.mode, p.fsyncFile, ctx);
861
+ assertParentUnchangedOrDispose(parent, staged, ctx, targetPath);
862
+ try {
863
+ ctx.ops.renameSync(staged.tmpPath, targetPath);
864
+ }
865
+ catch (err) {
866
+ publishFailure('rename', err, parent, staged, targetPath, ctx, warnings);
867
+ }
868
+ const dirReached = p.fsyncDir ? syncDirectory(parent, ctx, warnings) : 'file';
869
+ return { publication: 'PUBLISHED', durability: reached(p.requested, dirReached), warnings };
870
+ }
871
+ function assertParentUnchangedOrDispose(parent, staged, ctx, targetPath) {
872
+ try {
873
+ assertParentUnchanged(parent, ctx);
874
+ }
875
+ catch (err) {
876
+ const info = tmpInfo(disposeOwned(parent, staged.token, { ops: ctx.ops, caps: ctx.caps }), staged.token.name);
877
+ if (err instanceof SafeWriteError) {
878
+ throw new SafeWriteError(`${err.message}${residueNote(info, pdata(parent).path)}`, {
879
+ code: err.code,
880
+ phase: err.phase,
881
+ publication: 'NOT_PUBLISHED',
882
+ path: targetPath,
883
+ tmp: info,
884
+ cause: err.cause,
885
+ });
886
+ }
887
+ throw err;
888
+ }
889
+ }
890
+ /**
891
+ * Create `name` in `parent` without ever overwriting: temp file -> fsync ->
892
+ * `link()`, then the temp name is removed. The final name never shows a
893
+ * partially written file. A destination that exists in any form (file,
894
+ * symlink, FIFO, ...) is refused. Failures after the link succeeded are
895
+ * warnings (`STAGING_RESIDUE`, directory fsync), never errors.
896
+ */
897
+ export function createFile(parent, name, data, opts) {
898
+ const ctx = ctxOf(opts);
899
+ validName(name, parent);
900
+ const buf = toBuffer(data);
901
+ const dir = pdata(parent).path;
902
+ const targetPath = join(dir, name);
903
+ const p = plan(opts);
904
+ checkSize(targetPath, buf, opts.maxBytes);
905
+ assertParentUnchanged(parent, ctx);
906
+ inspectTarget(parent, name, ctx, 'create');
907
+ const warnings = platformWarnings(ctx);
908
+ const staged = stage(parent, name, buf, p.finalExact ? opts.mode : null, p.finalExact ? 0o600 : opts.mode, p.fsyncFile, ctx);
909
+ assertParentUnchangedOrDispose(parent, staged, ctx, targetPath);
910
+ let outcome;
911
+ try {
912
+ ctx.ops.linkSync(staged.tmpPath, targetPath);
913
+ outcome = 'published';
914
+ }
915
+ catch (err) {
916
+ outcome = publishFailure('link', err, parent, staged, targetPath, ctx, warnings);
917
+ }
918
+ if (outcome === 'lost-race') {
919
+ const info = tmpInfo(disposeOwned(parent, staged.token, { ops: ctx.ops, caps: ctx.caps }), staged.token.name);
920
+ throw new SafeWriteError(`"${targetPath}" appeared while it was being created; it is never overwritten.${residueNote(info, dir)}`, {
921
+ code: 'WRITE_TARGET_EXISTS',
922
+ phase: 'publish',
923
+ publication: 'NOT_PUBLISHED',
924
+ path: targetPath,
925
+ tmp: info,
926
+ });
927
+ }
928
+ const removed = disposeOwned(parent, staged.token, { ops: ctx.ops, caps: ctx.caps });
929
+ if (removed !== 'removed' && removed !== 'absent') {
930
+ warnings.push({
931
+ code: 'STAGING_RESIDUE',
932
+ message: `"${staged.tmpPath}" is a second name of the published file and could not be removed (${removed}); the file itself is complete.`,
933
+ path: staged.tmpPath,
934
+ });
935
+ }
936
+ const dirReached = p.fsyncDir ? syncDirectory(parent, ctx, warnings) : 'file';
937
+ return { publication: 'PUBLISHED', durability: reached(p.requested, dirReached), warnings };
938
+ }
939
+ // ---------------------------------------------------------------------------
940
+ // Reporting
941
+ // ---------------------------------------------------------------------------
942
+ let platformNoticePrinted = false;
943
+ /**
944
+ * Print a post-publication finding as one `warning` line (stderr by default).
945
+ * The platform note is printed once per process, however many files are written.
946
+ * Never throws: the write it reports on is already published.
947
+ */
948
+ export function reportWriteWarning(warning, print = (line) => {
949
+ process.stderr.write(`${line}\n`);
950
+ }) {
951
+ try {
952
+ if (warning.code === 'PLATFORM_BEST_EFFORT') {
953
+ if (platformNoticePrinted)
954
+ return;
955
+ platformNoticePrinted = true;
956
+ }
957
+ print(`warning [${warning.code}]: ${warning.message}${warning.path ? ` (${warning.path})` : ''}`);
958
+ }
959
+ catch {
960
+ /* ignored on purpose */
961
+ }
962
+ }
963
+ /** Test-only: forget that the platform note was printed. */
964
+ export function resetWriteWarningState() {
965
+ platformNoticePrinted = false;
966
+ }