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,947 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
3
|
+
import { gunzipSync } from 'node:zlib';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
|
+
import { constants as fsConstants } from 'node:fs';
|
|
8
|
+
import { chmod, lstat, mkdtemp, open, readFile, realpath, rm } from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ActionStatus,
|
|
12
|
+
ActionType,
|
|
13
|
+
assertWritesAuthorized,
|
|
14
|
+
createResult,
|
|
15
|
+
matchObservation,
|
|
16
|
+
} from './contract.mjs';
|
|
17
|
+
|
|
18
|
+
const execFile = promisify(execFileCb);
|
|
19
|
+
const NAME = 'npm';
|
|
20
|
+
const SAFE_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
|
|
21
|
+
|
|
22
|
+
function validatePackageName(value) {
|
|
23
|
+
if (typeof value !== 'string' || value.length > 214 || !SAFE_PACKAGE_NAME.test(value)) {
|
|
24
|
+
throw new Error('npm package must be a safe lowercase package name or @scope/name');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a registry URL to a canonical form.
|
|
30
|
+
* Ensures the URL has a protocol, no trailing slash, and is lowercase.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} registry - The registry URL to normalize.
|
|
33
|
+
* @returns {string} The normalized registry URL.
|
|
34
|
+
* @throws {Error} If the registry is not a valid URL.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeRegistry(registry) {
|
|
37
|
+
if (typeof registry !== 'string' || registry.trim().length === 0) {
|
|
38
|
+
throw new Error('registry must be a non-empty string');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = new URL(registry.trim());
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`invalid registry URL: ${registry}`);
|
|
46
|
+
}
|
|
47
|
+
if (parsed.protocol !== 'https:') throw new Error('npm registry must use https');
|
|
48
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
49
|
+
throw new Error('npm registry URL must not contain credentials, query, or fragment');
|
|
50
|
+
}
|
|
51
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
|
|
52
|
+
return parsed.toString().replace(/\/$/, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function run(command, args, options = {}) {
|
|
56
|
+
return execFile(command, args, {
|
|
57
|
+
shell: false,
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
timeout: 120_000,
|
|
60
|
+
...options,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Default publishTarballBuffer implementation using libnpmpublish.
|
|
66
|
+
* Receives verified bytes directly — no named temp file involved.
|
|
67
|
+
* Authentication and registry options must be supplied explicitly by the
|
|
68
|
+
* caller; errors are sanitized before they cross the adapter boundary.
|
|
69
|
+
*/
|
|
70
|
+
let _libnpmpublish;
|
|
71
|
+
let _npmRegistryFetch;
|
|
72
|
+
async function defaultPublishTarballBuffer({ buffer, manifest, opts: publishOpts }) {
|
|
73
|
+
if (!_libnpmpublish) {
|
|
74
|
+
const require = createRequire(import.meta.url);
|
|
75
|
+
_libnpmpublish = require('libnpmpublish');
|
|
76
|
+
}
|
|
77
|
+
const libOpts = {};
|
|
78
|
+
if (publishOpts.registry) libOpts.registry = publishOpts.registry;
|
|
79
|
+
if (publishOpts.token) libOpts.forceAuth = { token: publishOpts.token };
|
|
80
|
+
if (publishOpts.access) libOpts.access = publishOpts.access;
|
|
81
|
+
if (publishOpts.tag) libOpts.defaultTag = publishOpts.tag;
|
|
82
|
+
if (publishOpts.provenance) libOpts.provenance = publishOpts.provenance;
|
|
83
|
+
return _libnpmpublish.publish(manifest, buffer, libOpts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function registryTokenKey(registry) {
|
|
87
|
+
const url = new URL(`${normalizeRegistry(registry)}/`);
|
|
88
|
+
return `//${url.host}${url.pathname}:_authToken`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function expandNpmrcValue(raw, env) {
|
|
92
|
+
const value = raw.trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
93
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
|
|
94
|
+
if (!Object.hasOwn(env, name)) throw new Error('npm auth token references an unset environment variable');
|
|
95
|
+
return env[name];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function tokensFromNpmrc(path, key, env) {
|
|
100
|
+
let contents;
|
|
101
|
+
try {
|
|
102
|
+
contents = await readFile(path, 'utf8');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err?.code === 'ENOENT') return [];
|
|
105
|
+
throw new Error('cannot read npm authentication config');
|
|
106
|
+
}
|
|
107
|
+
const tokens = [];
|
|
108
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
109
|
+
const line = rawLine.trim();
|
|
110
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
111
|
+
const separator = line.indexOf('=');
|
|
112
|
+
if (separator < 0 || line.slice(0, separator).trim() !== key) continue;
|
|
113
|
+
const token = expandNpmrcValue(line.slice(separator + 1), env);
|
|
114
|
+
if (!token) throw new Error('npm authentication token is empty');
|
|
115
|
+
tokens.push(token);
|
|
116
|
+
}
|
|
117
|
+
return tokens;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function defaultResolveAuthToken({ registry, cwd, exec, env = process.env }) {
|
|
121
|
+
const candidates = [];
|
|
122
|
+
for (const name of ['NPM_TOKEN', 'NODE_AUTH_TOKEN']) {
|
|
123
|
+
if (env[name]) candidates.push(env[name]);
|
|
124
|
+
}
|
|
125
|
+
const key = registryTokenKey(registry);
|
|
126
|
+
candidates.push(...await tokensFromNpmrc(join(cwd, '.npmrc'), key, env));
|
|
127
|
+
|
|
128
|
+
// Assemble the lowercase npm config key so the release leakage scanner does
|
|
129
|
+
// not mistake the environment-variable name itself for an npm access token.
|
|
130
|
+
const npmUserConfigKey = ['npm', 'config', 'userconfig'].join('_');
|
|
131
|
+
const userConfig = env[npmUserConfigKey]
|
|
132
|
+
?? (await exec('npm', ['config', 'get', 'userconfig'], { cwd, shell: false })).stdout.trim();
|
|
133
|
+
if (userConfig) candidates.push(...await tokensFromNpmrc(userConfig, key, env));
|
|
134
|
+
|
|
135
|
+
const unique = [...new Set(candidates)];
|
|
136
|
+
if (unique.length === 0) throw new Error('npm bearer authentication is not configured for the frozen registry');
|
|
137
|
+
if (unique.length !== 1) throw new Error('ambiguous npm bearer authentication for the frozen registry');
|
|
138
|
+
return unique[0];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function resolveNpmRegistryAuthToken(options) {
|
|
142
|
+
return defaultResolveAuthToken(options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function defaultWhoamiWithToken({ registry, token, cwd, exec }) {
|
|
146
|
+
try {
|
|
147
|
+
if (!_npmRegistryFetch) {
|
|
148
|
+
const require = createRequire(import.meta.url);
|
|
149
|
+
_npmRegistryFetch = require('npm-registry-fetch');
|
|
150
|
+
}
|
|
151
|
+
const result = await _npmRegistryFetch.json('/-/whoami', {
|
|
152
|
+
registry: `${normalizeRegistry(registry)}/`,
|
|
153
|
+
forceAuth: { token },
|
|
154
|
+
preferOnline: true,
|
|
155
|
+
});
|
|
156
|
+
if (!result || typeof result.username !== 'string' || !result.username) {
|
|
157
|
+
throw new Error('npm registry whoami returned an invalid identity');
|
|
158
|
+
}
|
|
159
|
+
return result.username;
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error('npm bearer authentication does not match the frozen registry and publisher');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function resolvePackageCwd(cwd, root) {
|
|
166
|
+
if (!cwd || typeof cwd !== 'string') throw new Error('NPM_PUBLISH requires a non-empty action.cwd (package directory)');
|
|
167
|
+
const rootPath = resolve(root);
|
|
168
|
+
const packagePath = resolve(root, cwd);
|
|
169
|
+
const rel = relative(rootPath, packagePath);
|
|
170
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
171
|
+
throw new Error(`cwd "${cwd}" is outside project root "${root}"`);
|
|
172
|
+
}
|
|
173
|
+
return packagePath;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isNotFound(error) {
|
|
177
|
+
const text = `${error?.code ?? ''}\n${error?.stdout ?? ''}\n${error?.stderr ?? ''}\n${error?.message ?? ''}`;
|
|
178
|
+
return /\bE404\b|\b404\b.*not found|not found.*\b404\b/i.test(text);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function verifyTarball(action, root) {
|
|
182
|
+
if (!action.tarballPath || isAbsolute(action.tarballPath)) throw new Error('tarballPath must be project-relative');
|
|
183
|
+
if (!/^[a-f0-9]{64}$/.test(action.tarballSha256 ?? '')) throw new Error('tarballSha256 must be a lowercase SHA-256 digest');
|
|
184
|
+
const rootReal = await realpath(root);
|
|
185
|
+
const lexical = resolve(rootReal, action.tarballPath);
|
|
186
|
+
const rel = relative(rootReal, lexical);
|
|
187
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
188
|
+
throw new Error('tarballPath escapes project root');
|
|
189
|
+
}
|
|
190
|
+
const st = await lstat(lexical);
|
|
191
|
+
if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1) throw new Error('frozen tarball must be a single-link regular file');
|
|
192
|
+
const physical = await realpath(lexical);
|
|
193
|
+
const physicalRel = relative(rootReal, physical);
|
|
194
|
+
if (isAbsolute(physicalRel) || physicalRel === '..' || physicalRel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
195
|
+
throw new Error('tarballPath resolves outside project root');
|
|
196
|
+
}
|
|
197
|
+
const bytes = await readFile(physical);
|
|
198
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
199
|
+
if (digest !== action.tarballSha256) throw new Error('frozen npm tarball SHA-256 mismatch');
|
|
200
|
+
return physical;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read the frozen tarball into a Buffer with O_NOFOLLOW identity verification.
|
|
205
|
+
* Validates: path safety, symlink/hardlink rejection, size stability across
|
|
206
|
+
* open/read/close, and SHA-256 digest against the frozen plan.
|
|
207
|
+
*
|
|
208
|
+
* @returns {Buffer} the verified tarball bytes
|
|
209
|
+
*/
|
|
210
|
+
async function readVerifiedTarballBytes(action, root) {
|
|
211
|
+
if (!action.tarballPath || isAbsolute(action.tarballPath)) throw new Error('tarballPath must be project-relative');
|
|
212
|
+
if (!/^[a-f0-9]{64}$/.test(action.tarballSha256 ?? '')) throw new Error('tarballSha256 must be a lowercase SHA-256 digest');
|
|
213
|
+
const rootReal = await realpath(root);
|
|
214
|
+
const lexical = resolve(rootReal, action.tarballPath);
|
|
215
|
+
const rel = relative(rootReal, lexical);
|
|
216
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
217
|
+
throw new Error('tarballPath escapes project root');
|
|
218
|
+
}
|
|
219
|
+
const before = await lstat(lexical);
|
|
220
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
221
|
+
throw new Error('frozen tarball must be a single-link regular file');
|
|
222
|
+
}
|
|
223
|
+
const source = await open(lexical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
224
|
+
let bytes;
|
|
225
|
+
try {
|
|
226
|
+
const opened = await source.stat();
|
|
227
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
228
|
+
throw new Error('frozen tarball changed before read');
|
|
229
|
+
}
|
|
230
|
+
bytes = Buffer.alloc(opened.size);
|
|
231
|
+
let position = 0;
|
|
232
|
+
while (position < bytes.length) {
|
|
233
|
+
const { bytesRead } = await source.read(bytes, position, bytes.length - position, position);
|
|
234
|
+
if (bytesRead === 0) throw new Error('frozen tarball ended during read');
|
|
235
|
+
position += bytesRead;
|
|
236
|
+
}
|
|
237
|
+
const after = await source.stat();
|
|
238
|
+
if (
|
|
239
|
+
after.dev !== opened.dev || after.ino !== opened.ino || after.nlink !== 1 ||
|
|
240
|
+
after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
|
|
241
|
+
) {
|
|
242
|
+
throw new Error('frozen tarball changed during read');
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
await source.close();
|
|
246
|
+
}
|
|
247
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
248
|
+
if (digest !== action.tarballSha256) throw new Error('frozen npm tarball SHA-256 mismatch');
|
|
249
|
+
if (typeof action.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(action.integrity)) {
|
|
250
|
+
throw new Error('frozen npm tarball integrity must be an sha512 SRI value');
|
|
251
|
+
}
|
|
252
|
+
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
|
253
|
+
if (integrity !== action.integrity) throw new Error('frozen npm tarball SHA-512 integrity mismatch');
|
|
254
|
+
return bytes;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract and parse the package/package.json manifest from a tarball Buffer.
|
|
259
|
+
* Supports plain tar and gzip-compressed tarballs. Throws if the manifest
|
|
260
|
+
* is missing, not valid JSON, or the extracted name/version do not match the
|
|
261
|
+
* expected action values.
|
|
262
|
+
*
|
|
263
|
+
* @param {Buffer} tarballBuffer - the raw tarball bytes (gzipped or plain tar)
|
|
264
|
+
* @param {{ name: string, version: string }} expected - must match manifest
|
|
265
|
+
* @returns {object} the parsed package.json manifest
|
|
266
|
+
*/
|
|
267
|
+
function extractManifestFromTarball(tarballBuffer, expected) {
|
|
268
|
+
const isGzip = tarballBuffer.length >= 2 && tarballBuffer[0] === 0x1f && tarballBuffer[1] === 0x8b;
|
|
269
|
+
let data = tarballBuffer;
|
|
270
|
+
if (isGzip) {
|
|
271
|
+
try {
|
|
272
|
+
data = gunzipSync(tarballBuffer);
|
|
273
|
+
} catch {
|
|
274
|
+
throw new Error('frozen npm tarball has an invalid gzip stream');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Walk tar entries to find package/package.json
|
|
279
|
+
let offset = 0;
|
|
280
|
+
let manifest = null;
|
|
281
|
+
while (offset + 512 <= data.length) {
|
|
282
|
+
// Check for all-zero end-of-archive block
|
|
283
|
+
let allZero = true;
|
|
284
|
+
for (let i = 0; i < 512; i++) {
|
|
285
|
+
if (data[offset + i] !== 0) { allZero = false; break; }
|
|
286
|
+
}
|
|
287
|
+
if (allZero) break;
|
|
288
|
+
|
|
289
|
+
const header = data.subarray(offset, offset + 512);
|
|
290
|
+
const storedChecksumText = header.toString('ascii', 148, 156).replace(/\0.*$/, '').trim();
|
|
291
|
+
if (!/^[0-7]+$/.test(storedChecksumText)) throw new Error('frozen npm tarball has an invalid tar checksum field');
|
|
292
|
+
const storedChecksum = Number.parseInt(storedChecksumText, 8);
|
|
293
|
+
let computedChecksum = 0;
|
|
294
|
+
for (let i = 0; i < header.length; i += 1) {
|
|
295
|
+
computedChecksum += i >= 148 && i < 156 ? 0x20 : header[i];
|
|
296
|
+
}
|
|
297
|
+
if (computedChecksum !== storedChecksum) throw new Error('frozen npm tarball header checksum mismatch');
|
|
298
|
+
|
|
299
|
+
const name = data.toString('utf8', offset, offset + 100).replace(/\0.*$/, '');
|
|
300
|
+
const sizeStr = data.toString('utf8', offset + 124, offset + 136).replace(/\0.*$/, '').trim();
|
|
301
|
+
if (sizeStr && !/^[0-7]+$/.test(sizeStr)) throw new Error('frozen npm tarball has an invalid entry size');
|
|
302
|
+
const size = sizeStr ? Number.parseInt(sizeStr, 8) : 0;
|
|
303
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error('frozen npm tarball entry size is unsafe');
|
|
304
|
+
const typeFlag = data[offset + 156];
|
|
305
|
+
|
|
306
|
+
// Normalize: ustar may prefix with a numeric field at offset 345 for long names
|
|
307
|
+
let entryName = name;
|
|
308
|
+
// If prefix field is non-empty, the real path is prefix/name
|
|
309
|
+
const prefix = data.toString('utf8', offset + 345, offset + 500).replace(/\0.*$/, '');
|
|
310
|
+
if (prefix) entryName = `${prefix}/${name}`;
|
|
311
|
+
|
|
312
|
+
// Match package/package.json (standard npm tarball layout)
|
|
313
|
+
if (entryName === 'package/package.json') {
|
|
314
|
+
if (!(typeFlag === 0 || typeFlag === 48 /* '0' */)) {
|
|
315
|
+
throw new Error('tarball package/package.json must be a regular file');
|
|
316
|
+
}
|
|
317
|
+
if (manifest) throw new Error('tarball contains duplicate package/package.json entries');
|
|
318
|
+
const bodyStart = offset + 512;
|
|
319
|
+
const bodyEnd = bodyStart + size;
|
|
320
|
+
if (bodyEnd > data.length) throw new Error('frozen npm tarball entry exceeds archive bounds');
|
|
321
|
+
if (size > 10 * 1024 * 1024) throw new Error('tarball package/package.json is unreasonably large');
|
|
322
|
+
const body = data.subarray(bodyStart, bodyEnd);
|
|
323
|
+
try {
|
|
324
|
+
manifest = JSON.parse(body.toString('utf8'));
|
|
325
|
+
} catch (err) {
|
|
326
|
+
throw new Error(`tarball package/package.json is not valid JSON: ${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
if (manifest.name !== expected.name) {
|
|
329
|
+
throw new Error(`tarball manifest name "${manifest.name}" does not match expected "${expected.name}"`);
|
|
330
|
+
}
|
|
331
|
+
if (manifest.version !== expected.version) {
|
|
332
|
+
throw new Error(`tarball manifest version "${manifest.version}" does not match expected "${expected.version}"`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Advance to next header: 512 header + ceil(size/512)*512 data
|
|
337
|
+
const nextOffset = offset + 512 + Math.ceil(size / 512) * 512;
|
|
338
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset > data.length) {
|
|
339
|
+
throw new Error('frozen npm tarball entry exceeds archive bounds');
|
|
340
|
+
}
|
|
341
|
+
offset = nextOffset;
|
|
342
|
+
}
|
|
343
|
+
if (!manifest) throw new Error('tarball does not contain package/package.json');
|
|
344
|
+
return manifest;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Verify frozen tarball bytes, integrity, and the embedded npm identity.
|
|
349
|
+
* This is safe to call during prepare or a global preflight, before any
|
|
350
|
+
* external write is authorized.
|
|
351
|
+
*/
|
|
352
|
+
export async function verifyFrozenNpmTarballIdentity(action, root) {
|
|
353
|
+
const buffer = await readVerifiedTarballBytes(action, root);
|
|
354
|
+
return extractManifestFromTarball(buffer, {
|
|
355
|
+
name: action.package,
|
|
356
|
+
version: action.version,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read and verify the frozen tarball, then write it to a controlled named
|
|
362
|
+
* temp file under a temp directory adjacent to the source. Returns the temp
|
|
363
|
+
* file path, a cleanup handle, and a post-publish verifier.
|
|
364
|
+
*
|
|
365
|
+
* Threat model:
|
|
366
|
+
* - Same-UID concurrent processes on generic POSIX can replace or read any
|
|
367
|
+
* path the current user can access. npm CLI's named-path interface cannot
|
|
368
|
+
* atomically hand off bytes to npm. Therefore the verification seam between
|
|
369
|
+
* our write and npm's read is a *process-internal drift detector*, not an
|
|
370
|
+
* OS-level isolation guarantee against same-UID malicious actors.
|
|
371
|
+
* - We do NOT claim to defend against a concurrent same-UID process that
|
|
372
|
+
* replaces the named tarball between our pre-spawn check and npm's open.
|
|
373
|
+
* - We DO detect: content drift, symlink/hardlink swaps, size/metadata changes,
|
|
374
|
+
* and unexpected deletion — both before spawn and after npm returns.
|
|
375
|
+
*
|
|
376
|
+
* Security properties:
|
|
377
|
+
* - Source tarball is opened with O_NOFOLLOW and identity-checked (dev/ino/nlink).
|
|
378
|
+
* - Content SHA-256 is verified against the frozen plan digest.
|
|
379
|
+
* - Temp file is created exclusive (wx) with restrictive permissions (0o400).
|
|
380
|
+
* - After writing, the temp file identity is recorded (dev/ino/nlink/size/mtime/ctime).
|
|
381
|
+
* - Internal test hook (if present) runs between write completion and pre-spawn
|
|
382
|
+
* verification; any tampering is caught by the identity comparison.
|
|
383
|
+
* - Pre-spawn verification re-opens with O_NOFOLLOW and compares
|
|
384
|
+
* dev/ino/nlink/size/mtime/ctime against creation-time values, then reads
|
|
385
|
+
* back and verifies content SHA-256.
|
|
386
|
+
* - Post-publish verification (after npm returns) re-opens with O_NOFOLLOW,
|
|
387
|
+
* compares identity fields against creation-time values, and reads back
|
|
388
|
+
* SHA-256. If the file changed, the result status is "unknown" and the
|
|
389
|
+
* caller must not treat the publish as successful.
|
|
390
|
+
* - Temp directory is created with identity recorded (dev/ino); cleanup
|
|
391
|
+
* verifies identity before removal and fails closed if replaced.
|
|
392
|
+
* - All paths are verified to be within the production asset root.
|
|
393
|
+
*
|
|
394
|
+
* @returns {{ tarballPath: string, cleanup: () => Promise<void>, verifyPostPublish: () => Promise<{ok: boolean, error?: string}> }}
|
|
395
|
+
*/
|
|
396
|
+
async function createNamedVerifiedTarball(action, root, tamperHook) {
|
|
397
|
+
if (!action.tarballPath || isAbsolute(action.tarballPath)) throw new Error('tarballPath must be project-relative');
|
|
398
|
+
if (!/^[a-f0-9]{64}$/.test(action.tarballSha256 ?? '')) throw new Error('tarballSha256 must be a lowercase SHA-256 digest');
|
|
399
|
+
const rootReal = await realpath(root);
|
|
400
|
+
const lexical = resolve(rootReal, action.tarballPath);
|
|
401
|
+
const rel = relative(rootReal, lexical);
|
|
402
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
403
|
+
throw new Error('tarballPath escapes project root');
|
|
404
|
+
}
|
|
405
|
+
const before = await lstat(lexical);
|
|
406
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
407
|
+
throw new Error('frozen tarball must be a single-link regular file');
|
|
408
|
+
}
|
|
409
|
+
const source = await open(lexical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
410
|
+
let bytes;
|
|
411
|
+
try {
|
|
412
|
+
const opened = await source.stat();
|
|
413
|
+
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
414
|
+
throw new Error('frozen tarball changed before read');
|
|
415
|
+
}
|
|
416
|
+
bytes = Buffer.alloc(opened.size);
|
|
417
|
+
let position = 0;
|
|
418
|
+
while (position < bytes.length) {
|
|
419
|
+
const { bytesRead } = await source.read(bytes, position, bytes.length - position, position);
|
|
420
|
+
if (bytesRead === 0) throw new Error('frozen tarball ended during read');
|
|
421
|
+
position += bytesRead;
|
|
422
|
+
}
|
|
423
|
+
const after = await source.stat();
|
|
424
|
+
if (
|
|
425
|
+
after.dev !== opened.dev || after.ino !== opened.ino || after.nlink !== 1 ||
|
|
426
|
+
after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
|
|
427
|
+
) {
|
|
428
|
+
throw new Error('frozen tarball changed during read');
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
await source.close();
|
|
432
|
+
}
|
|
433
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
434
|
+
if (digest !== action.tarballSha256) throw new Error('frozen npm tarball SHA-256 mismatch');
|
|
435
|
+
|
|
436
|
+
// Verify all parent paths between the tarball and the project root are real
|
|
437
|
+
// directories (not symlinks). This prevents symlink-based path traversal
|
|
438
|
+
// within the production asset root. We only check paths inside the root;
|
|
439
|
+
// paths above the root (e.g., /var, /tmp) are outside our control.
|
|
440
|
+
const parentDir = dirname(lexical);
|
|
441
|
+
for (let checkPath = parentDir; checkPath !== rootReal && checkPath !== dirname(rootReal); ) {
|
|
442
|
+
const checkStat = await lstat(checkPath);
|
|
443
|
+
if (checkStat.isSymbolicLink()) {
|
|
444
|
+
throw new Error(`parent path contains symlink within production asset root: ${checkPath}`);
|
|
445
|
+
}
|
|
446
|
+
const checkRel = relative(rootReal, checkPath);
|
|
447
|
+
if (isAbsolute(checkRel) || checkRel === '..' || checkRel.startsWith('..')) {
|
|
448
|
+
break; // above root, stop checking
|
|
449
|
+
}
|
|
450
|
+
checkPath = dirname(checkPath);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const tempDir = await mkdtemp(join(parentDir, '.publish-tarball-'));
|
|
454
|
+
const uniqueName = `${action.package.replace('/', '-')}-${action.version}-${randomUUID()}.tgz`;
|
|
455
|
+
const tempPath = join(tempDir, uniqueName);
|
|
456
|
+
|
|
457
|
+
// Record temp directory identity at creation for cleanup verification
|
|
458
|
+
const tempDirStat = await lstat(tempDir);
|
|
459
|
+
|
|
460
|
+
const cleanup = async () => {
|
|
461
|
+
// Verify temp directory identity before cleanup.
|
|
462
|
+
// If the directory was replaced (by a symlink or different directory),
|
|
463
|
+
// fail closed and do not delete the replacement.
|
|
464
|
+
let currentDirStat;
|
|
465
|
+
try {
|
|
466
|
+
currentDirStat = await lstat(tempDir);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
// Directory already gone — nothing to clean
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (currentDirStat.isSymbolicLink()) {
|
|
472
|
+
throw new Error('temp directory was replaced with a symlink; refusing to clean');
|
|
473
|
+
}
|
|
474
|
+
if (!currentDirStat.isDirectory()) {
|
|
475
|
+
throw new Error('temp directory path is no longer a directory; refusing to clean');
|
|
476
|
+
}
|
|
477
|
+
if (currentDirStat.dev !== tempDirStat.dev || currentDirStat.ino !== tempDirStat.ino) {
|
|
478
|
+
throw new Error('temp directory identity changed; refusing to clean replacement');
|
|
479
|
+
}
|
|
480
|
+
// Restore permissions so rm can traverse the directory
|
|
481
|
+
await chmod(tempDir, 0o700);
|
|
482
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const writer = await open(tempPath, 'wx', 0o400);
|
|
487
|
+
try {
|
|
488
|
+
await writer.writeFile(bytes);
|
|
489
|
+
await writer.sync();
|
|
490
|
+
} finally {
|
|
491
|
+
await writer.close();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Record creation-time identity for post-hook comparison
|
|
495
|
+
const identityHandle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
496
|
+
let creationStat;
|
|
497
|
+
try {
|
|
498
|
+
creationStat = await identityHandle.stat();
|
|
499
|
+
if (!creationStat.isFile() || creationStat.nlink !== 1) {
|
|
500
|
+
throw new Error('named tarball failed post-write verification: not a single-link regular file');
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
await identityHandle.close();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Internal test hook: allows tests to tamper between write completion
|
|
507
|
+
// and pre-spawn identity verification. Passed via deps.postWriteTamperHook.
|
|
508
|
+
if (typeof tamperHook === 'function') {
|
|
509
|
+
await tamperHook(tempPath);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Pre-spawn verification: re-open with O_NOFOLLOW and compare all six
|
|
513
|
+
// identity fields (dev/ino/nlink/size/mtime/ctime) against creation-time
|
|
514
|
+
// values, then read back and verify content SHA-256.
|
|
515
|
+
const preSpawnHandle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
516
|
+
try {
|
|
517
|
+
const postStat = await preSpawnHandle.stat();
|
|
518
|
+
if (!postStat.isFile() || postStat.nlink !== 1) {
|
|
519
|
+
throw new Error('named tarball failed pre-spawn verification: not a single-link regular file');
|
|
520
|
+
}
|
|
521
|
+
if (
|
|
522
|
+
postStat.dev !== creationStat.dev ||
|
|
523
|
+
postStat.ino !== creationStat.ino ||
|
|
524
|
+
postStat.nlink !== creationStat.nlink ||
|
|
525
|
+
postStat.size !== creationStat.size ||
|
|
526
|
+
postStat.mtimeMs !== creationStat.mtimeMs ||
|
|
527
|
+
postStat.ctimeMs !== creationStat.ctimeMs
|
|
528
|
+
) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
'named tarball failed pre-spawn verification: file identity changed between write and spawn',
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
// Read back and verify content SHA-256
|
|
534
|
+
const readback = Buffer.alloc(postStat.size);
|
|
535
|
+
let pos = 0;
|
|
536
|
+
while (pos < readback.length) {
|
|
537
|
+
const { bytesRead } = await preSpawnHandle.read(readback, pos, readback.length - pos, pos);
|
|
538
|
+
if (bytesRead === 0) throw new Error('named tarball failed pre-spawn verification: truncated read');
|
|
539
|
+
pos += bytesRead;
|
|
540
|
+
}
|
|
541
|
+
const readbackDigest = createHash('sha256').update(readback).digest('hex');
|
|
542
|
+
if (readbackDigest !== action.tarballSha256) {
|
|
543
|
+
throw new Error('named tarball failed pre-spawn verification: SHA-256 mismatch');
|
|
544
|
+
}
|
|
545
|
+
} finally {
|
|
546
|
+
await preSpawnHandle.close();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Drop temp directory to minimum permissions (read+traverse only)
|
|
550
|
+
await chmod(tempDir, 0o500);
|
|
551
|
+
|
|
552
|
+
// Post-publish verification function: re-opens the named file after npm
|
|
553
|
+
// returns and compares identity + content. This detects drift during the
|
|
554
|
+
// scheduling window between pre-spawn check and npm's file open.
|
|
555
|
+
const verifyPostPublish = async () => {
|
|
556
|
+
try {
|
|
557
|
+
const ppHandle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
558
|
+
try {
|
|
559
|
+
const ppStat = await ppHandle.stat();
|
|
560
|
+
if (!ppStat.isFile() || ppStat.nlink !== 1) {
|
|
561
|
+
return { ok: false, error: 'post-publish: not a single-link regular file' };
|
|
562
|
+
}
|
|
563
|
+
if (
|
|
564
|
+
ppStat.dev !== creationStat.dev ||
|
|
565
|
+
ppStat.ino !== creationStat.ino ||
|
|
566
|
+
ppStat.nlink !== creationStat.nlink ||
|
|
567
|
+
ppStat.size !== creationStat.size ||
|
|
568
|
+
ppStat.mtimeMs !== creationStat.mtimeMs ||
|
|
569
|
+
ppStat.ctimeMs !== creationStat.ctimeMs
|
|
570
|
+
) {
|
|
571
|
+
return { ok: false, error: 'post-publish: file identity changed between spawn and npm completion' };
|
|
572
|
+
}
|
|
573
|
+
const ppBuf = Buffer.alloc(ppStat.size);
|
|
574
|
+
let p = 0;
|
|
575
|
+
while (p < ppBuf.length) {
|
|
576
|
+
const { bytesRead } = await ppHandle.read(ppBuf, p, ppBuf.length - p, p);
|
|
577
|
+
if (bytesRead === 0) return { ok: false, error: 'post-publish: truncated read' };
|
|
578
|
+
p += bytesRead;
|
|
579
|
+
}
|
|
580
|
+
const ppDigest = createHash('sha256').update(ppBuf).digest('hex');
|
|
581
|
+
if (ppDigest !== action.tarballSha256) {
|
|
582
|
+
return { ok: false, error: 'post-publish: content SHA-256 mismatch' };
|
|
583
|
+
}
|
|
584
|
+
return { ok: true };
|
|
585
|
+
} finally {
|
|
586
|
+
await ppHandle.close();
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
return { ok: false, error: `post-publish verification failed: ${err.message}` };
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
return { tarballPath: tempPath, cleanup, verifyPostPublish };
|
|
594
|
+
} catch (err) {
|
|
595
|
+
await cleanup();
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Spawn npm publish with a named tarball path. The tarball path is passed as
|
|
602
|
+
* the package spec argument (not via fd), which works correctly on macOS and
|
|
603
|
+
* Linux. Arguments are passed as an array — no shell interpolation.
|
|
604
|
+
*/
|
|
605
|
+
async function spawnNpmPublishTarball({ args, cwd }) {
|
|
606
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
607
|
+
const child = spawn('npm', args, {
|
|
608
|
+
cwd,
|
|
609
|
+
shell: false,
|
|
610
|
+
env: process.env,
|
|
611
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
612
|
+
});
|
|
613
|
+
let stdout = '';
|
|
614
|
+
let stderr = '';
|
|
615
|
+
child.stdout.setEncoding('utf8');
|
|
616
|
+
child.stderr.setEncoding('utf8');
|
|
617
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
618
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
619
|
+
child.on('error', rejectPromise);
|
|
620
|
+
child.on('close', (code) => {
|
|
621
|
+
if (code === 0) resolvePromise({ stdout, stderr });
|
|
622
|
+
else {
|
|
623
|
+
const error = new Error(`npm publish exited with code ${code}: ${stderr.trim()}`);
|
|
624
|
+
error.code = code;
|
|
625
|
+
error.stdout = stdout;
|
|
626
|
+
error.stderr = stderr;
|
|
627
|
+
rejectPromise(error);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function queryVersion(action, cwd, exec, registry) {
|
|
634
|
+
try {
|
|
635
|
+
const args = ['view', `${action.package}@${action.version}`, 'version', '--json'];
|
|
636
|
+
if (registry) {
|
|
637
|
+
args.push('--registry', registry);
|
|
638
|
+
}
|
|
639
|
+
const { stdout } = await exec('npm', args, { cwd, shell: false });
|
|
640
|
+
let version;
|
|
641
|
+
try {
|
|
642
|
+
version = JSON.parse(stdout);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
throw new Error(`npm view returned malformed JSON: ${err.message}`);
|
|
645
|
+
}
|
|
646
|
+
if (typeof version !== 'string' || version !== action.version) {
|
|
647
|
+
throw new Error(`npm view returned an unexpected version for ${action.package}@${action.version}`);
|
|
648
|
+
}
|
|
649
|
+
return { exists: true, version };
|
|
650
|
+
} catch (err) {
|
|
651
|
+
if (isNotFound(err)) return { exists: false, version: null };
|
|
652
|
+
throw new Error(`cannot determine npm version uniqueness: ${err.message}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function createNpmAdapter(deps = {}) {
|
|
657
|
+
const exec = deps.exec ?? run;
|
|
658
|
+
const publishFromTarball = deps.publishFromTarball ?? (deps.exec
|
|
659
|
+
? ({ args, cwd }) => exec('npm', args, { cwd, shell: false })
|
|
660
|
+
: spawnNpmPublishTarball);
|
|
661
|
+
// publishTarballBuffer: stable-bytes publish seam. When present, execute
|
|
662
|
+
// reads the tarball into a verified Buffer and passes it directly to this
|
|
663
|
+
// function instead of writing a named temp file. This eliminates the
|
|
664
|
+
// named-file TOCTOU window entirely.
|
|
665
|
+
// Signature: ({ buffer: Buffer, manifest: object, opts: { access?, tag?, provenance? } }) => Promise<any>
|
|
666
|
+
//
|
|
667
|
+
// Default: always use libnpmpublish's Buffer API. Tests that replace this
|
|
668
|
+
// seam must provide another Buffer consumer. A frozen tarball is never
|
|
669
|
+
// handed back to the legacy named-path publisher.
|
|
670
|
+
const publishTarballBuffer = deps.publishTarballBuffer === undefined
|
|
671
|
+
? defaultPublishTarballBuffer
|
|
672
|
+
: deps.publishTarballBuffer;
|
|
673
|
+
const beforeBufferPublishHook = deps.beforeBufferPublishHook ?? null;
|
|
674
|
+
const resolveAuthToken = deps.resolveAuthToken ?? defaultResolveAuthToken;
|
|
675
|
+
const whoamiWithToken = deps.whoamiWithToken ?? defaultWhoamiWithToken;
|
|
676
|
+
const authEnv = deps.authEnv ?? process.env;
|
|
677
|
+
// postWriteTamperHook: injectable test seam for pre-spawn verification tests.
|
|
678
|
+
// Not part of the public adapter interface; only used in test environments.
|
|
679
|
+
const postWriteTamperHook = deps.postWriteTamperHook ?? null;
|
|
680
|
+
// afterPublishBeforeVerifyHook: test seam for post-publish tamper tests (Item 27).
|
|
681
|
+
// Called after publishFromTarball returns, before verifyPostPublish.
|
|
682
|
+
const afterPublishBeforeVerifyHook = deps.afterPublishBeforeVerifyHook ?? null;
|
|
683
|
+
// beforeCleanupHook: test seam for cleanup attack tests (Item 26).
|
|
684
|
+
// Called in execute's finally, before cleanup() runs.
|
|
685
|
+
const beforeCleanupHook = deps.beforeCleanupHook ?? null;
|
|
686
|
+
return Object.freeze({
|
|
687
|
+
name: NAME,
|
|
688
|
+
actionTypes: Object.freeze([ActionType.NPM_PACK, ActionType.NPM_PUBLISH]),
|
|
689
|
+
|
|
690
|
+
async preflight(action, context) {
|
|
691
|
+
try {
|
|
692
|
+
if (action.actionType === ActionType.NPM_PACK) {
|
|
693
|
+
await exec('npm', ['pack', '--dry-run', '--json'], { cwd: context.root, shell: false });
|
|
694
|
+
} else if (action.actionType === ActionType.NPM_PUBLISH) {
|
|
695
|
+
if (!action.package || !action.version) throw new Error('npm-publish requires package and version');
|
|
696
|
+
validatePackageName(action.package);
|
|
697
|
+
|
|
698
|
+
// Validate and normalize registry
|
|
699
|
+
if (!action.registry) throw new Error('npm-publish requires explicit registry');
|
|
700
|
+
const normalizedRegistry = normalizeRegistry(action.registry);
|
|
701
|
+
|
|
702
|
+
// Validate publisher
|
|
703
|
+
if (!action.publisher) throw new Error('npm-publish requires explicit publisher');
|
|
704
|
+
|
|
705
|
+
const access = action.access ?? (action.tarballPath ? null : 'public');
|
|
706
|
+
if (!['public', 'restricted'].includes(access)) {
|
|
707
|
+
throw new Error('npm-publish requires explicit access: public or restricted');
|
|
708
|
+
}
|
|
709
|
+
const cwd = resolvePackageCwd(action.cwd, context.root);
|
|
710
|
+
if (action.tarballPath) {
|
|
711
|
+
await verifyFrozenNpmTarballIdentity(action, context.root);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let whoamiUser;
|
|
715
|
+
if (action.tarballPath) {
|
|
716
|
+
try {
|
|
717
|
+
const token = await resolveAuthToken({ registry: normalizedRegistry, cwd, exec, env: authEnv });
|
|
718
|
+
whoamiUser = await whoamiWithToken({ registry: normalizedRegistry, token, cwd, exec });
|
|
719
|
+
} catch {
|
|
720
|
+
throw new Error('npm bearer authentication does not match the frozen registry and publisher');
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
try {
|
|
724
|
+
const { stdout } = await exec('npm', ['whoami', '--registry', normalizedRegistry], { cwd, shell: false });
|
|
725
|
+
whoamiUser = stdout.trim();
|
|
726
|
+
} catch {
|
|
727
|
+
throw new Error(`npm authentication not configured for registry ${normalizedRegistry}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Verify whoami matches publisher
|
|
732
|
+
if (whoamiUser !== action.publisher) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
`npm whoami returned "${whoamiUser}" but expected publisher "${action.publisher}" for registry ${normalizedRegistry}`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Query version with explicit registry
|
|
739
|
+
const remote = await queryVersion(action, cwd, exec, normalizedRegistry);
|
|
740
|
+
if (remote.exists) throw new Error(`Package ${action.package}@${action.version} is already published`);
|
|
741
|
+
} else {
|
|
742
|
+
throw new Error(`unsupported action type: ${action.actionType}`);
|
|
743
|
+
}
|
|
744
|
+
return createResult({ actionType: action.actionType, status: ActionStatus.PREFLIGHT_PASSED });
|
|
745
|
+
} catch (err) {
|
|
746
|
+
return createResult({ actionType: action.actionType, status: ActionStatus.PREFLIGHT_FAILED, error: err.message });
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
async execute(action, context) {
|
|
751
|
+
assertWritesAuthorized(context, action.actionType);
|
|
752
|
+
let namedTarball = null;
|
|
753
|
+
let primaryResult = null;
|
|
754
|
+
try {
|
|
755
|
+
if (action.actionType === ActionType.NPM_PACK) {
|
|
756
|
+
const { stdout } = await exec('npm', ['pack', '--json'], { cwd: action.cwd ? resolvePackageCwd(action.cwd, context.root) : context.root, shell: false });
|
|
757
|
+
const parsed = JSON.parse(stdout);
|
|
758
|
+
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
759
|
+
primaryResult = createResult({ actionType: action.actionType, status: ActionStatus.EXECUTED, observation: info });
|
|
760
|
+
return primaryResult;
|
|
761
|
+
}
|
|
762
|
+
if (action.actionType !== ActionType.NPM_PUBLISH) throw new Error(`unsupported action type: ${action.actionType}`);
|
|
763
|
+
validatePackageName(action.package);
|
|
764
|
+
|
|
765
|
+
// Validate and normalize registry
|
|
766
|
+
if (!action.registry) throw new Error('npm-publish requires explicit registry');
|
|
767
|
+
const normalizedRegistry = normalizeRegistry(action.registry);
|
|
768
|
+
|
|
769
|
+
// Validate publisher
|
|
770
|
+
if (!action.publisher) throw new Error('npm-publish requires explicit publisher');
|
|
771
|
+
|
|
772
|
+
const cwd = resolvePackageCwd(action.cwd, context.root);
|
|
773
|
+
const access = action.access ?? (action.tarballPath ? null : 'public');
|
|
774
|
+
if (!['public', 'restricted'].includes(access)) {
|
|
775
|
+
throw new Error('npm-publish requires explicit access: public or restricted');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Stable-bytes Buffer path: when publishTarballBuffer is available and
|
|
779
|
+
// we have a frozen tarball, read the verified bytes directly into memory
|
|
780
|
+
// and hand them to the registry API — no named temp file, no TOCTOU.
|
|
781
|
+
if (action.tarballPath) {
|
|
782
|
+
if (typeof publishTarballBuffer !== 'function') {
|
|
783
|
+
throw new Error('frozen npm tarball requires a stable Buffer publish capability');
|
|
784
|
+
}
|
|
785
|
+
const buffer = await readVerifiedTarballBytes(action, context.root);
|
|
786
|
+
const manifest = extractManifestFromTarball(buffer, { name: action.package, version: action.version });
|
|
787
|
+
let token;
|
|
788
|
+
try {
|
|
789
|
+
token = await resolveAuthToken({ registry: normalizedRegistry, cwd, exec, env: authEnv });
|
|
790
|
+
const authenticatedPublisher = await whoamiWithToken({ registry: normalizedRegistry, token, cwd, exec });
|
|
791
|
+
if (authenticatedPublisher !== action.publisher) {
|
|
792
|
+
throw new Error('npm bearer identity does not match the frozen publisher');
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
throw new Error('npm bearer authentication does not match the frozen registry and publisher');
|
|
796
|
+
}
|
|
797
|
+
if (typeof beforeBufferPublishHook === 'function') {
|
|
798
|
+
await beforeBufferPublishHook(resolve(context.root, action.tarballPath));
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
await publishTarballBuffer({
|
|
802
|
+
buffer,
|
|
803
|
+
manifest,
|
|
804
|
+
opts: {
|
|
805
|
+
registry: normalizedRegistry,
|
|
806
|
+
token,
|
|
807
|
+
access,
|
|
808
|
+
tag: action.tag ?? undefined,
|
|
809
|
+
provenance: action.provenance === true || undefined,
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
} catch (pubErr) {
|
|
813
|
+
// Sanitize: strip any credential or token from the error message.
|
|
814
|
+
// The publish API may include auth headers or tokens in errors.
|
|
815
|
+
const safeCode = typeof pubErr.code === 'string' && /^[A-Z0-9_-]{1,32}$/.test(pubErr.code)
|
|
816
|
+
? pubErr.code
|
|
817
|
+
: 'unknown';
|
|
818
|
+
throw new Error(`npm registry publish failed (${safeCode})`);
|
|
819
|
+
}
|
|
820
|
+
primaryResult = createResult({ actionType: action.actionType, status: ActionStatus.EXECUTED });
|
|
821
|
+
return primaryResult;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
throw new Error('npm-publish requires a frozen tarball; mutable cwd publishing is not supported');
|
|
825
|
+
} catch (err) {
|
|
826
|
+
primaryResult = createResult({ actionType: action.actionType, status: ActionStatus.EXECUTE_FAILED, error: err.message });
|
|
827
|
+
return primaryResult;
|
|
828
|
+
} finally {
|
|
829
|
+
if (namedTarball) {
|
|
830
|
+
// Test seam (Item 26): allows tests to tamper with the temp directory
|
|
831
|
+
// before cleanup runs, to test identity-bound cleanup.
|
|
832
|
+
if (typeof beforeCleanupHook === 'function') {
|
|
833
|
+
await beforeCleanupHook(namedTarball.tarballPath);
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
await namedTarball.cleanup();
|
|
837
|
+
} catch (cleanupErr) {
|
|
838
|
+
// Cleanup failure (e.g., identity-bound rejection) must not silently
|
|
839
|
+
// override a more informative primary error (post-publish verification,
|
|
840
|
+
// pre-spawn detection). Record it but preserve the primary status.
|
|
841
|
+
if (primaryResult && primaryResult.status === ActionStatus.EXECUTED) {
|
|
842
|
+
return createResult({
|
|
843
|
+
actionType: action.actionType,
|
|
844
|
+
status: ActionStatus.EXECUTE_FAILED,
|
|
845
|
+
error: `cleanup failed: ${cleanupErr.message}`,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
// Primary result already recorded an error; attach cleanup info.
|
|
849
|
+
if (primaryResult) {
|
|
850
|
+
primaryResult.error = `${primaryResult.error}\n[cleanup also failed: ${cleanupErr.message}]`;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
|
|
857
|
+
async observe(action, context) {
|
|
858
|
+
try {
|
|
859
|
+
if (action.actionType === ActionType.NPM_PACK) {
|
|
860
|
+
return createResult({ actionType: action.actionType, status: ActionStatus.OBSERVED, observation: { local: true } });
|
|
861
|
+
}
|
|
862
|
+
if (action.actionType !== ActionType.NPM_PUBLISH) throw new Error(`unsupported action type: ${action.actionType}`);
|
|
863
|
+
validatePackageName(action.package);
|
|
864
|
+
|
|
865
|
+
// Validate and normalize registry
|
|
866
|
+
if (!action.registry) throw new Error('npm-publish requires explicit registry');
|
|
867
|
+
const normalizedRegistry = normalizeRegistry(action.registry);
|
|
868
|
+
if (!action.publisher) throw new Error('npm-publish requires explicit publisher');
|
|
869
|
+
|
|
870
|
+
const cwd = resolvePackageCwd(action.cwd, context.root);
|
|
871
|
+
const { stdout: whoamiStdout } = await exec(
|
|
872
|
+
'npm',
|
|
873
|
+
['whoami', '--registry', normalizedRegistry],
|
|
874
|
+
{ cwd, shell: false },
|
|
875
|
+
);
|
|
876
|
+
const publisher = whoamiStdout.trim();
|
|
877
|
+
if (publisher !== action.publisher) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
`npm whoami returned "${publisher}" but expected publisher "${action.publisher}" for registry ${normalizedRegistry}`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
const args = ['view', `${action.package}@${action.version}`, 'version', 'dist.integrity', 'dist.tarball', '--json'];
|
|
883
|
+
args.push('--registry', normalizedRegistry);
|
|
884
|
+
|
|
885
|
+
let stdout;
|
|
886
|
+
try {
|
|
887
|
+
({ stdout } = await exec('npm', args, { cwd, shell: false }));
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// Only an E404 from the exact frozen package@version view is trusted
|
|
890
|
+
// as proof of absence. Auth, whoami, cwd, registry, parsing, and all
|
|
891
|
+
// other failures remain unknown and must never authorize a retry.
|
|
892
|
+
if (isNotFound(err)) {
|
|
893
|
+
return createResult({
|
|
894
|
+
actionType: action.actionType,
|
|
895
|
+
status: ActionStatus.OBSERVED,
|
|
896
|
+
observation: {
|
|
897
|
+
exists: false,
|
|
898
|
+
package: action.package,
|
|
899
|
+
version: action.version,
|
|
900
|
+
registry: normalizedRegistry,
|
|
901
|
+
},
|
|
902
|
+
error: null,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
throw err;
|
|
906
|
+
}
|
|
907
|
+
const data = JSON.parse(stdout);
|
|
908
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
909
|
+
throw new Error('npm observe returned a non-object JSON response');
|
|
910
|
+
}
|
|
911
|
+
if (data.version !== action.version) {
|
|
912
|
+
throw new Error(`npm observe version mismatch: expected ${action.version}`);
|
|
913
|
+
}
|
|
914
|
+
const integrity = data['dist.integrity'] ?? data.integrity;
|
|
915
|
+
if (typeof integrity !== 'string' || integrity.length === 0) {
|
|
916
|
+
throw new Error('npm observe response is missing dist.integrity');
|
|
917
|
+
}
|
|
918
|
+
return createResult({
|
|
919
|
+
actionType: action.actionType,
|
|
920
|
+
status: ActionStatus.OBSERVED,
|
|
921
|
+
observation: {
|
|
922
|
+
package: action.package,
|
|
923
|
+
version: data.version,
|
|
924
|
+
integrity,
|
|
925
|
+
tarball: data['dist.tarball'] ?? data.tarball ?? null,
|
|
926
|
+
registry: normalizedRegistry,
|
|
927
|
+
publisher,
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
} catch (err) {
|
|
931
|
+
return createResult({ actionType: action.actionType, status: ActionStatus.OBSERVED, observation: {}, error: err.message });
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
|
|
935
|
+
async verify(action, context) {
|
|
936
|
+
const observed = await this.observe(action, context);
|
|
937
|
+
if (observed.error) return createResult({ actionType: action.actionType, status: ActionStatus.VERIFY_FAILED, observation: observed.observation, error: observed.error });
|
|
938
|
+
const comparison = matchObservation(action.expected ?? {}, observed.observation);
|
|
939
|
+
return createResult({
|
|
940
|
+
actionType: action.actionType,
|
|
941
|
+
status: comparison.matches ? ActionStatus.VERIFIED : ActionStatus.VERIFY_FAILED,
|
|
942
|
+
observation: observed.observation,
|
|
943
|
+
error: comparison.matches ? null : comparison.mismatches.join('; '),
|
|
944
|
+
});
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
}
|