cinatra 0.1.0
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/LICENSE +202 -0
- package/README.md +77 -0
- package/bin/cinatra.mjs +8 -0
- package/package.json +32 -0
- package/src/agents-install.mjs +801 -0
- package/src/checkout-resolve.mjs +236 -0
- package/src/cinatra-dev-extensions.mjs +338 -0
- package/src/clone-registry.mjs +623 -0
- package/src/clone-runtime.mjs +543 -0
- package/src/command-table.mjs +390 -0
- package/src/dev-apps.mjs +79 -0
- package/src/dev-cli-modules.mjs +91 -0
- package/src/dev-refresh.mjs +117 -0
- package/src/dev-repo-sync.mjs +297 -0
- package/src/extensions-dependency-gate.mjs +258 -0
- package/src/extensions-submit.mjs +137 -0
- package/src/index.mjs +9203 -0
- package/src/install.mjs +815 -0
- package/src/login.mjs +508 -0
- package/src/marketplace-mcp.mjs +100 -0
- package/src/mcp-public-base-url-shape.mjs +134 -0
- package/src/prod-extension-acquisition.mjs +679 -0
- package/src/seed-local-registry.mjs +538 -0
- package/src/tailscale-provision.mjs +219 -0
- package/src/teardown-config.mjs +113 -0
- package/src/worktree-collision-guard.mjs +157 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
// Production required-extension acquisition.
|
|
2
|
+
//
|
|
3
|
+
// The prod base image needs the full "bootable set" of extension packages on
|
|
4
|
+
// disk at build time: every package declared in `cinatra.extensions`
|
|
5
|
+
// (root package.json). That set is the union of the genuine system packages
|
|
6
|
+
// and every extension package the host source still value-imports — see the
|
|
7
|
+
// coverage gate (scripts/audit/required-extensions-cover-host-imports.mjs),
|
|
8
|
+
// which keeps the declaration honest against the real import graph.
|
|
9
|
+
//
|
|
10
|
+
// Dev acquires those packages as tracking git clones (cinatra-dev-extensions);
|
|
11
|
+
// PROD must not depend on a git/gh binary or movable refs. This module
|
|
12
|
+
// downloads each package as a GitHub codeload tarball pinned to an immutable
|
|
13
|
+
// commit SHA — which removes the git-binary dependency, NOT the dependency on
|
|
14
|
+
// reaching github.com: a genuinely air-gapped build still needs a vendored
|
|
15
|
+
// bundle or a private mirror. Each tarball is verified against a committed
|
|
16
|
+
// lockfile,
|
|
17
|
+
// `cinatra-required-extensions.lock.json` (regenerated by
|
|
18
|
+
// scripts/extensions/update-required-extension-lock.mjs). Prod consumes ONLY
|
|
19
|
+
// the lock — never `main`/`latest`, never the version ranges.
|
|
20
|
+
//
|
|
21
|
+
// Integrity model (deliberately NOT `dist.cinatraSignature` — that signature
|
|
22
|
+
// covers the npm registry tarball used by the runtime marketplace install
|
|
23
|
+
// pipeline, a different artifact and a separate path that stays separate):
|
|
24
|
+
// 1. the download URL embeds the pinned commit SHA (an immutable ref);
|
|
25
|
+
// 2. the whole archive is inspected IN MEMORY before anything touches disk:
|
|
26
|
+
// entry types, paths, and sizes are validated (see below) and a
|
|
27
|
+
// deterministic content hash of the delivered file tree is computed and
|
|
28
|
+
// compared against the lock's `treeSha256`;
|
|
29
|
+
// 3. the extracted `package.json` must carry the locked `name` + `version`;
|
|
30
|
+
// 4. only then is the verified archive extracted (to a temp dir, renamed
|
|
31
|
+
// into place atomically, and stamped with a marker that later runs
|
|
32
|
+
// RE-VERIFY rather than trust).
|
|
33
|
+
//
|
|
34
|
+
// Archive hardening (fail-closed, never silently dropped): an entry that is
|
|
35
|
+
// not a plain file or directory (symlink, hardlink, FIFO, device), an
|
|
36
|
+
// absolute path, a `..` traversal segment, an entry outside the single
|
|
37
|
+
// archive root directory, or a reserved marker filename is a hard failure.
|
|
38
|
+
// Bounded resources: compressed size, decompressed size, per-file size, and
|
|
39
|
+
// entry count are all capped before extraction, so a hostile response cannot
|
|
40
|
+
// fill the disk.
|
|
41
|
+
//
|
|
42
|
+
// This module is data-driven end to end: package identities come from the
|
|
43
|
+
// lockfile, never from code (no host->extension coupling is added here).
|
|
44
|
+
// `tar` (a root dependency) is imported lazily AFTER the entry guards so
|
|
45
|
+
// `cinatra setup prod` inside the standalone runtime image — detected
|
|
46
|
+
// POSITIVELY by `server.js` + `.next/` at the root (Next's file tracing
|
|
47
|
+
// copies pnpm-workspace.yaml into the standalone output, so "no workspace
|
|
48
|
+
// file" alone is NOT a reliable standalone signal), where the `tar`
|
|
49
|
+
// dependency may not exist — skips cleanly.
|
|
50
|
+
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
52
|
+
import {
|
|
53
|
+
chmodSync,
|
|
54
|
+
existsSync,
|
|
55
|
+
mkdirSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
readdirSync,
|
|
58
|
+
renameSync,
|
|
59
|
+
rmSync,
|
|
60
|
+
statSync,
|
|
61
|
+
writeFileSync,
|
|
62
|
+
} from "node:fs";
|
|
63
|
+
import path from "node:path";
|
|
64
|
+
import { createGunzip } from "node:zlib";
|
|
65
|
+
|
|
66
|
+
import { destDirForExtension } from "./cinatra-dev-extensions.mjs";
|
|
67
|
+
|
|
68
|
+
export const LOCK_FILENAME = "cinatra-required-extensions.lock.json";
|
|
69
|
+
export const ACQUISITION_MARKER_FILENAME = ".cinatra-acquired.json";
|
|
70
|
+
|
|
71
|
+
// Resource bounds. Extension repos are small (single-digit MiB); these caps
|
|
72
|
+
// are generous headroom, not tuning knobs — they exist so a compromised or
|
|
73
|
+
// malfunctioning endpoint cannot exhaust memory/disk before the integrity
|
|
74
|
+
// check fails the run.
|
|
75
|
+
export const MAX_COMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
76
|
+
export const MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
77
|
+
export const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
78
|
+
export const MAX_ENTRY_COUNT = 20_000;
|
|
79
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
80
|
+
|
|
81
|
+
const SCOPED_PKG_RE = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
|
|
82
|
+
const REPO_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
83
|
+
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
|
|
84
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
85
|
+
const CONCRETE_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read + strictly validate the committed lock. Every entry must carry a
|
|
89
|
+
* well-formed scoped package name, an `owner/repo` slug, a 40-hex commit SHA,
|
|
90
|
+
* a concrete x.y.z version, and a 64-hex tree hash; duplicates are rejected.
|
|
91
|
+
* Throws (listing every defect) rather than skipping — prod consumes ONLY
|
|
92
|
+
* this file, so a malformed lock must never half-acquire.
|
|
93
|
+
*/
|
|
94
|
+
export function readRequiredExtensionsLock(lockPath) {
|
|
95
|
+
if (!existsSync(lockPath)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[prod-extension-acquisition] lockfile not found: ${lockPath}. The committed ` +
|
|
98
|
+
`${LOCK_FILENAME} is the ONLY source prod acquires from — regenerate it with ` +
|
|
99
|
+
`\`node scripts/extensions/update-required-extension-lock.mjs\` and commit it.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
let doc;
|
|
103
|
+
try {
|
|
104
|
+
doc = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new Error(`[prod-extension-acquisition] lockfile ${lockPath} is not valid JSON: ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
const packages = Array.isArray(doc?.packages) ? doc.packages : null;
|
|
109
|
+
if (!packages || packages.length === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`[prod-extension-acquisition] lockfile ${lockPath} has no "packages" entries — refusing to continue.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const defects = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const [i, p] of packages.entries()) {
|
|
117
|
+
const tag = `packages[${i}]${p && typeof p.packageName === "string" ? ` (${p.packageName})` : ""}`;
|
|
118
|
+
if (!p || typeof p !== "object") {
|
|
119
|
+
defects.push(`${tag}: not an object`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (typeof p.packageName !== "string" || !SCOPED_PKG_RE.test(p.packageName)) {
|
|
123
|
+
defects.push(`${tag}: packageName must be a lowercase @scope/name`);
|
|
124
|
+
} else if (seen.has(p.packageName)) {
|
|
125
|
+
defects.push(`${tag}: duplicate packageName`);
|
|
126
|
+
} else {
|
|
127
|
+
seen.add(p.packageName);
|
|
128
|
+
}
|
|
129
|
+
if (typeof p.repo !== "string" || !REPO_SLUG_RE.test(p.repo) || p.repo.includes("..")) {
|
|
130
|
+
defects.push(`${tag}: repo must be an "owner/name" GitHub slug`);
|
|
131
|
+
}
|
|
132
|
+
if (typeof p.resolvedSha !== "string" || !COMMIT_SHA_RE.test(p.resolvedSha)) {
|
|
133
|
+
defects.push(`${tag}: resolvedSha must be a 40-hex lowercase commit SHA`);
|
|
134
|
+
}
|
|
135
|
+
if (typeof p.packageVersion !== "string" || !CONCRETE_VERSION_RE.test(p.packageVersion)) {
|
|
136
|
+
defects.push(`${tag}: packageVersion must be a concrete x.y.z version`);
|
|
137
|
+
}
|
|
138
|
+
if (typeof p.treeSha256 !== "string" || !SHA256_RE.test(p.treeSha256)) {
|
|
139
|
+
defects.push(`${tag}: treeSha256 must be a 64-hex lowercase sha256`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (defects.length > 0) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[prod-extension-acquisition] lockfile ${lockPath} failed validation:\n - ${defects.join("\n - ")}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return { schemaVersion: doc.schemaVersion ?? null, packages };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The declared `cinatra.extensions` package NAMES (ranges stripped —
|
|
152
|
+
* the same last-`@` split as the canonical host parser in
|
|
153
|
+
* packages/extensions/src/required-in-prod.ts). Returns an empty set when the
|
|
154
|
+
* manifest or block is absent/unreadable (the caller treats that as
|
|
155
|
+
* "nothing to cross-check", never as an acquisition failure).
|
|
156
|
+
*/
|
|
157
|
+
export function readDeclaredRequiredExtensionNames(packageJsonPath) {
|
|
158
|
+
try {
|
|
159
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
160
|
+
const raw = Array.isArray(pkg?.cinatra?.extensions) ? pkg.cinatra.extensions : [];
|
|
161
|
+
const names = new Set();
|
|
162
|
+
for (const entry of raw) {
|
|
163
|
+
if (typeof entry !== "string" || entry.trim().length === 0) continue;
|
|
164
|
+
const trimmed = entry.trim();
|
|
165
|
+
const at = trimmed.lastIndexOf("@");
|
|
166
|
+
names.add(at <= 0 ? trimmed : trimmed.slice(0, at));
|
|
167
|
+
}
|
|
168
|
+
return names;
|
|
169
|
+
} catch {
|
|
170
|
+
return new Set();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Canonical tree-hash fold shared by every producer (archive inspection,
|
|
176
|
+
* disk re-verification, the lock regenerator). Input records are
|
|
177
|
+
* `{ relPath, executable, sha256 }` for REGULAR FILES ONLY; the fold sorts by
|
|
178
|
+
* relPath and hashes `<relPath>\n<gitMode>\n<contentSha256>\n` per file, so
|
|
179
|
+
* the result is independent of archive entry order and filesystem walk order.
|
|
180
|
+
* Modes are normalized to git's two file modes (100755 when the owner-exec
|
|
181
|
+
* bit is set, else 100644) so umask differences cannot shift the hash.
|
|
182
|
+
* Directories (incl. empty ones) are not hashed — git archives do not
|
|
183
|
+
* meaningfully carry them.
|
|
184
|
+
*/
|
|
185
|
+
export function foldTreeHash(records) {
|
|
186
|
+
const sorted = [...records].sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
|
|
187
|
+
const hash = createHash("sha256");
|
|
188
|
+
for (const r of sorted) {
|
|
189
|
+
hash.update(`${r.relPath}\n${r.executable ? "100755" : "100644"}\n${r.sha256}\n`);
|
|
190
|
+
}
|
|
191
|
+
return hash.digest("hex");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compute the canonical tree hash of an on-disk package directory (the
|
|
196
|
+
* marker-hit RE-VERIFICATION path). The acquisition marker at the package
|
|
197
|
+
* root is excluded (it is written by us after verification, never part of
|
|
198
|
+
* the upstream tree). Any directory entry that is not a regular file or a
|
|
199
|
+
* directory (e.g. a symlink introduced after acquisition) is a hard error —
|
|
200
|
+
* a verified tree never contains one.
|
|
201
|
+
*/
|
|
202
|
+
export function computeTreeSha256FromDir(rootDir) {
|
|
203
|
+
const records = [];
|
|
204
|
+
const walk = (dir) => {
|
|
205
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
206
|
+
const full = path.join(dir, dirent.name);
|
|
207
|
+
const rel = path.relative(rootDir, full).split(path.sep).join("/");
|
|
208
|
+
if (rel === ACQUISITION_MARKER_FILENAME) continue;
|
|
209
|
+
if (dirent.isDirectory()) {
|
|
210
|
+
walk(full);
|
|
211
|
+
} else if (dirent.isFile()) {
|
|
212
|
+
const st = statSync(full);
|
|
213
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
214
|
+
throw new Error(`[prod-extension-acquisition] ${rel} exceeds the per-file size bound`);
|
|
215
|
+
}
|
|
216
|
+
records.push({
|
|
217
|
+
relPath: rel,
|
|
218
|
+
executable: (st.mode & 0o100) !== 0,
|
|
219
|
+
sha256: createHash("sha256").update(readFileSync(full)).digest("hex"),
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[prod-extension-acquisition] unexpected non-regular entry in acquired tree: ${rel} ` +
|
|
224
|
+
`(symlinks/devices are never part of a verified acquisition)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
walk(rootDir);
|
|
230
|
+
return foldTreeHash(records);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Decompress a gzip buffer with a hard output bound (zip-bomb guard). */
|
|
234
|
+
export function gunzipBounded(gzBuffer, maxBytes = MAX_DECOMPRESSED_BYTES) {
|
|
235
|
+
return new Promise((resolvePromise, reject) => {
|
|
236
|
+
const gunzip = createGunzip();
|
|
237
|
+
const chunks = [];
|
|
238
|
+
let total = 0;
|
|
239
|
+
gunzip.on("data", (chunk) => {
|
|
240
|
+
total += chunk.length;
|
|
241
|
+
if (total > maxBytes) {
|
|
242
|
+
gunzip.destroy();
|
|
243
|
+
reject(
|
|
244
|
+
new Error(`[prod-extension-acquisition] decompressed archive exceeds the ${maxBytes}-byte bound`),
|
|
245
|
+
);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
chunks.push(chunk);
|
|
249
|
+
});
|
|
250
|
+
gunzip.on("error", (err) => reject(new Error(`[prod-extension-acquisition] gunzip failed: ${err.message}`)));
|
|
251
|
+
gunzip.on("end", () => resolvePromise(Buffer.concat(chunks)));
|
|
252
|
+
gunzip.end(gzBuffer);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Download a URL into a buffer with a size bound and timeout; fail loud. */
|
|
257
|
+
export async function downloadBounded(url, { fetchImpl = globalThis.fetch, maxBytes = MAX_COMPRESSED_BYTES } = {}) {
|
|
258
|
+
let res;
|
|
259
|
+
try {
|
|
260
|
+
res = await fetchImpl(url, { redirect: "follow", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
throw new Error(`[prod-extension-acquisition] fetch ${url} failed: ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
if (!res.ok || !res.body) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`[prod-extension-acquisition] fetch ${url} failed: HTTP ${res.status} ${res.statusText ?? ""}`.trim(),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const declared = Number(res.headers?.get?.("content-length") ?? 0);
|
|
270
|
+
if (declared > maxBytes) {
|
|
271
|
+
throw new Error(`[prod-extension-acquisition] ${url}: declared size ${declared} exceeds the ${maxBytes}-byte bound`);
|
|
272
|
+
}
|
|
273
|
+
const chunks = [];
|
|
274
|
+
let total = 0;
|
|
275
|
+
for await (const chunk of res.body) {
|
|
276
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
277
|
+
total += buf.length;
|
|
278
|
+
if (total > maxBytes) {
|
|
279
|
+
throw new Error(`[prod-extension-acquisition] ${url}: download exceeds the ${maxBytes}-byte bound`);
|
|
280
|
+
}
|
|
281
|
+
chunks.push(buf);
|
|
282
|
+
}
|
|
283
|
+
return Buffer.concat(chunks);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate one raw tar entry path against the hardening rules. Returns
|
|
288
|
+
* `{ stripped }` (the path with the single archive-root directory removed,
|
|
289
|
+
* "" for the root directory itself) or records a violation. Exported for
|
|
290
|
+
* direct unit testing of the path rules.
|
|
291
|
+
*/
|
|
292
|
+
export function classifyEntryPath(rawPath) {
|
|
293
|
+
const normalized = String(rawPath).replace(/^\.\//, "");
|
|
294
|
+
if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized) || normalized.startsWith("\\")) {
|
|
295
|
+
return { violation: "absolute path" };
|
|
296
|
+
}
|
|
297
|
+
const segments = normalized.split("/").filter((s) => s.length > 0);
|
|
298
|
+
if (segments.some((s) => s === "..")) {
|
|
299
|
+
return { violation: "path traversal (`..` segment)" };
|
|
300
|
+
}
|
|
301
|
+
if (segments.length === 0) {
|
|
302
|
+
return { violation: "empty path" };
|
|
303
|
+
}
|
|
304
|
+
return { stripped: segments.slice(1).join("/") };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* PASS 1 — inspect the (already size-bounded, decompressed) tar buffer
|
|
309
|
+
* entirely in memory: enforce the entry hardening rules, compute the
|
|
310
|
+
* canonical tree hash, and capture the root `package.json` bytes. Nothing is
|
|
311
|
+
* written to disk; a violation list (never a silent drop) comes back to the
|
|
312
|
+
* caller, which must treat ANY violation as fatal.
|
|
313
|
+
*/
|
|
314
|
+
export async function inspectTarball(tarBuffer, { tar }) {
|
|
315
|
+
const violations = [];
|
|
316
|
+
const records = [];
|
|
317
|
+
let entryCount = 0;
|
|
318
|
+
let packageJsonRaw = null;
|
|
319
|
+
|
|
320
|
+
const parser = tar.t({
|
|
321
|
+
onReadEntry: (entry) => {
|
|
322
|
+
entryCount += 1;
|
|
323
|
+
if (entryCount > MAX_ENTRY_COUNT) {
|
|
324
|
+
violations.push(`entry count exceeds ${MAX_ENTRY_COUNT}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const { stripped, violation } = classifyEntryPath(entry.path);
|
|
328
|
+
if (violation) {
|
|
329
|
+
violations.push(`${entry.path}: ${violation}`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Mode hardening. Git stores only the owner-exec distinction; the
|
|
333
|
+
// archive writer's umask decides the rest (GitHub codeload emits
|
|
334
|
+
// 664/775). setuid/setgid/sticky bits, however, can NEVER come from a
|
|
335
|
+
// git tree — an archive carrying one is hostile and is rejected here;
|
|
336
|
+
// everything else is NORMALIZED to the canonical 0644/0755 at
|
|
337
|
+
// extraction (see extractVerifiedTarball), so the applied metadata is
|
|
338
|
+
// exactly what the tree hash describes.
|
|
339
|
+
const perms = (entry.mode ?? 0) & 0o7777;
|
|
340
|
+
if ((perms & 0o7000) !== 0) {
|
|
341
|
+
violations.push(
|
|
342
|
+
`${entry.path}: forbidden special mode bits in ${perms.toString(8)} ` +
|
|
343
|
+
`(setuid/setgid/sticky can never originate from a git tree)`,
|
|
344
|
+
);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (entry.type === "Directory") {
|
|
348
|
+
return; // directories (incl. the single archive root) carry no content
|
|
349
|
+
}
|
|
350
|
+
if (stripped === "") {
|
|
351
|
+
violations.push(`${entry.path}: non-directory entry at the archive root`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (entry.type !== "File") {
|
|
355
|
+
violations.push(`${entry.path}: forbidden entry type "${entry.type}" (symlink/hardlink/device/FIFO)`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (stripped === ACQUISITION_MARKER_FILENAME) {
|
|
359
|
+
violations.push(`${entry.path}: reserved acquisition-marker filename in archive`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (typeof entry.size === "number" && entry.size > MAX_FILE_BYTES) {
|
|
363
|
+
violations.push(`${entry.path}: file exceeds the per-file size bound`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const contentHash = createHash("sha256");
|
|
367
|
+
const wantPackageJson = stripped === "package.json";
|
|
368
|
+
const packageJsonChunks = wantPackageJson ? [] : null;
|
|
369
|
+
entry.on("data", (chunk) => {
|
|
370
|
+
contentHash.update(chunk);
|
|
371
|
+
if (packageJsonChunks) packageJsonChunks.push(Buffer.from(chunk));
|
|
372
|
+
});
|
|
373
|
+
entry.on("end", () => {
|
|
374
|
+
records.push({
|
|
375
|
+
relPath: stripped,
|
|
376
|
+
executable: ((entry.mode ?? 0) & 0o100) !== 0,
|
|
377
|
+
sha256: contentHash.digest("hex"),
|
|
378
|
+
});
|
|
379
|
+
if (packageJsonChunks) packageJsonRaw = Buffer.concat(packageJsonChunks).toString("utf8");
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await new Promise((resolvePromise, reject) => {
|
|
385
|
+
parser.on("error", (err) => reject(new Error(`[prod-extension-acquisition] tar parse failed: ${err.message}`)));
|
|
386
|
+
// node-tar list/parse streams emit "end"; "finish" is not reliably emitted.
|
|
387
|
+
parser.on("end", resolvePromise);
|
|
388
|
+
parser.end(tarBuffer);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return { records, entryCount, packageJsonRaw, violations };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* PASS 2 — extract the ALREADY-VERIFIED tar buffer into `destDir`. The same
|
|
396
|
+
* entry rules are enforced again as defense in depth (filter excludes; pass 1
|
|
397
|
+
* is the authority that FAILS the run).
|
|
398
|
+
*/
|
|
399
|
+
export async function extractVerifiedTarball(tarBuffer, destDir, { tar }) {
|
|
400
|
+
mkdirSync(destDir, { recursive: true });
|
|
401
|
+
await new Promise((resolvePromise, reject) => {
|
|
402
|
+
const extractor = tar.x({
|
|
403
|
+
cwd: destDir,
|
|
404
|
+
strip: 1,
|
|
405
|
+
// Never apply archive uid/gid: node-tar PRESERVES ownership when the
|
|
406
|
+
// process runs as root (the Docker build does) unless told otherwise.
|
|
407
|
+
preserveOwner: false,
|
|
408
|
+
// Normalize every applied mode to the canonical git pair (0755 dirs,
|
|
409
|
+
// 0644/0755 files keyed on the owner-exec bit) — the archive writer's
|
|
410
|
+
// umask (codeload emits 664/775) and any other permission noise never
|
|
411
|
+
// reach disk, so the applied metadata is exactly what the tree hash
|
|
412
|
+
// describes. Special bits were already rejected during inspection.
|
|
413
|
+
onReadEntry: (entry) => {
|
|
414
|
+
entry.mode = entry.type === "Directory" ? 0o755 : ((entry.mode ?? 0) & 0o100) !== 0 ? 0o755 : 0o644;
|
|
415
|
+
},
|
|
416
|
+
filter: (entryPath, entry) => {
|
|
417
|
+
const { stripped, violation } = classifyEntryPath(entryPath);
|
|
418
|
+
if (violation) return false;
|
|
419
|
+
if (((entry.mode ?? 0) & 0o7000) !== 0) return false;
|
|
420
|
+
if (entry.type === "Directory") return true;
|
|
421
|
+
return entry.type === "File" && stripped !== "" && stripped !== ACQUISITION_MARKER_FILENAME;
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
extractor.on("error", (err) => reject(new Error(`[prod-extension-acquisition] extraction failed: ${err.message}`)));
|
|
425
|
+
extractor.on("finish", resolvePromise);
|
|
426
|
+
extractor.end(tarBuffer);
|
|
427
|
+
});
|
|
428
|
+
// node-tar applies the process umask at file creation, so the normalized
|
|
429
|
+
// entry modes can land narrower on disk (e.g. 0600 under umask 077) —
|
|
430
|
+
// harmless but not canonical. Walk the extracted tree once and chmod every
|
|
431
|
+
// node to the exact git pair keyed on the (umask-surviving) owner-exec
|
|
432
|
+
// bit, so the applied modes are deterministic for any builder umask.
|
|
433
|
+
// (A umask hostile enough to strip owner bits changes the exec-bit hash
|
|
434
|
+
// input and fails the re-hash that follows extraction — fail closed.)
|
|
435
|
+
const normalizeModes = (dir) => {
|
|
436
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
437
|
+
const full = path.join(dir, dirent.name);
|
|
438
|
+
if (dirent.isDirectory()) {
|
|
439
|
+
chmodSync(full, 0o755);
|
|
440
|
+
normalizeModes(full);
|
|
441
|
+
} else if (dirent.isFile()) {
|
|
442
|
+
chmodSync(full, (statSync(full).mode & 0o100) !== 0 ? 0o755 : 0o644);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
normalizeModes(destDir);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function readMarker(destDir) {
|
|
450
|
+
const markerPath = path.join(destDir, ACQUISITION_MARKER_FILENAME);
|
|
451
|
+
if (!existsSync(markerPath)) return null;
|
|
452
|
+
try {
|
|
453
|
+
const m = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
454
|
+
return m && typeof m === "object" ? m : null;
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function verifyPackageManifest(rawPackageJson, lockEntry, label) {
|
|
461
|
+
if (typeof rawPackageJson !== "string" || rawPackageJson.length === 0) {
|
|
462
|
+
throw new Error(`[prod-extension-acquisition] ${label}: archive carries no root package.json`);
|
|
463
|
+
}
|
|
464
|
+
let manifest;
|
|
465
|
+
try {
|
|
466
|
+
manifest = JSON.parse(rawPackageJson);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
throw new Error(`[prod-extension-acquisition] ${label}: root package.json is not valid JSON: ${err.message}`);
|
|
469
|
+
}
|
|
470
|
+
if (manifest.name !== lockEntry.packageName) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`[prod-extension-acquisition] ${label}: package.json name "${manifest.name}" does not match the locked ` +
|
|
473
|
+
`packageName "${lockEntry.packageName}"`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (manifest.version !== lockEntry.packageVersion) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`[prod-extension-acquisition] ${label}: package.json version "${manifest.version}" does not match the ` +
|
|
479
|
+
`locked packageVersion "${lockEntry.packageVersion}"`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Acquire every package in the committed lock into `extensions/<scope>/<name>`.
|
|
486
|
+
*
|
|
487
|
+
* - In a non-workspace root (the Next standalone runtime image, where the
|
|
488
|
+
* extension source was baked in at image build), returns
|
|
489
|
+
* `{ skipped: true, reason: "not-a-workspace" }` without importing `tar`.
|
|
490
|
+
* - Idempotent: a directory stamped by a marker matching the lock is
|
|
491
|
+
* RE-VERIFIED (tree hash + manifest) and skipped; a marker that matches a
|
|
492
|
+
* STALE lock entry triggers a clean re-acquisition; a directory WITHOUT a
|
|
493
|
+
* marker (e.g. a dev git clone) is a hard error — this routine never
|
|
494
|
+
* clobbers a tree it does not own.
|
|
495
|
+
* - Fail-fast and loud on network errors, HTTP failures, unsafe archives,
|
|
496
|
+
* hash/manifest mismatches. A failed run leaves previously verified
|
|
497
|
+
* packages in place (markers make a re-run resume cheaply).
|
|
498
|
+
*
|
|
499
|
+
* Returns `{ results: [{ pkgName, action, changed, dest }] }`, the shape
|
|
500
|
+
* `installAfterExtensionSync` consumes ("downloaded" counts as materially
|
|
501
|
+
* changed; "verified-existing" does not).
|
|
502
|
+
*/
|
|
503
|
+
export async function acquireProdRequiredExtensions({
|
|
504
|
+
repoRoot,
|
|
505
|
+
lockPath,
|
|
506
|
+
fetchImpl = globalThis.fetch,
|
|
507
|
+
log = console.log,
|
|
508
|
+
} = {}) {
|
|
509
|
+
if (!repoRoot) throw new Error("[prod-extension-acquisition] repoRoot is required");
|
|
510
|
+
// Standalone runtime image — POSITIVE detection, checked FIRST. Next's
|
|
511
|
+
// output-file tracing mirrors the project root into .next/standalone
|
|
512
|
+
// INCLUDING pnpm-workspace.yaml (so "workspace file absent" is NOT a
|
|
513
|
+
// reliable standalone detector) and INCLUDING the host-imported extension
|
|
514
|
+
// sources WITHOUT their acquisition markers (tracing copies only the
|
|
515
|
+
// imported files) — running the acquisition there would refuse on
|
|
516
|
+
// "exists but is not acquisition-managed" and brick `setup prod` on a
|
|
517
|
+
// perfectly good image (caught by scripts/ci/prod-boot-e2e.sh). The
|
|
518
|
+
// standalone root is identified by the traced server entry `server.js`
|
|
519
|
+
// sitting NEXT TO `.next/` — true only for the standalone output (a real
|
|
520
|
+
// repo root has no root-level server.js; the build stage's standalone
|
|
521
|
+
// output lives nested under .next/standalone/, not at the build root).
|
|
522
|
+
if (
|
|
523
|
+
existsSync(path.join(repoRoot, "server.js")) &&
|
|
524
|
+
existsSync(path.join(repoRoot, ".next"))
|
|
525
|
+
) {
|
|
526
|
+
log(
|
|
527
|
+
"- Required-extension acquisition: skipped (standalone runtime image — the extension " +
|
|
528
|
+
"source was baked and verified at image build time).",
|
|
529
|
+
);
|
|
530
|
+
return { skipped: true, reason: "standalone-runtime-image" };
|
|
531
|
+
}
|
|
532
|
+
if (!existsSync(path.join(repoRoot, "pnpm-workspace.yaml"))) {
|
|
533
|
+
log(
|
|
534
|
+
"- Required-extension acquisition: skipped (no pnpm workspace at this root — the standalone " +
|
|
535
|
+
"runtime image bakes the extension source at image build time).",
|
|
536
|
+
);
|
|
537
|
+
return { skipped: true, reason: "not-a-workspace" };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const lock = readRequiredExtensionsLock(lockPath ?? path.join(repoRoot, LOCK_FILENAME));
|
|
541
|
+
|
|
542
|
+
// The lock must match the declaration it was generated from. Enforcing the
|
|
543
|
+
// bijection HERE (not only in the CI coverage gate) means the image build
|
|
544
|
+
// itself fails on a drifted lock — the publish path cannot outrun a
|
|
545
|
+
// forgotten `update-required-extension-lock` regeneration. Only a root with
|
|
546
|
+
// NO package.json at all skips the cross-check (unit-test scratch roots);
|
|
547
|
+
// a real workspace manifest that declares nothing while the lock pins
|
|
548
|
+
// packages is itself drift and fails loud.
|
|
549
|
+
const rootManifestPath = path.join(repoRoot, "package.json");
|
|
550
|
+
if (existsSync(rootManifestPath)) {
|
|
551
|
+
const declared = readDeclaredRequiredExtensionNames(rootManifestPath);
|
|
552
|
+
const lockedNames = new Set(lock.packages.map((p) => p.packageName));
|
|
553
|
+
const missingFromLock = [...declared].filter((n) => !lockedNames.has(n)).sort();
|
|
554
|
+
const staleInLock = [...lockedNames].filter((n) => !declared.has(n)).sort();
|
|
555
|
+
if (missingFromLock.length > 0 || staleInLock.length > 0) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`[prod-extension-acquisition] the acquisition lock does not match cinatra.extensions:` +
|
|
558
|
+
(missingFromLock.length ? `\n declared but not locked: ${missingFromLock.join(", ")}` : "") +
|
|
559
|
+
(staleInLock.length ? `\n locked but not declared: ${staleInLock.join(", ")}` : "") +
|
|
560
|
+
`\nRegenerate with \`node scripts/extensions/update-required-extension-lock.mjs\` and commit the lock.`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Lazy: `tar` is a root workspace dependency, intentionally not resolvable
|
|
566
|
+
// in the standalone runtime image (the guard above returns first there).
|
|
567
|
+
const tar = await import("tar");
|
|
568
|
+
|
|
569
|
+
const results = [];
|
|
570
|
+
let downloaded = 0;
|
|
571
|
+
let verified = 0;
|
|
572
|
+
log(`- Required-extension acquisition: ${lock.packages.length} locked package(s)…`);
|
|
573
|
+
|
|
574
|
+
for (const entry of lock.packages) {
|
|
575
|
+
const dest = destDirForExtension(entry.packageName, {}, repoRoot);
|
|
576
|
+
const label = `${entry.packageName}@${entry.packageVersion} (${entry.repo}#${entry.resolvedSha.slice(0, 12)})`;
|
|
577
|
+
|
|
578
|
+
if (existsSync(dest)) {
|
|
579
|
+
const marker = readMarker(dest);
|
|
580
|
+
if (!marker) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`[prod-extension-acquisition] ${dest} exists but is not acquisition-managed (no ` +
|
|
583
|
+
`${ACQUISITION_MARKER_FILENAME}). Refusing to overwrite — if this is a dev checkout, use the dev ` +
|
|
584
|
+
`setup flow; otherwise remove the directory and re-run.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
if (marker.resolvedSha === entry.resolvedSha && marker.treeSha256 === entry.treeSha256) {
|
|
588
|
+
// Marker hit is a CLAIM, not proof — re-verify content before trusting.
|
|
589
|
+
const actualTreeSha = computeTreeSha256FromDir(dest);
|
|
590
|
+
if (actualTreeSha !== entry.treeSha256) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`[prod-extension-acquisition] ${label}: on-disk tree hash ${actualTreeSha} does not match the ` +
|
|
593
|
+
`locked treeSha256 ${entry.treeSha256} (content changed after acquisition). Remove ${dest} and re-run.`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const manifestPath = path.join(dest, "package.json");
|
|
597
|
+
verifyPackageManifest(existsSync(manifestPath) ? readFileSync(manifestPath, "utf8") : "", entry, label);
|
|
598
|
+
results.push({ pkgName: entry.packageName, action: "verified-existing", changed: false, dest });
|
|
599
|
+
verified += 1;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
// Acquisition-managed but pinned elsewhere: the lock moved — re-acquire.
|
|
603
|
+
// The stale tree is NOT removed yet: it stays in place until the
|
|
604
|
+
// replacement has been fully downloaded and verified, so a transient
|
|
605
|
+
// network/integrity failure cannot leave the slot empty.
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const url = `https://codeload.github.com/${entry.repo}/tar.gz/${entry.resolvedSha}`;
|
|
609
|
+
log(` - ${label}: downloading…`);
|
|
610
|
+
const gzBuffer = await downloadBounded(url, { fetchImpl });
|
|
611
|
+
const tarBuffer = await gunzipBounded(gzBuffer);
|
|
612
|
+
const { records, packageJsonRaw, violations } = await inspectTarball(tarBuffer, { tar });
|
|
613
|
+
if (violations.length > 0) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`[prod-extension-acquisition] ${label}: unsafe archive from ${url}:\n - ${violations.join("\n - ")}`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const treeSha = foldTreeHash(records);
|
|
619
|
+
if (treeSha !== entry.treeSha256) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
`[prod-extension-acquisition] ${label}: tree hash mismatch for ${url}\n expected ${entry.treeSha256}\n` +
|
|
622
|
+
` actual ${treeSha}\nThe pinned content changed or the response was tampered with — refusing to install.`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
verifyPackageManifest(packageJsonRaw, entry, label);
|
|
626
|
+
|
|
627
|
+
// Verified in memory — now (and only now) touch disk: extract to a temp
|
|
628
|
+
// sibling, RE-HASH what actually landed (closes any divergence between
|
|
629
|
+
// inspection and extraction), stamp the marker, then swap into place.
|
|
630
|
+
const tmpDir = path.join(repoRoot, "extensions", `.acquire-tmp-${process.pid}-${downloaded}`);
|
|
631
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
632
|
+
try {
|
|
633
|
+
await extractVerifiedTarball(tarBuffer, tmpDir, { tar });
|
|
634
|
+
const extractedTreeSha = computeTreeSha256FromDir(tmpDir);
|
|
635
|
+
if (extractedTreeSha !== entry.treeSha256) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`[prod-extension-acquisition] ${label}: extracted tree hash ${extractedTreeSha} does not match the ` +
|
|
638
|
+
`locked treeSha256 ${entry.treeSha256} — refusing to install.`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
writeFileSync(
|
|
642
|
+
path.join(tmpDir, ACQUISITION_MARKER_FILENAME),
|
|
643
|
+
JSON.stringify(
|
|
644
|
+
{ resolvedSha: entry.resolvedSha, treeSha256: entry.treeSha256, acquiredAt: new Date().toISOString() },
|
|
645
|
+
null,
|
|
646
|
+
2,
|
|
647
|
+
) + "\n",
|
|
648
|
+
);
|
|
649
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
650
|
+
// Swap. The previously verified (now stale-pinned) tree is renamed
|
|
651
|
+
// ASIDE — not deleted — so a failed rename into place restores it: a
|
|
652
|
+
// failed replacement can never leave the slot empty. The aside dir is
|
|
653
|
+
// dot-prefixed (like the tmp dir) so workspace globs never see it as a
|
|
654
|
+
// package; it is removed once the new tree is in place.
|
|
655
|
+
const asideDir = path.join(repoRoot, "extensions", `.acquire-old-${process.pid}-${downloaded}`);
|
|
656
|
+
rmSync(asideDir, { recursive: true, force: true });
|
|
657
|
+
let movedOldAside = false;
|
|
658
|
+
if (existsSync(dest)) {
|
|
659
|
+
renameSync(dest, asideDir);
|
|
660
|
+
movedOldAside = true;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
renameSync(tmpDir, dest);
|
|
664
|
+
} catch (renameErr) {
|
|
665
|
+
if (movedOldAside) renameSync(asideDir, dest); // restore the old verified tree
|
|
666
|
+
throw renameErr;
|
|
667
|
+
}
|
|
668
|
+
if (movedOldAside) rmSync(asideDir, { recursive: true, force: true });
|
|
669
|
+
} catch (err) {
|
|
670
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
671
|
+
throw err;
|
|
672
|
+
}
|
|
673
|
+
results.push({ pkgName: entry.packageName, action: "downloaded", changed: true, dest });
|
|
674
|
+
downloaded += 1;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
log(`- Required-extension acquisition: OK (${downloaded} downloaded, ${verified} verified in place).`);
|
|
678
|
+
return { results };
|
|
679
|
+
}
|