@stacksjs/defaults 0.75.361 → 0.75.366
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +5 -5
- package/ai/skills/stacks-payments/SKILL.md +10 -0
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ai/skills/stacks-testing/SKILL.md +8 -1
- package/ide/vscode/package.json +1 -1
- 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
|
+
}
|