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,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal module: stable addon reader and backend creator.
|
|
3
|
+
*
|
|
4
|
+
* NOT part of the public API. Tests import this module directly to exercise
|
|
5
|
+
* fake-addon scenarios without polluting the public `loadSafeFs` entry point.
|
|
6
|
+
*
|
|
7
|
+
* @module artifacts/safe-fs-backend-internal
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import { realpath } from 'node:fs/promises';
|
|
14
|
+
import { readFileSync, realpathSync, lstatSync } from 'node:fs';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ReleaseError,
|
|
20
|
+
SAFE_WRITE_UNAVAILABLE,
|
|
21
|
+
PATH_UNSAFE,
|
|
22
|
+
} from '../core/errors.mjs';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const __dirname = dirname(__filename);
|
|
30
|
+
|
|
31
|
+
const DEFAULT_ADDON_REL = '../../native/safe-write/build/Release/safe_write.node';
|
|
32
|
+
const PREBUILDS_REL = '../../native/safe-write/prebuilds.json';
|
|
33
|
+
|
|
34
|
+
const EXPECTED_EXPORTS = Object.freeze([
|
|
35
|
+
'openRoot', 'openDir', 'readEntry', 'readFile',
|
|
36
|
+
'createTemp', 'rename', 'abortTemp', 'mkdir', 'rmdir', 'unlink', 'chmod', 'fsync', 'close',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Error sanitization
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function sanitizeError(err) {
|
|
44
|
+
if (typeof err?.message === 'string') {
|
|
45
|
+
let msg = err.message.replace(/\/[^\s]*:\s*/g, '');
|
|
46
|
+
msg = msg.replace(/\/\S+/g, '<redacted>');
|
|
47
|
+
return msg;
|
|
48
|
+
}
|
|
49
|
+
return 'native addon error';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Dev addon validation: lexical lstat + physical containment
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate dev addon: lexical lstat + realpath containment.
|
|
58
|
+
* Returns the verified physical path on success, or null on failure.
|
|
59
|
+
*/
|
|
60
|
+
function isWithinPackageBuildDir(addonPath) {
|
|
61
|
+
const buildDir = resolve(__dirname, '../../native/safe-write/build/');
|
|
62
|
+
const resolved = resolve(addonPath);
|
|
63
|
+
if (!resolved.startsWith(buildDir + '/') && resolved !== buildDir) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Lexical lstat candidate: must be non-symlink regular, nlink==1
|
|
68
|
+
let lstatResult;
|
|
69
|
+
try {
|
|
70
|
+
lstatResult = lstatSync(addonPath, { bigint: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (lstatResult.isSymbolicLink()) return null;
|
|
75
|
+
if (!lstatResult.isFile()) return null;
|
|
76
|
+
if (lstatResult.nlink !== 1n) return null;
|
|
77
|
+
|
|
78
|
+
// Physical containment: realpath both buildDir and addonPath
|
|
79
|
+
try {
|
|
80
|
+
const physicalBuildDir = realpathSync(buildDir);
|
|
81
|
+
const physicalAddon = realpathSync(addonPath);
|
|
82
|
+
if (!(physicalAddon.startsWith(physicalBuildDir + '/') || physicalAddon === physicalBuildDir)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return physicalAddon;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Manifest entry validation
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function validateManifestEntry(entry, key) {
|
|
96
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
97
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, `invalid manifest entry for ${key}`);
|
|
98
|
+
}
|
|
99
|
+
if (typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
100
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, `manifest entry missing path for ${key}`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(entry.sha256)) {
|
|
103
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, `manifest entry missing or invalid sha256 for ${key}`);
|
|
104
|
+
}
|
|
105
|
+
if (typeof entry.exports !== 'object' || !Array.isArray(entry.exports)) {
|
|
106
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, `manifest entry missing exports for ${key}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const resolved = resolve(__dirname, '../../native/safe-write', entry.path);
|
|
110
|
+
const baseDir = resolve(__dirname, '../../native/safe-write/');
|
|
111
|
+
if (!resolved.startsWith(baseDir + '/') && resolved !== baseDir) {
|
|
112
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, `manifest path escapes base directory for ${key}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sortedActual = [...entry.exports].sort();
|
|
116
|
+
const sortedExpected = [...EXPECTED_EXPORTS].sort();
|
|
117
|
+
if (sortedActual.length !== sortedExpected.length ||
|
|
118
|
+
sortedActual.some((v, i) => v !== sortedExpected[i])) {
|
|
119
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, `manifest exports mismatch for ${key}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// requireAddon: createRequire wrapper
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function requireAddon(addonPath) {
|
|
128
|
+
try {
|
|
129
|
+
const require = createRequire(import.meta.url);
|
|
130
|
+
return require(addonPath);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, sanitizeError(err));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// readStableAddon: pure function called ONLY by the main loader with real fs ops.
|
|
138
|
+
// Accepts optional fs hooks for testing identity-swap TOCTOU detection.
|
|
139
|
+
//
|
|
140
|
+
// Production: called with no hooks → uses real readFileSync/lstatSync.
|
|
141
|
+
// Test: inject { _hooks: { readFileSync, lstatSync } } to exercise TOCTOU.
|
|
142
|
+
//
|
|
143
|
+
// Step 1: lstat LEXICAL candidate ({bigint:true}) → reject symlink / non-regular / nlink!=1
|
|
144
|
+
// Step 2: realpath → compare with physical native base for containment
|
|
145
|
+
// Step 3: stable read (pre-stat → read → hash → post-stat → identity compare)
|
|
146
|
+
// Step 4: require addon from verified physical path (NO hooks)
|
|
147
|
+
// Step 5: post-require identity verification (lstat again, compare with pre-require)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export function readStableAddon(options = {}) {
|
|
151
|
+
const hooks = options._hooks || {};
|
|
152
|
+
|
|
153
|
+
const prebuildsPath = resolve(__dirname, PREBUILDS_REL);
|
|
154
|
+
let manifest;
|
|
155
|
+
try {
|
|
156
|
+
manifest = JSON.parse(readFileSync(prebuildsPath, 'utf8'));
|
|
157
|
+
} catch {
|
|
158
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilds manifest not readable');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!manifest.prebuilds || typeof manifest.prebuilds !== 'object') {
|
|
162
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilds manifest has invalid structure');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const key = `${process.platform}-${process.arch}`;
|
|
166
|
+
const entry = manifest.prebuilds[key];
|
|
167
|
+
if (!entry) {
|
|
168
|
+
throw new ReleaseError(
|
|
169
|
+
SAFE_WRITE_UNAVAILABLE,
|
|
170
|
+
`no prebuilt addon for ${key}; build from source with native:build`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
validateManifestEntry(entry, key);
|
|
175
|
+
|
|
176
|
+
// Resolve lexical candidate path
|
|
177
|
+
const addonFile = resolve(__dirname, '../../native/safe-write', entry.path);
|
|
178
|
+
|
|
179
|
+
// Step 1: lstat LEXICAL path FIRST — reject symlink, non-regular, nlink!=1
|
|
180
|
+
// Must lstat the original path to detect symlinks; lstat on a resolved path
|
|
181
|
+
// would return the target file's stats and miss the symlink.
|
|
182
|
+
// lstat must come before realpath to reject symlinks before physical resolution.
|
|
183
|
+
const lstatFn = hooks.lstatSync || lstatSync;
|
|
184
|
+
const lstatOpts = { bigint: true };
|
|
185
|
+
let preStat;
|
|
186
|
+
try {
|
|
187
|
+
preStat = lstatFn(addonFile, lstatOpts);
|
|
188
|
+
if (preStat.isSymbolicLink()) {
|
|
189
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon must not be a symlink');
|
|
190
|
+
}
|
|
191
|
+
if (!preStat.isFile()) {
|
|
192
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon is not a regular file');
|
|
193
|
+
}
|
|
194
|
+
if (preStat.nlink !== 1n) {
|
|
195
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon has invalid nlink count');
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e instanceof ReleaseError) throw e;
|
|
199
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon not accessible');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Step 2: realpath → physical containment
|
|
203
|
+
let physicalAddonFile;
|
|
204
|
+
try {
|
|
205
|
+
physicalAddonFile = realpathSync(addonFile);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon not accessible');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const nativeBaseDir = realpathSync(resolve(__dirname, '../../native/safe-write/'));
|
|
211
|
+
if (!physicalAddonFile.startsWith(nativeBaseDir + '/') && physicalAddonFile !== nativeBaseDir) {
|
|
212
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon physical path escapes base directory');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Step 3: hash the file bytes
|
|
216
|
+
const readFn = hooks.readFileSync || readFileSync;
|
|
217
|
+
let data;
|
|
218
|
+
try {
|
|
219
|
+
data = readFn(physicalAddonFile);
|
|
220
|
+
} catch {
|
|
221
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon not readable');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const hash = createHash('sha256').update(data).digest('hex');
|
|
225
|
+
if (hash !== entry.sha256) {
|
|
226
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon SHA-256 mismatch');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Step 4: post-read identity verification (TOCTOU detection)
|
|
230
|
+
// Must lstat the same lexical path as preStat for consistent comparison.
|
|
231
|
+
let postStat;
|
|
232
|
+
try {
|
|
233
|
+
postStat = lstatFn(addonFile, lstatOpts);
|
|
234
|
+
} catch {
|
|
235
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon identity check failed');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Compare full stable identity fields (bigint)
|
|
239
|
+
if (preStat.dev !== postStat.dev ||
|
|
240
|
+
preStat.ino !== postStat.ino ||
|
|
241
|
+
preStat.mode !== postStat.mode ||
|
|
242
|
+
preStat.nlink !== postStat.nlink ||
|
|
243
|
+
preStat.size !== postStat.size ||
|
|
244
|
+
preStat.mtimeNs !== postStat.mtimeNs ||
|
|
245
|
+
preStat.ctimeNs !== postStat.ctimeNs) {
|
|
246
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon identity changed during read (TOCTOU detected)');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Step 5: require addon from verified physical path (NO hooks)
|
|
250
|
+
const addon = requireAddon(physicalAddonFile);
|
|
251
|
+
|
|
252
|
+
// Step 6: post-require identity verification
|
|
253
|
+
// Must lstat the same lexical path as preStat for consistent comparison.
|
|
254
|
+
let postRequireStat;
|
|
255
|
+
try {
|
|
256
|
+
postRequireStat = lstatFn(addonFile, lstatOpts);
|
|
257
|
+
} catch {
|
|
258
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon identity check failed after require');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (preStat.dev !== postRequireStat.dev ||
|
|
262
|
+
preStat.ino !== postRequireStat.ino ||
|
|
263
|
+
preStat.mode !== postRequireStat.mode ||
|
|
264
|
+
preStat.nlink !== postRequireStat.nlink ||
|
|
265
|
+
preStat.size !== postRequireStat.size ||
|
|
266
|
+
preStat.mtimeNs !== postRequireStat.mtimeNs ||
|
|
267
|
+
preStat.ctimeNs !== postRequireStat.ctimeNs) {
|
|
268
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'prebuilt addon identity changed during require (TOCTOU detected)');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Verify exports match manifest
|
|
272
|
+
const addonExports = Object.keys(addon).sort();
|
|
273
|
+
const expectedExports = [...EXPECTED_EXPORTS].sort();
|
|
274
|
+
if (addonExports.length !== expectedExports.length ||
|
|
275
|
+
addonExports.some((v, i) => v !== expectedExports[i])) {
|
|
276
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'addon exports do not match manifest');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return addon;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// loadNativeAddon: production addon loader (dev + prebuilt)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
export function loadNativeAddon(addonPath) {
|
|
287
|
+
// 1. Explicit path — only in dev mode, must be within build dir
|
|
288
|
+
if (addonPath) {
|
|
289
|
+
if (process.env.RELEASE_SKILL_NATIVE_DEV !== '1') {
|
|
290
|
+
throw new ReleaseError(
|
|
291
|
+
SAFE_WRITE_UNAVAILABLE,
|
|
292
|
+
'explicit addonPath is only allowed in development mode',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const physicalPath = isWithinPackageBuildDir(addonPath);
|
|
296
|
+
if (!physicalPath) {
|
|
297
|
+
throw new ReleaseError(
|
|
298
|
+
SAFE_WRITE_UNAVAILABLE,
|
|
299
|
+
'addonPath is outside the package build directory',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
// Require from verified physical path to prevent TOCTOU swap
|
|
303
|
+
return requireAddon(physicalPath);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 2. Development mode
|
|
307
|
+
if (process.env.RELEASE_SKILL_NATIVE_DEV === '1') {
|
|
308
|
+
const devPath = resolve(__dirname, DEFAULT_ADDON_REL);
|
|
309
|
+
const physicalPath = isWithinPackageBuildDir(devPath);
|
|
310
|
+
if (!physicalPath) {
|
|
311
|
+
throw new ReleaseError(SAFE_WRITE_UNAVAILABLE, 'dev addon path is outside build directory');
|
|
312
|
+
}
|
|
313
|
+
// Require from verified physical path
|
|
314
|
+
return requireAddon(physicalPath);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 3. Prebuilt addon from manifest
|
|
318
|
+
return readStableAddon();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// wrapHandle: wrap native directory handle (addon-agnostic)
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function wrapHandle(addon, nativeHandle, nativeTokenMap, nativeIdentityMap) {
|
|
326
|
+
const handle = {
|
|
327
|
+
async openDir(name) {
|
|
328
|
+
try {
|
|
329
|
+
const nativeChild = addon.openDir(nativeHandle, name);
|
|
330
|
+
return wrapHandle(addon, nativeChild, nativeTokenMap, nativeIdentityMap);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
async readEntry(name) {
|
|
337
|
+
try {
|
|
338
|
+
return addon.readEntry(nativeHandle, name);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async readFile(name) {
|
|
345
|
+
try {
|
|
346
|
+
const result = addon.readFile(nativeHandle, name);
|
|
347
|
+
if (result === null) return null;
|
|
348
|
+
const publicResult = Object.freeze({
|
|
349
|
+
bytes: result.bytes,
|
|
350
|
+
size: Number(result.size),
|
|
351
|
+
mode: Number(result.mode),
|
|
352
|
+
dev: Number(result.dev),
|
|
353
|
+
ino: Number(result.ino),
|
|
354
|
+
nlink: Number(result.nlink),
|
|
355
|
+
mtimeNs: Number(result.mtimeNs),
|
|
356
|
+
ctimeNs: Number(result.ctimeNs),
|
|
357
|
+
});
|
|
358
|
+
nativeIdentityMap.set(publicResult, result);
|
|
359
|
+
return publicResult;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async createTemp(name, mode, data) {
|
|
366
|
+
try {
|
|
367
|
+
const nativeResult = addon.createTemp(nativeHandle, name, mode, data);
|
|
368
|
+
const publicToken = Object.freeze({ name: nativeResult.tempName });
|
|
369
|
+
nativeTokenMap.set(publicToken, nativeResult);
|
|
370
|
+
return publicToken;
|
|
371
|
+
} catch (err) {
|
|
372
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
async rename(publicToken, to, expectedIdentity) {
|
|
377
|
+
try {
|
|
378
|
+
const nativeToken = nativeTokenMap.get(publicToken);
|
|
379
|
+
if (!nativeToken) {
|
|
380
|
+
throw new ReleaseError(PATH_UNSAFE, 'rename: invalid or forged token');
|
|
381
|
+
}
|
|
382
|
+
if (expectedIdentity === undefined || expectedIdentity === null) {
|
|
383
|
+
addon.rename(nativeHandle, nativeToken, to);
|
|
384
|
+
} else {
|
|
385
|
+
const nativeIdentity = nativeIdentityMap.get(expectedIdentity);
|
|
386
|
+
if (!nativeIdentity) {
|
|
387
|
+
throw new ReleaseError(PATH_UNSAFE, 'rename: invalid or forged expected identity');
|
|
388
|
+
}
|
|
389
|
+
addon.rename(nativeHandle, nativeToken, to, nativeIdentity);
|
|
390
|
+
}
|
|
391
|
+
nativeTokenMap.delete(publicToken);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
async abortTemp(publicToken) {
|
|
398
|
+
try {
|
|
399
|
+
const nativeToken = nativeTokenMap.get(publicToken);
|
|
400
|
+
if (!nativeToken) {
|
|
401
|
+
throw new ReleaseError(PATH_UNSAFE, 'abortTemp: invalid or forged token');
|
|
402
|
+
}
|
|
403
|
+
const nativeResult = addon.abortTemp(nativeHandle, nativeToken);
|
|
404
|
+
nativeTokenMap.delete(publicToken);
|
|
405
|
+
// Build a new frozen object — never forward the native plain object directly.
|
|
406
|
+
return Object.freeze({
|
|
407
|
+
removed: !!nativeResult.removed,
|
|
408
|
+
reason: String(nativeResult.reason),
|
|
409
|
+
});
|
|
410
|
+
} catch (err) {
|
|
411
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async mkdir(name, mode) {
|
|
416
|
+
try {
|
|
417
|
+
addon.mkdir(nativeHandle, name, mode);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
async rmdir(name) {
|
|
424
|
+
try {
|
|
425
|
+
addon.rmdir(nativeHandle, name);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
async unlink(name) {
|
|
432
|
+
try {
|
|
433
|
+
addon.unlink(nativeHandle, name);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
async chmod(name, mode) {
|
|
440
|
+
try {
|
|
441
|
+
addon.chmod(nativeHandle, name, mode);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
async fsync() {
|
|
448
|
+
try {
|
|
449
|
+
addon.fsync(nativeHandle);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
async close() {
|
|
456
|
+
try {
|
|
457
|
+
addon.close(nativeHandle);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
return Object.freeze(handle);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// createBackend: build backend from addon (addon-agnostic)
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
export function createBackend(addon) {
|
|
472
|
+
// WeakMap: frozen public token -> native token object (A3)
|
|
473
|
+
const nativeTokenMap = new WeakMap();
|
|
474
|
+
// WeakMap: frozen readFile result -> addon-produced identity object. This
|
|
475
|
+
// makes replace authorization unforgeable at the JS boundary.
|
|
476
|
+
const nativeIdentityMap = new WeakMap();
|
|
477
|
+
|
|
478
|
+
const backend = Object.freeze({
|
|
479
|
+
async probe(root) {
|
|
480
|
+
const primitives = [];
|
|
481
|
+
let handle = null;
|
|
482
|
+
let closeAttempted = false;
|
|
483
|
+
try {
|
|
484
|
+
handle = await backend.openRoot(root);
|
|
485
|
+
|
|
486
|
+
const probeId = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
487
|
+
const probeFinal = `${probeId}-final.txt`;
|
|
488
|
+
|
|
489
|
+
const probeData = Buffer.from('probe-data-verify');
|
|
490
|
+
const tmpToken = await handle.createTemp(`${probeId}.txt`, 0o600, probeData);
|
|
491
|
+
primitives.push('createTemp');
|
|
492
|
+
|
|
493
|
+
await handle.rename(tmpToken, probeFinal);
|
|
494
|
+
primitives.push('rename');
|
|
495
|
+
|
|
496
|
+
await handle.readEntry(probeFinal);
|
|
497
|
+
primitives.push('readEntry');
|
|
498
|
+
|
|
499
|
+
const readResult = await handle.readFile(probeFinal);
|
|
500
|
+
if (!readResult) {
|
|
501
|
+
throw new Error('probe readFile returned null for committed file');
|
|
502
|
+
}
|
|
503
|
+
if (!Buffer.isBuffer(readResult.bytes) ||
|
|
504
|
+
!readResult.bytes.equals(probeData)) {
|
|
505
|
+
throw new Error('probe readFile bytes mismatch');
|
|
506
|
+
}
|
|
507
|
+
if (readResult.size !== probeData.length) {
|
|
508
|
+
throw new Error('probe readFile size mismatch');
|
|
509
|
+
}
|
|
510
|
+
primitives.push('readFile');
|
|
511
|
+
|
|
512
|
+
await handle.fsync();
|
|
513
|
+
primitives.push('fsync');
|
|
514
|
+
|
|
515
|
+
await handle.chmod(probeFinal, 0o644);
|
|
516
|
+
const postChmod = await handle.readFile(probeFinal);
|
|
517
|
+
if (!postChmod) {
|
|
518
|
+
throw new Error('probe readFile after chmod returned null');
|
|
519
|
+
}
|
|
520
|
+
if (postChmod.mode !== 0o644) {
|
|
521
|
+
throw new Error(`probe chmod mode mismatch: expected 0o644, got 0o${postChmod.mode.toString(8)}`);
|
|
522
|
+
}
|
|
523
|
+
primitives.push('chmod');
|
|
524
|
+
|
|
525
|
+
const abortToken = await handle.createTemp(`${probeId}-abort.txt`, 0o600, Buffer.from('abort'));
|
|
526
|
+
const abortResult = await handle.abortTemp(abortToken);
|
|
527
|
+
if (!abortResult || abortResult.removed !== true) {
|
|
528
|
+
throw new Error('probe abortTemp must return {removed: true}');
|
|
529
|
+
}
|
|
530
|
+
primitives.push('abortTemp');
|
|
531
|
+
|
|
532
|
+
await handle.unlink(probeFinal);
|
|
533
|
+
primitives.push('unlink');
|
|
534
|
+
|
|
535
|
+
// mkdir/rmdir: create 0700 subdirectory, verify with openDir, clean up
|
|
536
|
+
const probeSubdir = `${probeId}-subdir`;
|
|
537
|
+
await handle.mkdir(probeSubdir, 0o700);
|
|
538
|
+
primitives.push('mkdir');
|
|
539
|
+
|
|
540
|
+
const childHandle = await handle.openDir(probeSubdir);
|
|
541
|
+
const childEntry = await childHandle.readEntry('.');
|
|
542
|
+
if (!childEntry || childEntry.type !== 'directory') {
|
|
543
|
+
throw new Error('probe mkdir did not create a directory');
|
|
544
|
+
}
|
|
545
|
+
if ((childEntry.mode & 0o777) !== 0o700) {
|
|
546
|
+
throw new Error(`probe mkdir mode mismatch: expected 0700, got 0o${(childEntry.mode & 0o777).toString(8)}`);
|
|
547
|
+
}
|
|
548
|
+
await childHandle.close();
|
|
549
|
+
|
|
550
|
+
await handle.rmdir(probeSubdir);
|
|
551
|
+
primitives.push('rmdir');
|
|
552
|
+
|
|
553
|
+
closeAttempted = true;
|
|
554
|
+
await handle.close();
|
|
555
|
+
handle = null;
|
|
556
|
+
|
|
557
|
+
return Object.freeze({
|
|
558
|
+
supported: true,
|
|
559
|
+
platform: `${process.platform}-${process.arch}`,
|
|
560
|
+
primitives: Object.freeze(primitives),
|
|
561
|
+
});
|
|
562
|
+
} catch {
|
|
563
|
+
if (handle && !closeAttempted) {
|
|
564
|
+
closeAttempted = true;
|
|
565
|
+
try { await handle.close(); } catch { /* best-effort */ }
|
|
566
|
+
}
|
|
567
|
+
return Object.freeze({
|
|
568
|
+
supported: false,
|
|
569
|
+
platform: `${process.platform}-${process.arch}`,
|
|
570
|
+
primitives: Object.freeze(primitives),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
async openRoot(root) {
|
|
576
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
577
|
+
throw new ReleaseError(PATH_UNSAFE, 'openRoot requires a non-empty root path');
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
const nativeHandle = addon.openRoot(root);
|
|
581
|
+
return wrapHandle(addon, nativeHandle, nativeTokenMap, nativeIdentityMap);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
throw new ReleaseError(err.code || PATH_UNSAFE, sanitizeError(err));
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
async openEntry(rootOrPath, relativePath) {
|
|
588
|
+
if (typeof rootOrPath !== 'string' || rootOrPath.length === 0) {
|
|
589
|
+
throw new ReleaseError(PATH_UNSAFE, 'openEntry requires a root path');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (relativePath === undefined || relativePath === null) {
|
|
593
|
+
throw new ReleaseError(PATH_UNSAFE,
|
|
594
|
+
'openEntry requires (root, relativePath); single absolutePath is not supported');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0) {
|
|
598
|
+
throw new ReleaseError(PATH_UNSAFE, 'openEntry requires a non-empty relative path');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (relativePath.startsWith('/') || relativePath.includes('\\')) {
|
|
602
|
+
throw new ReleaseError(PATH_UNSAFE,
|
|
603
|
+
'openEntry: relative path must not be absolute or contain backslashes');
|
|
604
|
+
}
|
|
605
|
+
if (relativePath.includes('\0')) {
|
|
606
|
+
throw new ReleaseError(PATH_UNSAFE, 'openEntry: relative path must not contain NUL');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const rawSegments = relativePath.split('/');
|
|
610
|
+
for (const seg of rawSegments) {
|
|
611
|
+
if (seg.length === 0) {
|
|
612
|
+
throw new ReleaseError(PATH_UNSAFE,
|
|
613
|
+
'openEntry: relative path must not contain empty segments');
|
|
614
|
+
}
|
|
615
|
+
if (seg === '.' || seg === '..') {
|
|
616
|
+
throw new ReleaseError(PATH_UNSAFE, 'openEntry: relative path must not contain . or ..');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const segments = rawSegments;
|
|
620
|
+
if (segments.length === 0) {
|
|
621
|
+
throw new ReleaseError(PATH_UNSAFE, 'openEntry: relative path is empty');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const handleStack = [];
|
|
625
|
+
const rootHandle = await backend.openRoot(rootOrPath);
|
|
626
|
+
handleStack.push(rootHandle);
|
|
627
|
+
let primaryResult;
|
|
628
|
+
let primaryError;
|
|
629
|
+
try {
|
|
630
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
631
|
+
const childHandle = await handleStack[handleStack.length - 1].openDir(segments[i]);
|
|
632
|
+
handleStack.push(childHandle);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const leaf = segments[segments.length - 1];
|
|
636
|
+
const result = await handleStack[handleStack.length - 1].readFile(leaf);
|
|
637
|
+
|
|
638
|
+
if (result === null) {
|
|
639
|
+
throw new ReleaseError(PATH_UNSAFE, 'openEntry: file does not exist');
|
|
640
|
+
}
|
|
641
|
+
primaryResult = Object.freeze({
|
|
642
|
+
type: 'file',
|
|
643
|
+
size: result.size,
|
|
644
|
+
mode: result.mode,
|
|
645
|
+
});
|
|
646
|
+
} catch (err) {
|
|
647
|
+
primaryError = err instanceof ReleaseError ? err : new ReleaseError(PATH_UNSAFE, sanitizeError(err));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Close in reverse order, tracking failures
|
|
651
|
+
const closeFailures = [];
|
|
652
|
+
for (let i = handleStack.length - 1; i >= 0; i--) {
|
|
653
|
+
try {
|
|
654
|
+
await handleStack[i].close();
|
|
655
|
+
} catch (closeErr) {
|
|
656
|
+
closeFailures.push(sanitizeError(closeErr));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (primaryError) {
|
|
661
|
+
if (closeFailures.length > 0) {
|
|
662
|
+
primaryError.details.closeFailures = closeFailures;
|
|
663
|
+
}
|
|
664
|
+
throw primaryError;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (closeFailures.length > 0) {
|
|
668
|
+
throw new ReleaseError(
|
|
669
|
+
PATH_UNSAFE,
|
|
670
|
+
'openEntry: handle close failed after successful operation',
|
|
671
|
+
{ closeFailures },
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return primaryResult;
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
return backend;
|
|
680
|
+
}
|