release-skill 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- package/src/snapshot/scan.mjs +419 -0
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit public mapping and snapshot fidelity for release units.
|
|
3
|
+
*
|
|
4
|
+
* Provides two public functions:
|
|
5
|
+
* - `normalizePublicMappings({ unit })` — converts repository-relative
|
|
6
|
+
* `publicFiles.from` paths to source-relative paths.
|
|
7
|
+
* - `buildPublicStaging({ sourceRoot, unit, outputDir })` — reads each
|
|
8
|
+
* mapped source file, rejects symlinks/special files/hardlinks, copies
|
|
9
|
+
* exact bytes and mode, and verifies requiredPublicFiles.
|
|
10
|
+
*
|
|
11
|
+
* Safety checks:
|
|
12
|
+
* - `from` must reside inside `unit.source` (no escaping the unit).
|
|
13
|
+
* - Paths must not escape the repository root (path traversal rejected).
|
|
14
|
+
* - Symlinks, block/char devices, and hardlinks are rejected.
|
|
15
|
+
* - Duplicate targets, case-fold collisions, and NFC collisions are rejected.
|
|
16
|
+
* - Non-empty staging directories are rejected (fail closed).
|
|
17
|
+
* - Missing required public files produce `PUBLIC_FILE_MISSING`.
|
|
18
|
+
* - Post-copy content digest, byte count, type, and mode mismatches
|
|
19
|
+
* produce `SNAPSHOT_FIDELITY_FAILED`.
|
|
20
|
+
*
|
|
21
|
+
* @module snapshot/public-map
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { lstat, readFile, mkdir, readdir, realpath, chmod as fsChmod } from 'node:fs/promises';
|
|
25
|
+
import { open as fsOpen } from 'node:fs/promises';
|
|
26
|
+
import { relative, resolve, dirname, sep as pathSep, isAbsolute } from 'node:path';
|
|
27
|
+
import { posix } from 'node:path';
|
|
28
|
+
import { constants as fsConstants } from 'node:fs';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import {
|
|
31
|
+
ReleaseError,
|
|
32
|
+
PUBLIC_FILE_MISSING,
|
|
33
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
34
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
35
|
+
CONFIG_INVALID,
|
|
36
|
+
} from '../core/errors.mjs';
|
|
37
|
+
import { canonicalPublicPath, publicPathCollisionKey } from './public-path.mjs';
|
|
38
|
+
import { isReservedReleaseControlPath } from '../core/baseline.mjs';
|
|
39
|
+
|
|
40
|
+
const { O_RDONLY, O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = fsConstants;
|
|
41
|
+
|
|
42
|
+
// O_NOFOLLOW capability check — must be numeric and nonzero.
|
|
43
|
+
// If the platform does not define O_NOFOLLOW, fail closed rather than
|
|
44
|
+
// silently degrading to follow symlinks.
|
|
45
|
+
if (typeof O_NOFOLLOW !== 'number' || O_NOFOLLOW === 0) {
|
|
46
|
+
throw new ReleaseError(
|
|
47
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
48
|
+
'O_NOFOLLOW is not available on this platform; refusing to degrade symlink safety',
|
|
49
|
+
{ O_NOFOLLOW },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resource limits
|
|
54
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MiB
|
|
55
|
+
const MAX_MAPPINGS = 100000;
|
|
56
|
+
const MAX_PATH_LENGTH = 4096;
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Containment helper (exported for cross-platform canary testing)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pure containment check using injected path helpers.
|
|
64
|
+
*
|
|
65
|
+
* Returns true only when `candidate` is strictly inside `root`:
|
|
66
|
+
* - Empty relative path (candidate === root) → false (root is not inside itself)
|
|
67
|
+
* - Relative path starting with `..` → false (candidate escapes root)
|
|
68
|
+
* - Absolute relative path (e.g. `D:\<path>` from `C:\<root>` on Windows) → false
|
|
69
|
+
* (cross-device escape detected by checking `isAbsolute(rel)`)
|
|
70
|
+
*
|
|
71
|
+
* The `relPathSep` parameter allows matching the `..` prefix to the correct
|
|
72
|
+
* path separator for the `relativeFn` implementation (POSIX uses `/`,
|
|
73
|
+
* win32 uses `\`).
|
|
74
|
+
*
|
|
75
|
+
* @param {function} relativeFn - e.g. `path.relative` or `path.win32.relative`.
|
|
76
|
+
* @param {function} isAbsoluteFn - e.g. `path.isAbsolute` or `path.win32.isAbsolute`.
|
|
77
|
+
* @param {string} root - Absolute root directory.
|
|
78
|
+
* @param {string} candidate - Absolute candidate path.
|
|
79
|
+
* @param {string} [relPathSep=pathSep] - Path separator to match `..` prefix.
|
|
80
|
+
* @returns {boolean} true if candidate is strictly inside root.
|
|
81
|
+
*/
|
|
82
|
+
export function _isContainedWith(relativeFn, isAbsoluteFn, root, candidate, relPathSep = pathSep) {
|
|
83
|
+
const rel = relativeFn(root, candidate);
|
|
84
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${relPathSep}`) && !isAbsoluteFn(rel);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Platform-native containment check.
|
|
89
|
+
*
|
|
90
|
+
* Delegates to `_isContainedWith` using the current platform's `path.relative`
|
|
91
|
+
* and `path.isAbsolute`.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} root - Absolute root directory.
|
|
94
|
+
* @param {string} candidate - Absolute candidate path.
|
|
95
|
+
* @returns {boolean} true if candidate is strictly inside root.
|
|
96
|
+
*/
|
|
97
|
+
function isContained(root, candidate) {
|
|
98
|
+
return _isContainedWith(relative, isAbsolute, root, candidate);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Private path helpers
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check that no ancestor of `filePath` is a symlink.
|
|
107
|
+
*
|
|
108
|
+
* Walks from `root` towards `filePath`, checking each path component with
|
|
109
|
+
* lstat (no follow). Rejects with PUBLIC_PATH_FORBIDDEN if any component
|
|
110
|
+
* is a symlink.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} root - Repository root (absolute).
|
|
113
|
+
* @param {string} filePath - Absolute file path to check.
|
|
114
|
+
*/
|
|
115
|
+
async function assertNoAncestorSymlinks(root, filePath, fs = { lstat }) {
|
|
116
|
+
const rel = relative(root, filePath);
|
|
117
|
+
const segments = rel.split(/[/\\]/);
|
|
118
|
+
let current = root;
|
|
119
|
+
|
|
120
|
+
for (const segment of segments) {
|
|
121
|
+
current = resolve(current, segment);
|
|
122
|
+
try {
|
|
123
|
+
const st = await fs.lstat(current, { stage: 'source-ancestor' });
|
|
124
|
+
if (st.isSymbolicLink()) {
|
|
125
|
+
throw new ReleaseError(
|
|
126
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
127
|
+
`symlink in path component: "${current}"`,
|
|
128
|
+
{ path: current, root },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof ReleaseError) throw err;
|
|
133
|
+
// ENOENT is acceptable — the file itself may not exist yet.
|
|
134
|
+
if (err.code !== 'ENOENT') {
|
|
135
|
+
throw new ReleaseError(
|
|
136
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
137
|
+
`cannot check path component: "${current}": ${err.message}`,
|
|
138
|
+
{ path: current, cause: err.code },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Walk the outputDir ancestor chain checking each EXISTING component.
|
|
147
|
+
*
|
|
148
|
+
* Every existing component must be a real directory — symlinks are rejected
|
|
149
|
+
* regardless of their target type. No "benign system symlink" exception.
|
|
150
|
+
* Test helpers should call `realpath(tmpRoot)` to resolve macOS
|
|
151
|
+
* `/var → /private/var` before constructing output paths.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} effectiveOutputDir - Absolute resolved output directory.
|
|
154
|
+
*/
|
|
155
|
+
async function assertOutputAncestorsNoFollow(effectiveOutputDir, fs = { lstat }) {
|
|
156
|
+
const parts = effectiveOutputDir.split(pathSep).filter(Boolean);
|
|
157
|
+
let accumulated = '';
|
|
158
|
+
|
|
159
|
+
for (const part of parts) {
|
|
160
|
+
accumulated = accumulated ? `${accumulated}${pathSep}${part}` : `${pathSep}${part}`;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const partStat = await fs.lstat(accumulated, { stage: 'output-ancestor' });
|
|
164
|
+
|
|
165
|
+
if (partStat.isSymbolicLink()) {
|
|
166
|
+
// Symlink in ancestor chain — always reject.
|
|
167
|
+
// Test helpers must use realpath(tmpRoot) to avoid false positives
|
|
168
|
+
// from macOS /var → /private/var.
|
|
169
|
+
throw new ReleaseError(
|
|
170
|
+
CONFIG_INVALID,
|
|
171
|
+
`outputDir ancestor is a symlink: "${accumulated}"`,
|
|
172
|
+
{ outputDir: effectiveOutputDir, symlinkAncestor: accumulated },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!partStat.isDirectory()) {
|
|
177
|
+
throw new ReleaseError(
|
|
178
|
+
CONFIG_INVALID,
|
|
179
|
+
`outputDir ancestor is not a directory: "${accumulated}"`,
|
|
180
|
+
{ outputDir: effectiveOutputDir, ancestor: accumulated },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof ReleaseError) throw err;
|
|
185
|
+
if (err.code === 'ENOENT') {
|
|
186
|
+
// This component doesn't exist yet — it will be created fresh.
|
|
187
|
+
// Its parent (the last existing component) has already been
|
|
188
|
+
// verified as a non-symlink directory by the previous iteration.
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
throw new ReleaseError(
|
|
192
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
193
|
+
`cannot inspect outputDir ancestor: ${err.message}`,
|
|
194
|
+
{ outputDir: effectiveOutputDir, ancestor: accumulated, cause: err.code },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Walk the destination path from the real output root to the destination
|
|
202
|
+
* file, checking each EXISTING component with lstat (no follow).
|
|
203
|
+
*
|
|
204
|
+
* This detects inter-mapping symlink injection where a previous mapping's
|
|
205
|
+
* _afterCopy replaced an output directory with a symlink to an external
|
|
206
|
+
* location. Must be called AFTER mkdir(destDir) and BEFORE open(destPath)
|
|
207
|
+
* to catch symlinks that mkdir follows through.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} realOutputRoot - Fixed real output root (realpath-resolved).
|
|
210
|
+
* @param {string} destPath - Absolute destination file path.
|
|
211
|
+
*/
|
|
212
|
+
async function assertDestAncestorsNoFollow(realOutputRoot, destPath, fs = { lstat }) {
|
|
213
|
+
// Walk from realOutputRoot to destPath, checking each component.
|
|
214
|
+
// The realOutputRoot is already verified as a real directory.
|
|
215
|
+
const relDest = relative(realOutputRoot, destPath);
|
|
216
|
+
const segments = relDest.split(pathSep).filter(Boolean);
|
|
217
|
+
let current = realOutputRoot;
|
|
218
|
+
|
|
219
|
+
for (const segment of segments) {
|
|
220
|
+
current = resolve(current, segment);
|
|
221
|
+
try {
|
|
222
|
+
const st = await fs.lstat(current, { stage: 'dest-ancestor' });
|
|
223
|
+
if (st.isSymbolicLink()) {
|
|
224
|
+
throw new ReleaseError(
|
|
225
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
226
|
+
`destination ancestor is a symlink: "${current}"`,
|
|
227
|
+
{ destPath, symlinkAncestor: current, realOutputRoot },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
// The component is a real directory (for ancestors) or file (for leaf).
|
|
231
|
+
// Ancestors must be directories; the leaf will be verified separately.
|
|
232
|
+
if (segment !== segments[segments.length - 1] && !st.isDirectory()) {
|
|
233
|
+
throw new ReleaseError(
|
|
234
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
235
|
+
`destination ancestor is not a directory: "${current}"`,
|
|
236
|
+
{ destPath, ancestor: current, realOutputRoot },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err instanceof ReleaseError) throw err;
|
|
241
|
+
if (err.code === 'ENOENT') {
|
|
242
|
+
// This component doesn't exist — expected for the leaf file.
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
throw new ReleaseError(
|
|
246
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
247
|
+
`cannot inspect destination ancestor: "${current}": ${err.message}`,
|
|
248
|
+
{ destPath, ancestor: current, cause: err.code },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Public API
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Normalise public file mappings for a release unit.
|
|
260
|
+
*
|
|
261
|
+
* Converts each `publicFiles.from` path from a repository-relative path
|
|
262
|
+
* to a source-relative path by stripping the source prefix. The canonical
|
|
263
|
+
* `from` path must reside inside `unit.source`; paths outside produce
|
|
264
|
+
* `PUBLIC_PATH_FORBIDDEN`.
|
|
265
|
+
*
|
|
266
|
+
* @param {Object} options
|
|
267
|
+
* @param {object} options.unit - Release unit configuration.
|
|
268
|
+
* @param {string} options.unit.source - Relative source directory.
|
|
269
|
+
* @param {object[]} options.unit.publicFiles - Array of `{ from, to, mode }`.
|
|
270
|
+
*
|
|
271
|
+
* @returns {ReadonlyArray<{ from: string, sourceRelative: string, to: string, mode: string }>}
|
|
272
|
+
*/
|
|
273
|
+
export function normalizePublicMappings({ unit } = {}) {
|
|
274
|
+
if (!unit || !Array.isArray(unit.publicFiles)) {
|
|
275
|
+
throw new ReleaseError(CONFIG_INVALID, 'unit.publicFiles must be an array');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// unit.source uses allowDot for standalone `.` as valid source.
|
|
279
|
+
const sourcePrefix = canonicalPublicPath(unit.source, { allowDot: true }).path;
|
|
280
|
+
|
|
281
|
+
return Object.freeze(
|
|
282
|
+
unit.publicFiles.map((mapping) => {
|
|
283
|
+
const from = canonicalPublicPath(mapping.from).path;
|
|
284
|
+
if (isReservedReleaseControlPath(from)) {
|
|
285
|
+
throw new ReleaseError(
|
|
286
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
287
|
+
`public file "${mapping.from}" is inside the reserved release-skill control plane`,
|
|
288
|
+
{ from: mapping.from, source: unit.source },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const sourceRelative = posix.relative(sourcePrefix, from);
|
|
292
|
+
|
|
293
|
+
// Reject `from` paths that escape `unit.source`.
|
|
294
|
+
if (sourceRelative.startsWith('../') || sourceRelative === '..') {
|
|
295
|
+
throw new ReleaseError(
|
|
296
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
297
|
+
`public file "${mapping.from}" is outside unit source "${unit.source}"`,
|
|
298
|
+
{ from: mapping.from, source: unit.source, sourceRelative },
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
from,
|
|
304
|
+
sourceRelative,
|
|
305
|
+
to: canonicalPublicPath(mapping.to).path,
|
|
306
|
+
mode: mapping.mode,
|
|
307
|
+
});
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Build a public staging directory from explicit public file mappings.
|
|
314
|
+
*
|
|
315
|
+
* For each mapping, reads `resolve(sourceRoot, mapping.from)`,
|
|
316
|
+
* rejects symlinks/special files/hardlinks, copies exact bytes and mode to
|
|
317
|
+
* `outputDir/to`, and records the entry in the manifest.
|
|
318
|
+
*
|
|
319
|
+
* After all entries are collected, checks that every file listed in
|
|
320
|
+
* `unit.requiredPublicFiles` appears in the manifest. Missing files
|
|
321
|
+
* produce `PUBLIC_FILE_MISSING`.
|
|
322
|
+
*
|
|
323
|
+
* The `outputDir` must either not exist or be empty. A non-empty
|
|
324
|
+
* staging directory is rejected to prevent overwriting stale artifacts.
|
|
325
|
+
*
|
|
326
|
+
* @param {Object} options
|
|
327
|
+
* @param {string} options.sourceRoot - Absolute source root (repository root).
|
|
328
|
+
* @param {object} options.unit - Release unit configuration.
|
|
329
|
+
* @param {string} [options.outputDir] - Output directory for staged files.
|
|
330
|
+
* If not provided, a temporary directory is created.
|
|
331
|
+
*
|
|
332
|
+
* @returns {Promise<SnapshotManifest>}
|
|
333
|
+
* @throws {ReleaseError} PUBLIC_FILE_MISSING if a required file is absent.
|
|
334
|
+
* @throws {ReleaseError} PUBLIC_PATH_FORBIDDEN if a path escapes the source.
|
|
335
|
+
* @throws {ReleaseError} SNAPSHOT_FIDELITY_FAILED on integrity mismatch.
|
|
336
|
+
* @throws {ReleaseError} CONFIG_INVALID on duplicate targets or non-empty staging.
|
|
337
|
+
*/
|
|
338
|
+
export async function buildPublicStaging({
|
|
339
|
+
sourceRoot,
|
|
340
|
+
unit,
|
|
341
|
+
outputDir,
|
|
342
|
+
/**
|
|
343
|
+
* Internal test hook — called after each file copy, before fidelity
|
|
344
|
+
* verification. Default is a no-op. Injected by fidelity tests to
|
|
345
|
+
* simulate content or mode corruption deterministically.
|
|
346
|
+
*
|
|
347
|
+
* @param {{ destPath: string, srcPath: string }} ctx
|
|
348
|
+
*/
|
|
349
|
+
_afterCopy = async () => {},
|
|
350
|
+
/**
|
|
351
|
+
* Internal test hook — called between preflight and source open.
|
|
352
|
+
* Default is a no-op. Injected by fidelity tests to simulate source
|
|
353
|
+
* mutations between lstat and open (e.g., mode change).
|
|
354
|
+
*
|
|
355
|
+
* @param {{ srcPath: string }} ctx
|
|
356
|
+
*/
|
|
357
|
+
_beforeOpen = async () => {},
|
|
358
|
+
/**
|
|
359
|
+
* Stage hook — called after source readFile() but before post-read stat().
|
|
360
|
+
* Allows tests to mutate the source file between read and verification
|
|
361
|
+
* (e.g., chmod same inode, same-length overwrite).
|
|
362
|
+
*
|
|
363
|
+
* @param {{ srcPath: string, content: Buffer }} ctx
|
|
364
|
+
*/
|
|
365
|
+
_afterSourceRead = async () => {},
|
|
366
|
+
/**
|
|
367
|
+
* Stage hook — called after destination readFile() but before post-read
|
|
368
|
+
* stat(). Allows tests to mutate the destination between read and
|
|
369
|
+
* verification (e.g., change mode/size of same inode).
|
|
370
|
+
*
|
|
371
|
+
* @param {{ destPath: string }} ctx
|
|
372
|
+
*/
|
|
373
|
+
_afterDestRead = async () => {},
|
|
374
|
+
/**
|
|
375
|
+
* Internal FS dependency injection — allows deterministic error injection
|
|
376
|
+
* for testing. NOT exposed through the public buildSnapshot adapter.
|
|
377
|
+
* Each function defaults to the real fs function when not provided.
|
|
378
|
+
*/
|
|
379
|
+
_fsOps = {},
|
|
380
|
+
/**
|
|
381
|
+
* Legacy input — must not be provided. Rejects with CONFIG_INVALID
|
|
382
|
+
* to prevent implicit file collection.
|
|
383
|
+
*/
|
|
384
|
+
generatedFiles,
|
|
385
|
+
} = {}) {
|
|
386
|
+
// Detect NFC/case collisions from the raw spellings before canonicalization
|
|
387
|
+
// collapses them to the same string. This preserves an actionable diagnostic.
|
|
388
|
+
const rawTargetByKey = new Map();
|
|
389
|
+
for (const mapping of unit?.publicFiles ?? []) {
|
|
390
|
+
const raw = mapping?.to;
|
|
391
|
+
if (typeof raw !== 'string') continue;
|
|
392
|
+
const nfc = raw.normalize('NFC');
|
|
393
|
+
const key = nfc.toLowerCase();
|
|
394
|
+
const existing = rawTargetByKey.get(key);
|
|
395
|
+
if (existing !== undefined) {
|
|
396
|
+
if (existing === raw) {
|
|
397
|
+
throw new ReleaseError(CONFIG_INVALID, `duplicate public file target: "${raw}"`, { target: raw });
|
|
398
|
+
}
|
|
399
|
+
const existingNfc = existing.normalize('NFC');
|
|
400
|
+
const kind = existingNfc === nfc ? 'NFC' : 'case-fold';
|
|
401
|
+
throw new ReleaseError(
|
|
402
|
+
CONFIG_INVALID,
|
|
403
|
+
`${kind} collision on target: "${raw}" and "${existing}"`,
|
|
404
|
+
{ target: raw, existing },
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
rawTargetByKey.set(key, raw);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Reject legacy generatedFiles input — fail closed
|
|
411
|
+
if (generatedFiles !== undefined) {
|
|
412
|
+
throw new ReleaseError(
|
|
413
|
+
CONFIG_INVALID,
|
|
414
|
+
'generatedFiles is not allowed; use explicit publicFiles mappings',
|
|
415
|
+
{ generatedFiles },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!sourceRoot || typeof sourceRoot !== 'string') {
|
|
420
|
+
throw new ReleaseError(CONFIG_INVALID, 'sourceRoot must be a non-empty string');
|
|
421
|
+
}
|
|
422
|
+
if (!unit || typeof unit.source !== 'string') {
|
|
423
|
+
throw new ReleaseError(CONFIG_INVALID, 'unit.source must be a non-empty string');
|
|
424
|
+
}
|
|
425
|
+
if (!Array.isArray(unit.publicFiles)) {
|
|
426
|
+
throw new ReleaseError(CONFIG_INVALID, 'unit.publicFiles must be an array');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Resolve FS operations (allow DI override for testing) ---
|
|
430
|
+
// All _fsOps functions receive {operation, path, stage} context for
|
|
431
|
+
// deterministic test injection. When _fsOps provides a function, it is
|
|
432
|
+
// wrapped to receive the context; otherwise the real fs function is used.
|
|
433
|
+
// This ensures tests can match by exact path+stage, not call count.
|
|
434
|
+
const fs = {
|
|
435
|
+
lstat: _fsOps.lstat
|
|
436
|
+
? (p, ctx) => _fsOps.lstat({ operation: 'lstat', path: p, ...ctx })
|
|
437
|
+
: lstat,
|
|
438
|
+
realpath: _fsOps.realpath
|
|
439
|
+
? (p, ctx) => _fsOps.realpath({ operation: 'realpath', path: p, ...ctx })
|
|
440
|
+
: realpath,
|
|
441
|
+
readFile: _fsOps.readFile
|
|
442
|
+
? (p, ctx) => _fsOps.readFile({ operation: 'readFile', path: p, ...ctx })
|
|
443
|
+
: readFile,
|
|
444
|
+
open: _fsOps.open
|
|
445
|
+
? (p, flags, mode, ctx) => _fsOps.open({ operation: 'open', path: p, flags, mode, ...ctx })
|
|
446
|
+
: fsOpen,
|
|
447
|
+
mkdir: _fsOps.mkdir
|
|
448
|
+
? (p, opts, ctx) => _fsOps.mkdir({ operation: 'mkdir', path: p, opts, ...ctx })
|
|
449
|
+
: mkdir,
|
|
450
|
+
readdir: _fsOps.readdir
|
|
451
|
+
? (p, ctx) => _fsOps.readdir({ operation: 'readdir', path: p, ...ctx })
|
|
452
|
+
: readdir,
|
|
453
|
+
chmod: _fsOps.chmod
|
|
454
|
+
? (p, mode, ctx) => _fsOps.chmod({ operation: 'chmod', path: p, mode, ...ctx })
|
|
455
|
+
: fsChmod,
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// --- sourceRoot must be a real directory, not a symlink ---
|
|
459
|
+
// Use lstat (no follow) to detect if sourceRoot itself is a symlink.
|
|
460
|
+
let realSourceRoot;
|
|
461
|
+
try {
|
|
462
|
+
const rootStat = await fs.lstat(sourceRoot, { stage: 'source-root' });
|
|
463
|
+
if (rootStat.isSymbolicLink()) {
|
|
464
|
+
throw new ReleaseError(
|
|
465
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
466
|
+
`sourceRoot is a symlink: "${sourceRoot}"`,
|
|
467
|
+
{ sourceRoot },
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
// Fix the real path of sourceRoot for all subsequent containment checks.
|
|
471
|
+
realSourceRoot = await fs.realpath(sourceRoot, { stage: 'source-root-realpath' });
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (err instanceof ReleaseError) throw err;
|
|
474
|
+
const code = err.code ?? 'UNKNOWN';
|
|
475
|
+
// ENOENT means sourceRoot doesn't exist — config issue.
|
|
476
|
+
// EACCES/EIO etc. are FS fidelity issues.
|
|
477
|
+
const errorCode = code === 'ENOENT' ? CONFIG_INVALID : SNAPSHOT_FIDELITY_FAILED;
|
|
478
|
+
throw new ReleaseError(
|
|
479
|
+
errorCode,
|
|
480
|
+
`cannot inspect sourceRoot: ${err.message}`,
|
|
481
|
+
{ sourceRoot, cause: code },
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const mappings = normalizePublicMappings({ unit });
|
|
486
|
+
|
|
487
|
+
// Resource limit: number of mappings
|
|
488
|
+
if (mappings.length > MAX_MAPPINGS) {
|
|
489
|
+
throw new ReleaseError(
|
|
490
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
491
|
+
`too many mappings: ${mappings.length} (max ${MAX_MAPPINGS})`,
|
|
492
|
+
{ count: mappings.length, limit: MAX_MAPPINGS },
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- Fix real unit root for containment checks ---
|
|
497
|
+
const unitRootLexical = resolve(sourceRoot, unit.source);
|
|
498
|
+
let realUnitRoot;
|
|
499
|
+
try {
|
|
500
|
+
realUnitRoot = await fs.realpath(unitRootLexical, { stage: 'unit-root-realpath' });
|
|
501
|
+
} catch (err) {
|
|
502
|
+
const code = err.code ?? 'UNKNOWN';
|
|
503
|
+
// ENOENT means unit source directory doesn't exist — config issue.
|
|
504
|
+
// EACCES/EIO etc. are FS fidelity issues.
|
|
505
|
+
const errorCode = code === 'ENOENT' ? CONFIG_INVALID : SNAPSHOT_FIDELITY_FAILED;
|
|
506
|
+
throw new ReleaseError(
|
|
507
|
+
errorCode,
|
|
508
|
+
`cannot resolve unit source root: ${err.message}`,
|
|
509
|
+
{ sourceRoot, unitSource: unit.source, cause: code },
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const effectiveOutputDir = resolve(
|
|
514
|
+
outputDir ?? resolve(sourceRoot, '.release-skill', 'staging'),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// =======================================================================
|
|
518
|
+
// PREFLIGHT: Validate ALL source mappings before any file I/O.
|
|
519
|
+
// This ensures that if any mapping is invalid, no destination files are
|
|
520
|
+
// created and the outputDir remains untouched (zero file writes).
|
|
521
|
+
// =======================================================================
|
|
522
|
+
const preflightRecords = [];
|
|
523
|
+
const requiredTargetSet = new Set(unit.requiredPublicFiles ?? []);
|
|
524
|
+
|
|
525
|
+
for (const mapping of mappings) {
|
|
526
|
+
const srcPath = resolve(sourceRoot, mapping.from);
|
|
527
|
+
const destPath = resolve(effectiveOutputDir, mapping.to);
|
|
528
|
+
|
|
529
|
+
// Resource limit: path length
|
|
530
|
+
if (srcPath.length > MAX_PATH_LENGTH || destPath.length > MAX_PATH_LENGTH) {
|
|
531
|
+
preflightRecords.push({
|
|
532
|
+
ok: false,
|
|
533
|
+
err: new ReleaseError(
|
|
534
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
535
|
+
`path too long: source=${srcPath.length}, dest=${destPath.length} (max ${MAX_PATH_LENGTH})`,
|
|
536
|
+
{ from: mapping.from, to: mapping.to, limit: MAX_PATH_LENGTH },
|
|
537
|
+
),
|
|
538
|
+
});
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Lexical containment: source path must be inside realSourceRoot.
|
|
543
|
+
// Uses unified isContained() to reject traversal, cross-device, and root equality.
|
|
544
|
+
if (!isContained(realSourceRoot, srcPath)) {
|
|
545
|
+
preflightRecords.push({
|
|
546
|
+
ok: false,
|
|
547
|
+
err: new ReleaseError(
|
|
548
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
549
|
+
`source file escapes sourceRoot: "${mapping.from}"`,
|
|
550
|
+
{ from: mapping.from, sourceRoot },
|
|
551
|
+
),
|
|
552
|
+
});
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Check for symlinks in ancestor directories (walk from sourceRoot)
|
|
557
|
+
try {
|
|
558
|
+
await assertNoAncestorSymlinks(sourceRoot, srcPath, fs);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
preflightRecords.push({ ok: false, err });
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Realpath containment: resolved source must be inside realSourceRoot.
|
|
565
|
+
try {
|
|
566
|
+
const realSrc = await fs.realpath(srcPath, { stage: 'source-preflight-realpath' });
|
|
567
|
+
if (!isContained(realSourceRoot, realSrc)) {
|
|
568
|
+
preflightRecords.push({
|
|
569
|
+
ok: false,
|
|
570
|
+
err: new ReleaseError(
|
|
571
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
572
|
+
`source file escapes sourceRoot via realpath: "${mapping.from}"`,
|
|
573
|
+
{ from: mapping.from, realSrc, sourceRoot },
|
|
574
|
+
),
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// Also check real containment inside unit root
|
|
579
|
+
if (!isContained(realUnitRoot, realSrc)) {
|
|
580
|
+
preflightRecords.push({
|
|
581
|
+
ok: false,
|
|
582
|
+
err: new ReleaseError(
|
|
583
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
584
|
+
`source file escapes unit root via realpath: "${mapping.from}"`,
|
|
585
|
+
{ from: mapping.from, realSrc, unitRoot: realUnitRoot },
|
|
586
|
+
),
|
|
587
|
+
});
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
} catch (err) {
|
|
591
|
+
if (err instanceof ReleaseError) {
|
|
592
|
+
preflightRecords.push({ ok: false, err });
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const code = err.code ?? 'UNKNOWN';
|
|
596
|
+
if (code === 'ENOENT') {
|
|
597
|
+
// Required source missing → PUBLIC_FILE_MISSING; non-required → CONFIG_INVALID.
|
|
598
|
+
const isRequired = requiredTargetSet.has(mapping.to);
|
|
599
|
+
preflightRecords.push({
|
|
600
|
+
ok: false,
|
|
601
|
+
err: new ReleaseError(
|
|
602
|
+
isRequired ? PUBLIC_FILE_MISSING : CONFIG_INVALID,
|
|
603
|
+
`source file not found: "${mapping.from}"`,
|
|
604
|
+
{ from: mapping.from, sourceRelative: mapping.sourceRelative, cause: code,
|
|
605
|
+
...(isRequired ? { missing: [mapping.to] } : {}) },
|
|
606
|
+
),
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
preflightRecords.push({
|
|
611
|
+
ok: false,
|
|
612
|
+
err: new ReleaseError(
|
|
613
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
614
|
+
`cannot resolve source file: "${mapping.from}"`,
|
|
615
|
+
{ from: mapping.from, cause: code },
|
|
616
|
+
),
|
|
617
|
+
});
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// lstat source: reject symlinks, devices, hardlinks, special files
|
|
622
|
+
let srcStat;
|
|
623
|
+
try {
|
|
624
|
+
srcStat = await fs.lstat(srcPath, { stage: 'source-preflight-lstat' });
|
|
625
|
+
} catch (err) {
|
|
626
|
+
const code = err.code ?? 'UNKNOWN';
|
|
627
|
+
if (code === 'ENOENT') {
|
|
628
|
+
const isRequired = requiredTargetSet.has(mapping.to);
|
|
629
|
+
preflightRecords.push({
|
|
630
|
+
ok: false,
|
|
631
|
+
err: new ReleaseError(
|
|
632
|
+
isRequired ? PUBLIC_FILE_MISSING : CONFIG_INVALID,
|
|
633
|
+
`source file not found: "${mapping.from}"`,
|
|
634
|
+
{ from: mapping.from, sourceRelative: mapping.sourceRelative, cause: code,
|
|
635
|
+
...(isRequired ? { missing: [mapping.to] } : {}) },
|
|
636
|
+
),
|
|
637
|
+
});
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
preflightRecords.push({
|
|
641
|
+
ok: false,
|
|
642
|
+
err: new ReleaseError(
|
|
643
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
644
|
+
`cannot stat source file: "${mapping.from}"`,
|
|
645
|
+
{ from: mapping.from, sourceRelative: mapping.sourceRelative, cause: code },
|
|
646
|
+
),
|
|
647
|
+
});
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (srcStat.isSymbolicLink()) {
|
|
652
|
+
preflightRecords.push({
|
|
653
|
+
ok: false,
|
|
654
|
+
err: new ReleaseError(
|
|
655
|
+
PUBLIC_PATH_FORBIDDEN,
|
|
656
|
+
`symlink not allowed: "${mapping.from}"`,
|
|
657
|
+
{ from: mapping.from },
|
|
658
|
+
),
|
|
659
|
+
});
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (srcStat.isBlockDevice() || srcStat.isCharacterDevice()) {
|
|
664
|
+
preflightRecords.push({
|
|
665
|
+
ok: false,
|
|
666
|
+
err: new ReleaseError(
|
|
667
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
668
|
+
`device file not allowed: "${mapping.from}"`,
|
|
669
|
+
{ from: mapping.from },
|
|
670
|
+
),
|
|
671
|
+
});
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Reject hardlinks (nlink > 1 means multiple directory entries)
|
|
676
|
+
if (srcStat.nlink > 1) {
|
|
677
|
+
preflightRecords.push({
|
|
678
|
+
ok: false,
|
|
679
|
+
err: new ReleaseError(
|
|
680
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
681
|
+
`hardlinked file not allowed: "${mapping.from}" (nlink=${srcStat.nlink})`,
|
|
682
|
+
{ from: mapping.from, nlink: srcStat.nlink },
|
|
683
|
+
),
|
|
684
|
+
});
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Reject special files (FIFO, socket)
|
|
689
|
+
if (!srcStat.isFile()) {
|
|
690
|
+
preflightRecords.push({
|
|
691
|
+
ok: false,
|
|
692
|
+
err: new ReleaseError(
|
|
693
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
694
|
+
`special file not allowed: "${mapping.from}"`,
|
|
695
|
+
{ from: mapping.from },
|
|
696
|
+
),
|
|
697
|
+
});
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Resource limit: file size
|
|
702
|
+
if (srcStat.size > MAX_FILE_BYTES) {
|
|
703
|
+
preflightRecords.push({
|
|
704
|
+
ok: false,
|
|
705
|
+
err: new ReleaseError(
|
|
706
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
707
|
+
`source file too large: "${mapping.from}" (${srcStat.size} bytes, max ${MAX_FILE_BYTES})`,
|
|
708
|
+
{ from: mapping.from, size: srcStat.size, limit: MAX_FILE_BYTES },
|
|
709
|
+
),
|
|
710
|
+
});
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
preflightRecords.push({
|
|
715
|
+
ok: true,
|
|
716
|
+
mapping,
|
|
717
|
+
srcPath,
|
|
718
|
+
destPath,
|
|
719
|
+
srcStat,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// If any preflight record failed, throw the FIRST error.
|
|
724
|
+
// No destination files or directories have been created yet.
|
|
725
|
+
const firstFailure = preflightRecords.find((r) => !r.ok);
|
|
726
|
+
if (firstFailure) {
|
|
727
|
+
throw firstFailure.err;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// =======================================================================
|
|
731
|
+
// REQUIRED COVERAGE + TARGET COLLISION: validate BEFORE any outputDir
|
|
732
|
+
// creation. If these fail, no directory or file is written.
|
|
733
|
+
// =======================================================================
|
|
734
|
+
|
|
735
|
+
// --- Required-file coverage check ---
|
|
736
|
+
const requiredPublicFiles = unit.requiredPublicFiles ?? [];
|
|
737
|
+
if (requiredPublicFiles.length > 0) {
|
|
738
|
+
const toSourceMap = new Map(mappings.map((m) => [m.to, m.from]));
|
|
739
|
+
const missingRequired = [];
|
|
740
|
+
for (const req of requiredPublicFiles) {
|
|
741
|
+
const src = toSourceMap.get(req);
|
|
742
|
+
if (!src) {
|
|
743
|
+
missingRequired.push(req);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
// Source existence is already validated by preflight — if we get here
|
|
747
|
+
// the source file is a valid regular file.
|
|
748
|
+
}
|
|
749
|
+
if (missingRequired.length > 0) {
|
|
750
|
+
throw new ReleaseError(
|
|
751
|
+
PUBLIC_FILE_MISSING,
|
|
752
|
+
`missing required public file(s): ${missingRequired.join(', ')}`,
|
|
753
|
+
{ missing: missingRequired },
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// --- Target uniqueness validation using shared collision key ---
|
|
759
|
+
const targetSet = new Set();
|
|
760
|
+
const collisionKeyMap = new Map();
|
|
761
|
+
|
|
762
|
+
for (const mapping of mappings) {
|
|
763
|
+
const target = mapping.to;
|
|
764
|
+
|
|
765
|
+
// Exact duplicate
|
|
766
|
+
if (targetSet.has(target)) {
|
|
767
|
+
throw new ReleaseError(
|
|
768
|
+
CONFIG_INVALID,
|
|
769
|
+
`duplicate public file target: "${target}"`,
|
|
770
|
+
{ target },
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
targetSet.add(target);
|
|
774
|
+
|
|
775
|
+
// Collision key: NFC + case-fold
|
|
776
|
+
const key = publicPathCollisionKey(target);
|
|
777
|
+
if (collisionKeyMap.has(key)) {
|
|
778
|
+
const existing = collisionKeyMap.get(key);
|
|
779
|
+
if (existing !== target) {
|
|
780
|
+
// Determine whether it's a case-fold, NFC, or combined collision.
|
|
781
|
+
const nfc = target.normalize('NFC');
|
|
782
|
+
const existingNfc = existing.normalize('NFC');
|
|
783
|
+
const isNfc = nfc === existingNfc;
|
|
784
|
+
const isCase = nfc.toLowerCase() === existingNfc.toLowerCase();
|
|
785
|
+
let kind = 'case+NFC';
|
|
786
|
+
if (isNfc && !isCase) kind = 'NFC';
|
|
787
|
+
else if (!isNfc && isCase) kind = 'case-fold';
|
|
788
|
+
|
|
789
|
+
throw new ReleaseError(
|
|
790
|
+
CONFIG_INVALID,
|
|
791
|
+
`${kind} collision on target: "${target}" and "${existing}"`,
|
|
792
|
+
{ target, existing },
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
collisionKeyMap.set(key, target);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// =======================================================================
|
|
800
|
+
// OUTPUT DIR: validate ancestors + create staging directory.
|
|
801
|
+
// All preflight and collision checks passed — safe to create outputDir.
|
|
802
|
+
// =======================================================================
|
|
803
|
+
|
|
804
|
+
// --- Walk outputDir ancestor chain, rejecting symlinks (lstat, no follow) ---
|
|
805
|
+
await assertOutputAncestorsNoFollow(effectiveOutputDir, fs);
|
|
806
|
+
|
|
807
|
+
// --- Check outputDir itself ---
|
|
808
|
+
try {
|
|
809
|
+
const outputDirStat = await fs.lstat(effectiveOutputDir, { stage: 'output-dir' });
|
|
810
|
+
if (outputDirStat.isSymbolicLink()) {
|
|
811
|
+
throw new ReleaseError(
|
|
812
|
+
CONFIG_INVALID,
|
|
813
|
+
`staging directory is a symlink: "${effectiveOutputDir}"`,
|
|
814
|
+
{ outputDir: effectiveOutputDir },
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
if (!outputDirStat.isDirectory()) {
|
|
818
|
+
throw new ReleaseError(
|
|
819
|
+
CONFIG_INVALID,
|
|
820
|
+
`staging path is not a directory: "${effectiveOutputDir}"`,
|
|
821
|
+
{ outputDir: effectiveOutputDir },
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Existing directory: must be empty
|
|
826
|
+
const dirEntries = await fs.readdir(effectiveOutputDir, { stage: 'output-dir-read' });
|
|
827
|
+
if (dirEntries.length > 0) {
|
|
828
|
+
throw new ReleaseError(
|
|
829
|
+
CONFIG_INVALID,
|
|
830
|
+
`staging directory is not empty: "${effectiveOutputDir}" (${dirEntries.length} entries)`,
|
|
831
|
+
{ outputDir: effectiveOutputDir, entryCount: dirEntries.length },
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Existing empty directory: verify owner (uid) and permissions ≤ 0700.
|
|
836
|
+
if (typeof outputDirStat.uid === 'number' && process.getuid) {
|
|
837
|
+
const currentUid = process.getuid();
|
|
838
|
+
if (outputDirStat.uid !== currentUid) {
|
|
839
|
+
throw new ReleaseError(
|
|
840
|
+
CONFIG_INVALID,
|
|
841
|
+
`staging directory not owned by current user: uid=${outputDirStat.uid} expected=${currentUid}`,
|
|
842
|
+
{ outputDir: effectiveOutputDir },
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const dirMode = outputDirStat.mode & 0o7777;
|
|
847
|
+
// Permission must not be wider than 0700 (owner rwx only)
|
|
848
|
+
if ((dirMode & ~0o700) !== 0) {
|
|
849
|
+
throw new ReleaseError(
|
|
850
|
+
CONFIG_INVALID,
|
|
851
|
+
`staging directory permissions too wide: 0o${dirMode.toString(8)} (max 0700)`,
|
|
852
|
+
{ outputDir: effectiveOutputDir, mode: dirMode },
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
} catch (err) {
|
|
856
|
+
if (err instanceof ReleaseError) throw err;
|
|
857
|
+
// Only ENOENT (directory does not exist) is safe to proceed from.
|
|
858
|
+
// ENOENT is a config issue (path doesn't exist yet).
|
|
859
|
+
// Other errors (EACCES, EIO, ENOTDIR, etc.) indicate FS fidelity issues.
|
|
860
|
+
if (err.code !== 'ENOENT') {
|
|
861
|
+
throw new ReleaseError(
|
|
862
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
863
|
+
`cannot inspect staging directory: ${err.message}`,
|
|
864
|
+
{ outputDir: effectiveOutputDir, cause: err.code },
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// --- Create staging directory with mode 0700 ---
|
|
870
|
+
try {
|
|
871
|
+
await fs.mkdir(effectiveOutputDir, { recursive: true, mode: 0o700 }, { stage: 'output-dir-mkdir' });
|
|
872
|
+
} catch (err) {
|
|
873
|
+
throw new ReleaseError(
|
|
874
|
+
CONFIG_INVALID,
|
|
875
|
+
`cannot create staging directory: ${err.message}`,
|
|
876
|
+
{ outputDir: effectiveOutputDir, cause: err.code },
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Ensure the created directory has mode 0700 (mkdir may be affected by umask)
|
|
881
|
+
try {
|
|
882
|
+
await fs.chmod(effectiveOutputDir, 0o700, { stage: 'output-dir-chmod' });
|
|
883
|
+
} catch (err) {
|
|
884
|
+
throw new ReleaseError(
|
|
885
|
+
CONFIG_INVALID,
|
|
886
|
+
`cannot chmod staging directory: ${err.message}`,
|
|
887
|
+
{ outputDir: effectiveOutputDir, cause: err.code },
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// --- Copy and verify each file ---
|
|
892
|
+
// All preflight checks passed. Now we create destination files.
|
|
893
|
+
const entries = [];
|
|
894
|
+
let totalSize = 0;
|
|
895
|
+
|
|
896
|
+
// Real output root for containment checks on destination writes.
|
|
897
|
+
let realOutputRoot;
|
|
898
|
+
try {
|
|
899
|
+
realOutputRoot = await fs.realpath(effectiveOutputDir, { stage: 'output-root-realpath' });
|
|
900
|
+
} catch (err) {
|
|
901
|
+
const code = err.code ?? 'UNKNOWN';
|
|
902
|
+
// ENOENT means directory doesn't exist yet — config issue.
|
|
903
|
+
// EACCES/EIO/etc. indicate FS fidelity issues.
|
|
904
|
+
const errorCode = code === 'ENOENT' ? CONFIG_INVALID : SNAPSHOT_FIDELITY_FAILED;
|
|
905
|
+
throw new ReleaseError(
|
|
906
|
+
errorCode,
|
|
907
|
+
`cannot resolve staging directory: ${err.message}`,
|
|
908
|
+
{ outputDir: effectiveOutputDir, cause: code },
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Fix output root identity for inter-mapping root swap detection.
|
|
913
|
+
// Each mapping re-verifies isDirectory/dev/ino/realPath before destination operations.
|
|
914
|
+
let rootIdentity;
|
|
915
|
+
try {
|
|
916
|
+
const rootStat = await fs.lstat(effectiveOutputDir, { stage: 'root-identity' });
|
|
917
|
+
if (!rootStat.isDirectory()) {
|
|
918
|
+
throw new ReleaseError(
|
|
919
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
920
|
+
`output root is not a directory: "${effectiveOutputDir}"`,
|
|
921
|
+
{ outputDir: effectiveOutputDir },
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
rootIdentity = {
|
|
925
|
+
dev: rootStat.dev,
|
|
926
|
+
ino: rootStat.ino,
|
|
927
|
+
realPath: realOutputRoot,
|
|
928
|
+
};
|
|
929
|
+
} catch (err) {
|
|
930
|
+
throw new ReleaseError(
|
|
931
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
932
|
+
`cannot fix output root identity: ${err.message}`,
|
|
933
|
+
{ outputDir: effectiveOutputDir, cause: err.code },
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
for (const { mapping, srcPath, destPath, srcStat } of preflightRecords) {
|
|
938
|
+
const srcMode = srcStat.mode & 0o7777;
|
|
939
|
+
|
|
940
|
+
// --- Internal test hook: called between preflight and source open ---
|
|
941
|
+
// Allows tests to mutate source (e.g., mode change) after lstat but
|
|
942
|
+
// before open, to verify stat→read→stat catches TOCTOU.
|
|
943
|
+
await _beforeOpen({ srcPath });
|
|
944
|
+
|
|
945
|
+
// --- Read source bytes via O_NOFOLLOW handle ---
|
|
946
|
+
// Use open(O_RDONLY|O_NOFOLLOW) to prevent symlink TOCTOU.
|
|
947
|
+
// All facts (fstat, readFile) come from the same handle.
|
|
948
|
+
let content;
|
|
949
|
+
let srcHandle;
|
|
950
|
+
try {
|
|
951
|
+
srcHandle = await fs.open(srcPath, O_RDONLY | O_NOFOLLOW, undefined, { stage: 'source-open' });
|
|
952
|
+
const srcHandleStat = await srcHandle.stat();
|
|
953
|
+
|
|
954
|
+
// Pre-read: verify ALL stat fields match lstat (type, nlink, size, mode, dev, ino)
|
|
955
|
+
if (srcHandleStat.dev !== srcStat.dev || srcHandleStat.ino !== srcStat.ino) {
|
|
956
|
+
throw new ReleaseError(
|
|
957
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
958
|
+
`TOCTOU detected: source handle dev/ino mismatch for "${mapping.from}"`,
|
|
959
|
+
{ from: mapping.from },
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
if (!srcHandleStat.isFile()) {
|
|
963
|
+
throw new ReleaseError(
|
|
964
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
965
|
+
`TOCTOU detected: source type changed for "${mapping.from}"`,
|
|
966
|
+
{ from: mapping.from },
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
if (srcHandleStat.nlink !== srcStat.nlink) {
|
|
970
|
+
throw new ReleaseError(
|
|
971
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
972
|
+
`TOCTOU detected: source nlink changed for "${mapping.from}"`,
|
|
973
|
+
{ from: mapping.from, lstatNlink: srcStat.nlink, handleNlink: srcHandleStat.nlink },
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
if (srcHandleStat.size !== srcStat.size) {
|
|
977
|
+
throw new ReleaseError(
|
|
978
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
979
|
+
`TOCTOU detected: source size changed for "${mapping.from}"`,
|
|
980
|
+
{ from: mapping.from, lstatSize: srcStat.size, handleSize: srcHandleStat.size },
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
if ((srcHandleStat.mode & 0o7777) !== (srcStat.mode & 0o7777)) {
|
|
984
|
+
throw new ReleaseError(
|
|
985
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
986
|
+
`TOCTOU detected: source mode changed for "${mapping.from}"`,
|
|
987
|
+
{ from: mapping.from },
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Verify realpath of the handle is inside both realSourceRoot and realUnitRoot.
|
|
992
|
+
// This catches a race between preflight and open where a component
|
|
993
|
+
// was replaced with a symlink.
|
|
994
|
+
const handleRealPath = await fs.realpath(srcPath, { stage: 'source-open-realpath' });
|
|
995
|
+
if (!isContained(realSourceRoot, handleRealPath)) {
|
|
996
|
+
throw new ReleaseError(
|
|
997
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
998
|
+
`source escapes sourceRoot containment after open: "${mapping.from}"`,
|
|
999
|
+
{ from: mapping.from, realPath: handleRealPath },
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (!isContained(realUnitRoot, handleRealPath)) {
|
|
1003
|
+
throw new ReleaseError(
|
|
1004
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1005
|
+
`source escapes unitRoot containment after open: "${mapping.from}"`,
|
|
1006
|
+
{ from: mapping.from, realPath: handleRealPath },
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Re-check source ancestor symlinks after open (defense in depth)
|
|
1011
|
+
try {
|
|
1012
|
+
await assertNoAncestorSymlinks(sourceRoot, srcPath, fs);
|
|
1013
|
+
} catch (ancestorErr) {
|
|
1014
|
+
throw new ReleaseError(
|
|
1015
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1016
|
+
`source ancestor symlink detected after open: "${mapping.from}"`,
|
|
1017
|
+
{ from: mapping.from, cause: ancestorErr.message },
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
content = await srcHandle.readFile();
|
|
1022
|
+
|
|
1023
|
+
// Stage hook: after source readFile(), before post-read stat()
|
|
1024
|
+
await _afterSourceRead({ srcPath, content });
|
|
1025
|
+
|
|
1026
|
+
// Post-read stat: verify ALL fields haven't changed during read.
|
|
1027
|
+
// Includes mtimeMs/ctimeMs to catch same-length overwrites.
|
|
1028
|
+
const postReadStat = await srcHandle.stat();
|
|
1029
|
+
if (postReadStat.dev !== srcStat.dev ||
|
|
1030
|
+
postReadStat.ino !== srcStat.ino ||
|
|
1031
|
+
postReadStat.nlink !== srcStat.nlink) {
|
|
1032
|
+
throw new ReleaseError(
|
|
1033
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1034
|
+
`TOCTOU detected: source changed during read for "${mapping.from}"`,
|
|
1035
|
+
{ from: mapping.from },
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
if (!postReadStat.isFile()) {
|
|
1039
|
+
throw new ReleaseError(
|
|
1040
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1041
|
+
`TOCTOU detected: source type changed during read for "${mapping.from}"`,
|
|
1042
|
+
{ from: mapping.from },
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
if ((postReadStat.mode & 0o7777) !== (srcStat.mode & 0o7777)) {
|
|
1046
|
+
throw new ReleaseError(
|
|
1047
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1048
|
+
`source mode changed during read for "${mapping.from}"`,
|
|
1049
|
+
{ from: mapping.from },
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (postReadStat.size !== srcStat.size) {
|
|
1053
|
+
throw new ReleaseError(
|
|
1054
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1055
|
+
`source size changed during read for "${mapping.from}"`,
|
|
1056
|
+
{ from: mapping.from },
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
if (postReadStat.mtimeMs !== srcStat.mtimeMs ||
|
|
1060
|
+
postReadStat.ctimeMs !== srcStat.ctimeMs) {
|
|
1061
|
+
throw new ReleaseError(
|
|
1062
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1063
|
+
`source timestamps changed during read for "${mapping.from}"`,
|
|
1064
|
+
{ from: mapping.from },
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
if (err instanceof ReleaseError) throw err;
|
|
1069
|
+
const code = err.code ?? 'UNKNOWN';
|
|
1070
|
+
if (code === 'ENOENT') {
|
|
1071
|
+
// Required source disappeared between preflight and open
|
|
1072
|
+
const isRequired = (unit.requiredPublicFiles ?? []).includes(mapping.to);
|
|
1073
|
+
throw new ReleaseError(
|
|
1074
|
+
isRequired ? PUBLIC_FILE_MISSING : CONFIG_INVALID,
|
|
1075
|
+
`source file not found during read: "${mapping.from}"`,
|
|
1076
|
+
{ from: mapping.from, sourceRelative: mapping.sourceRelative, cause: code },
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
throw new ReleaseError(
|
|
1080
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1081
|
+
`cannot read source file: "${mapping.from}"`,
|
|
1082
|
+
{ from: mapping.from, cause: code },
|
|
1083
|
+
);
|
|
1084
|
+
} finally {
|
|
1085
|
+
if (srcHandle) await srcHandle.close().catch(() => {});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// --- Re-verify output root identity before destination operations ---
|
|
1089
|
+
// Detects root swap (rename + symlink) between mappings.
|
|
1090
|
+
try {
|
|
1091
|
+
const currentRootReal = await fs.realpath(effectiveOutputDir, { stage: 'root-verify' });
|
|
1092
|
+
const currentRootStat = await fs.lstat(effectiveOutputDir, { stage: 'root-verify' });
|
|
1093
|
+
if (currentRootStat.isSymbolicLink() ||
|
|
1094
|
+
!currentRootStat.isDirectory() ||
|
|
1095
|
+
(currentRootStat.dev !== rootIdentity.dev) ||
|
|
1096
|
+
(currentRootStat.ino !== rootIdentity.ino) ||
|
|
1097
|
+
(currentRootReal !== rootIdentity.realPath)) {
|
|
1098
|
+
throw new ReleaseError(
|
|
1099
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1100
|
+
`output root identity changed before destination write for "${mapping.to}"`,
|
|
1101
|
+
{ to: mapping.to, outputDir: effectiveOutputDir },
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
if (err instanceof ReleaseError) throw err;
|
|
1106
|
+
throw new ReleaseError(
|
|
1107
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1108
|
+
`cannot verify output root identity: ${err.message}`,
|
|
1109
|
+
{ to: mapping.to, outputDir: effectiveOutputDir, cause: err.code },
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// --- Check existing destination ancestors BEFORE mkdir ---
|
|
1114
|
+
// mkdir({recursive: true}) follows symlinks in existing parent dirs.
|
|
1115
|
+
// If a previous _afterCopy injected a symlink, mkdir would create the
|
|
1116
|
+
// new directory INSIDE the symlink target before we could detect it.
|
|
1117
|
+
// Walking existing ancestors with lstat (no follow) BEFORE mkdir
|
|
1118
|
+
// prevents this: any symlink in the chain is detected and rejected
|
|
1119
|
+
// before mkdir can follow it.
|
|
1120
|
+
const destDir = dirname(destPath);
|
|
1121
|
+
const relDest = relative(realOutputRoot, destDir);
|
|
1122
|
+
const destSegments = relDest.split(pathSep).filter(Boolean);
|
|
1123
|
+
let accumulatedPath = realOutputRoot;
|
|
1124
|
+
for (const seg of destSegments) {
|
|
1125
|
+
accumulatedPath = resolve(accumulatedPath, seg);
|
|
1126
|
+
try {
|
|
1127
|
+
const segStat = await fs.lstat(accumulatedPath, { stage: 'dest-ancestor-pre-mkdir' });
|
|
1128
|
+
if (segStat.isSymbolicLink()) {
|
|
1129
|
+
throw new ReleaseError(
|
|
1130
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1131
|
+
`destination ancestor is a symlink: "${accumulatedPath}"`,
|
|
1132
|
+
{ to: mapping.to, symlinkAncestor: accumulatedPath, realOutputRoot },
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
if (err instanceof ReleaseError) throw err;
|
|
1137
|
+
if (err.code === 'ENOENT') break; // remaining will be created fresh
|
|
1138
|
+
throw new ReleaseError(
|
|
1139
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1140
|
+
`cannot inspect destination ancestor: "${accumulatedPath}": ${err.message}`,
|
|
1141
|
+
{ to: mapping.to, ancestor: accumulatedPath, cause: err.code },
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// --- Create subdirectory with mode 0700 ---
|
|
1147
|
+
try {
|
|
1148
|
+
await fs.mkdir(destDir, { recursive: true, mode: 0o700 }, { stage: 'dest-dir-mkdir' });
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
throw new ReleaseError(
|
|
1151
|
+
CONFIG_INVALID,
|
|
1152
|
+
`cannot create destination directory: ${err.message}`,
|
|
1153
|
+
{ to: mapping.to, destDir, cause: err.code },
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// --- Post-mkdir: re-verify root identity + full ancestor walk ---
|
|
1158
|
+
try {
|
|
1159
|
+
const postMkdirRootReal = await fs.realpath(effectiveOutputDir, { stage: 'post-mkdir-root' });
|
|
1160
|
+
const postMkdirRootStat = await fs.lstat(effectiveOutputDir, { stage: 'post-mkdir-root' });
|
|
1161
|
+
if (postMkdirRootStat.isSymbolicLink() ||
|
|
1162
|
+
!postMkdirRootStat.isDirectory() ||
|
|
1163
|
+
(postMkdirRootStat.dev !== rootIdentity.dev) ||
|
|
1164
|
+
(postMkdirRootStat.ino !== rootIdentity.ino) ||
|
|
1165
|
+
(postMkdirRootReal !== rootIdentity.realPath)) {
|
|
1166
|
+
throw new ReleaseError(
|
|
1167
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1168
|
+
`output root identity changed after mkdir for "${mapping.to}"`,
|
|
1169
|
+
{ to: mapping.to, outputDir: effectiveOutputDir },
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
if (err instanceof ReleaseError) throw err;
|
|
1174
|
+
throw new ReleaseError(
|
|
1175
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1176
|
+
`cannot verify output root identity after mkdir: ${err.message}`,
|
|
1177
|
+
{ to: mapping.to, outputDir: effectiveOutputDir, cause: err.code },
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
await assertDestAncestorsNoFollow(realOutputRoot, destPath, fs);
|
|
1181
|
+
|
|
1182
|
+
// --- Copy to destination via exclusive/no-follow handle ---
|
|
1183
|
+
let destHandle;
|
|
1184
|
+
try {
|
|
1185
|
+
// O_EXCL ensures we don't overwrite; O_NOFOLLOW prevents symlink attack.
|
|
1186
|
+
destHandle = await fs.open(destPath, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, srcMode, { stage: 'dest-open' });
|
|
1187
|
+
|
|
1188
|
+
// --- Post-open destination containment (defense in depth) ---
|
|
1189
|
+
// Verify the opened file's realpath is inside the fixed output root.
|
|
1190
|
+
// This catches races between assertDestAncestorsNoFollow and open.
|
|
1191
|
+
// Note: we check BEFORE write to fail closed early.
|
|
1192
|
+
// Non-ENOENT FS errors on realpath are wrapped as SNAPSHOT_FIDELITY_FAILED.
|
|
1193
|
+
let realDestAfterOpen;
|
|
1194
|
+
try {
|
|
1195
|
+
realDestAfterOpen = await fs.realpath(destPath, { stage: 'dest-post-open-realpath' });
|
|
1196
|
+
} catch (rpErr) {
|
|
1197
|
+
throw new ReleaseError(
|
|
1198
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1199
|
+
`cannot resolve destination after open: "${destPath}"`,
|
|
1200
|
+
{ from: mapping.from, to: mapping.to, cause: rpErr.code ?? 'UNKNOWN' },
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
if (!isContained(realOutputRoot, realDestAfterOpen)) {
|
|
1204
|
+
throw new ReleaseError(
|
|
1205
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1206
|
+
`destination ancestor is a symlink: "${destPath}"`,
|
|
1207
|
+
{ from: mapping.from, to: mapping.to, realDest: realDestAfterOpen, realOutputRoot },
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
await destHandle.write(content);
|
|
1212
|
+
await destHandle.chmod(srcMode);
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
if (err instanceof ReleaseError) throw err;
|
|
1215
|
+
const code = err.code ?? 'UNKNOWN';
|
|
1216
|
+
throw new ReleaseError(
|
|
1217
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1218
|
+
`cannot write destination file: "${mapping.to}"`,
|
|
1219
|
+
{ from: mapping.from, to: mapping.to, cause: code },
|
|
1220
|
+
);
|
|
1221
|
+
} finally {
|
|
1222
|
+
if (destHandle) await destHandle.close().catch(() => {});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Internal test hook: allows tests to corrupt destination after copy
|
|
1226
|
+
await _afterCopy({ destPath, srcPath });
|
|
1227
|
+
|
|
1228
|
+
// --- Post-copy fidelity verification ---
|
|
1229
|
+
// Use lstat (not stat) to detect if destination was replaced with symlink.
|
|
1230
|
+
// Then re-open with O_NOFOLLOW and verify dev/ino match.
|
|
1231
|
+
let destLstat;
|
|
1232
|
+
try {
|
|
1233
|
+
destLstat = await fs.lstat(destPath, { stage: 'post-copy-dest' });
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
throw new ReleaseError(
|
|
1236
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1237
|
+
`cannot lstat destination after copy: "${mapping.to}"`,
|
|
1238
|
+
{ from: mapping.from, to: mapping.to, cause: err.code },
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (destLstat.isSymbolicLink()) {
|
|
1243
|
+
throw new ReleaseError(
|
|
1244
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1245
|
+
`destination is a symlink after copy: "${mapping.to}"`,
|
|
1246
|
+
{ from: mapping.from, to: mapping.to },
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (!destLstat.isFile()) {
|
|
1251
|
+
throw new ReleaseError(
|
|
1252
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1253
|
+
`destination is not a regular file: "${mapping.to}"`,
|
|
1254
|
+
{ from: mapping.from, to: mapping.to },
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (destLstat.nlink > 1) {
|
|
1259
|
+
throw new ReleaseError(
|
|
1260
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1261
|
+
`destination has multiple hardlinks: "${mapping.to}" (nlink=${destLstat.nlink})`,
|
|
1262
|
+
{ from: mapping.from, to: mapping.to, nlink: destLstat.nlink },
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const destMode = destLstat.mode & 0o7777;
|
|
1267
|
+
if (srcMode !== destMode) {
|
|
1268
|
+
throw new ReleaseError(
|
|
1269
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1270
|
+
`mode mismatch for "${mapping.from}": source=0o${srcMode.toString(8)}, dest=0o${destMode.toString(8)}`,
|
|
1271
|
+
{ from: mapping.from, to: mapping.to, sourceMode: srcMode, destMode },
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// --- Early size gate: lstat size vs content.length ---
|
|
1276
|
+
// If the destination lstat size does not match the content we intend to
|
|
1277
|
+
// write, the file was tampered with between write and lstat. Fail early
|
|
1278
|
+
// without opening/reading the destination a second time.
|
|
1279
|
+
if (destLstat.size !== content.length) {
|
|
1280
|
+
throw new ReleaseError(
|
|
1281
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1282
|
+
`size mismatch for "${mapping.from}": dest lstat=${destLstat.size}, content=${content.length}`,
|
|
1283
|
+
{ from: mapping.from, to: mapping.to, destSize: destLstat.size, contentSize: content.length },
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// --- Fixed real output root containment ---
|
|
1288
|
+
// Verify destination is inside the real output root.
|
|
1289
|
+
// Non-ENOENT FS errors on realpath are wrapped as SNAPSHOT_FIDELITY_FAILED.
|
|
1290
|
+
let realDestPath;
|
|
1291
|
+
try {
|
|
1292
|
+
realDestPath = await fs.realpath(destPath, { stage: 'post-copy-dest-realpath' });
|
|
1293
|
+
} catch (rpErr) {
|
|
1294
|
+
throw new ReleaseError(
|
|
1295
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1296
|
+
`cannot resolve destination after copy: "${mapping.to}"`,
|
|
1297
|
+
{ from: mapping.from, to: mapping.to, cause: rpErr.code ?? 'UNKNOWN' },
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (!isContained(realOutputRoot, realDestPath)) {
|
|
1301
|
+
throw new ReleaseError(
|
|
1302
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1303
|
+
`destination escapes output root: "${mapping.to}"`,
|
|
1304
|
+
{ from: mapping.from, to: mapping.to, realDest: realDestPath },
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Read back destination via O_NOFOLLOW handle and verify content + all stat fields
|
|
1309
|
+
let destReadHandle;
|
|
1310
|
+
let destContent;
|
|
1311
|
+
try {
|
|
1312
|
+
destReadHandle = await fs.open(destPath, O_RDONLY | O_NOFOLLOW, undefined, { stage: 'dest-readback-open' });
|
|
1313
|
+
const destHandleStat = await destReadHandle.stat();
|
|
1314
|
+
// Pre-read: dev/ino/type/nlink/size/mode must match lstat
|
|
1315
|
+
if (destHandleStat.dev !== destLstat.dev || destHandleStat.ino !== destLstat.ino) {
|
|
1316
|
+
throw new ReleaseError(
|
|
1317
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1318
|
+
`TOCTOU detected: destination handle mismatch for "${mapping.to}"`,
|
|
1319
|
+
{ from: mapping.from, to: mapping.to },
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
if (!destHandleStat.isFile()) {
|
|
1323
|
+
throw new ReleaseError(
|
|
1324
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1325
|
+
`TOCTOU detected: destination type changed for "${mapping.to}"`,
|
|
1326
|
+
{ from: mapping.from, to: mapping.to },
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
if (destHandleStat.nlink !== destLstat.nlink) {
|
|
1330
|
+
throw new ReleaseError(
|
|
1331
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1332
|
+
`TOCTOU detected: destination nlink changed for "${mapping.to}"`,
|
|
1333
|
+
{ from: mapping.from, to: mapping.to },
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
if (destHandleStat.size !== content.length) {
|
|
1337
|
+
throw new ReleaseError(
|
|
1338
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1339
|
+
`TOCTOU detected: destination size changed for "${mapping.to}"`,
|
|
1340
|
+
{ from: mapping.from, to: mapping.to, handleSize: destHandleStat.size, contentSize: content.length },
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
if ((destHandleStat.mode & 0o7777) !== destMode) {
|
|
1344
|
+
throw new ReleaseError(
|
|
1345
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1346
|
+
`TOCTOU detected: destination mode changed for "${mapping.to}"`,
|
|
1347
|
+
{ from: mapping.from, to: mapping.to },
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
destContent = await destReadHandle.readFile();
|
|
1351
|
+
|
|
1352
|
+
// Stage hook: after destination readFile(), before post-read stat()
|
|
1353
|
+
await _afterDestRead({ destPath });
|
|
1354
|
+
|
|
1355
|
+
// Post-read stat: verify ALL fields haven't changed during readback.
|
|
1356
|
+
// Includes mtimeMs/ctimeMs to catch same-length overwrites.
|
|
1357
|
+
const postReadDestStat = await destReadHandle.stat();
|
|
1358
|
+
if (postReadDestStat.dev !== destLstat.dev ||
|
|
1359
|
+
postReadDestStat.ino !== destLstat.ino ||
|
|
1360
|
+
postReadDestStat.nlink !== destLstat.nlink ||
|
|
1361
|
+
postReadDestStat.size !== content.length ||
|
|
1362
|
+
(postReadDestStat.mode & 0o7777) !== destMode) {
|
|
1363
|
+
throw new ReleaseError(
|
|
1364
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1365
|
+
`destination changed during readback for "${mapping.to}"`,
|
|
1366
|
+
{ from: mapping.from, to: mapping.to },
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
if (!postReadDestStat.isFile()) {
|
|
1370
|
+
throw new ReleaseError(
|
|
1371
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1372
|
+
`destination type changed during readback for "${mapping.to}"`,
|
|
1373
|
+
{ from: mapping.from, to: mapping.to },
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
if (postReadDestStat.mtimeMs !== destLstat.mtimeMs ||
|
|
1377
|
+
postReadDestStat.ctimeMs !== destLstat.ctimeMs) {
|
|
1378
|
+
throw new ReleaseError(
|
|
1379
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1380
|
+
`destination timestamps changed during readback for "${mapping.to}"`,
|
|
1381
|
+
{ from: mapping.from, to: mapping.to },
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
if (err instanceof ReleaseError) throw err;
|
|
1386
|
+
throw new ReleaseError(
|
|
1387
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1388
|
+
`cannot read destination after copy: "${mapping.to}"`,
|
|
1389
|
+
{ from: mapping.from, to: mapping.to, cause: err.code },
|
|
1390
|
+
);
|
|
1391
|
+
} finally {
|
|
1392
|
+
if (destReadHandle) await destReadHandle.close().catch(() => {});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Compare actual bytes (not just size)
|
|
1396
|
+
if (!content.equals(destContent)) {
|
|
1397
|
+
throw new ReleaseError(
|
|
1398
|
+
SNAPSHOT_FIDELITY_FAILED,
|
|
1399
|
+
`content mismatch for "${mapping.from}": destination bytes differ from source`,
|
|
1400
|
+
{ from: mapping.from, to: mapping.to, sourceBytes: content.length, destBytes: destContent.length },
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const fileHash = createHash('sha256').update(content).digest('hex');
|
|
1405
|
+
totalSize += content.length;
|
|
1406
|
+
|
|
1407
|
+
entries.push({
|
|
1408
|
+
path: mapping.to,
|
|
1409
|
+
from: mapping.from,
|
|
1410
|
+
sourceRelative: mapping.sourceRelative,
|
|
1411
|
+
bytes: content.length,
|
|
1412
|
+
hash: fileHash,
|
|
1413
|
+
mode: srcStat.mode,
|
|
1414
|
+
type: 'file',
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Final required-public-files safety net (early check above handles
|
|
1419
|
+
// missing source files; this catches any other gap).
|
|
1420
|
+
const finalRequired = unit.requiredPublicFiles ?? [];
|
|
1421
|
+
const entryPaths = new Set(entries.map((e) => e.path));
|
|
1422
|
+
const finalMissing = finalRequired.filter((r) => !entryPaths.has(r));
|
|
1423
|
+
if (finalMissing.length > 0) {
|
|
1424
|
+
throw new ReleaseError(
|
|
1425
|
+
PUBLIC_FILE_MISSING,
|
|
1426
|
+
`missing required public file(s): ${finalMissing.join(', ')}`,
|
|
1427
|
+
{ missing: finalMissing },
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1432
|
+
|
|
1433
|
+
// Compute canonical manifest digest from sorted entries.
|
|
1434
|
+
// This ensures that mapping order changes do NOT change the digest.
|
|
1435
|
+
const manifestDigest = createHash('sha256')
|
|
1436
|
+
.update(JSON.stringify(entries.map((e) => ({
|
|
1437
|
+
path: e.path,
|
|
1438
|
+
type: e.type,
|
|
1439
|
+
mode: e.mode,
|
|
1440
|
+
size: e.bytes,
|
|
1441
|
+
contentDigest: e.hash,
|
|
1442
|
+
}))))
|
|
1443
|
+
.digest('hex');
|
|
1444
|
+
|
|
1445
|
+
// Build sorted files list for backward compatibility
|
|
1446
|
+
const files = entries.map((e) => e.path).sort();
|
|
1447
|
+
|
|
1448
|
+
return Object.freeze({
|
|
1449
|
+
entries: Object.freeze(entries),
|
|
1450
|
+
files,
|
|
1451
|
+
totalSize,
|
|
1452
|
+
fileCount: entries.length,
|
|
1453
|
+
contentHash: manifestDigest,
|
|
1454
|
+
snapshotDigest: manifestDigest,
|
|
1455
|
+
sourceRoot,
|
|
1456
|
+
source: unit.source,
|
|
1457
|
+
outputDir: effectiveOutputDir,
|
|
1458
|
+
});
|
|
1459
|
+
}
|