@mostra/sdk 0.0.0-stage → 0.2.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 +37 -2
- package/dist/bundles.json +31 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +484 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime-CUbnK5Za.js +1010 -0
- package/dist/runtime-CUbnK5Za.js.map +1 -0
- package/dist/setup-main.d.ts +1 -0
- package/dist/setup-main.js +49 -0
- package/dist/setup-main.js.map +1 -0
- package/package.json +63 -3
- package/src/bundle/cache.ts +375 -0
- package/src/bundle/download.ts +255 -0
- package/src/bundle/extract.ts +60 -0
- package/src/bundle/setup.ts +278 -0
- package/src/bundle/tree.ts +176 -0
- package/src/events.ts +276 -0
- package/src/index.ts +67 -0
- package/src/mostra.ts +607 -0
- package/src/platform.ts +81 -0
- package/src/runtime.ts +179 -0
- package/src/setup-main.ts +49 -0
- package/src/version.ts +38 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making an extracted bundle usable (RT-01, D142): its files checked against `files.json`, which
|
|
3
|
+
* `bundle.json` pins by SHA-256; the verify browser fetched through the bundle's own Playwright
|
|
4
|
+
* and checked against the bundle's pin; then `.mostra-setup.json`, the one marker every reader
|
|
5
|
+
* trusts. The SDK runs it after a download; `bin/mostra-setup` runs it for a bundle extracted by
|
|
6
|
+
* hand, and for the Rust crate (WP-12), so there is one implementation of what "ready" means.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
BUNDLE_SETUP_MARKER,
|
|
13
|
+
BundleFiles,
|
|
14
|
+
BundleInfo,
|
|
15
|
+
BundleSetup,
|
|
16
|
+
createDiagnostic,
|
|
17
|
+
RpcError,
|
|
18
|
+
} from "@mostra/protocol";
|
|
19
|
+
import { scrubUrls } from "./download.ts";
|
|
20
|
+
import { checkTree, hashFile, safeRelative } from "./tree.ts";
|
|
21
|
+
|
|
22
|
+
/** The environment setup reads. */
|
|
23
|
+
export type Environment = Readonly<Record<string, string | undefined>>;
|
|
24
|
+
|
|
25
|
+
/** An extracted bundle and what its `bundle.json` says. */
|
|
26
|
+
export interface Bundle {
|
|
27
|
+
/** Its root directory, holding `bundle.json`. */
|
|
28
|
+
readonly root: string;
|
|
29
|
+
/** Its parsed `bundle.json`. */
|
|
30
|
+
readonly info: BundleInfo;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One step of setup, for progress reports. */
|
|
34
|
+
export type SetupStep = "files" | "browser";
|
|
35
|
+
|
|
36
|
+
/** Options of {@link setupBundle}. */
|
|
37
|
+
export interface SetupOptions {
|
|
38
|
+
/** The environment: `MOSTRA_OFFLINE`, proxies, Playwright's download mirror. */
|
|
39
|
+
readonly env?: Environment | undefined;
|
|
40
|
+
/** Gives up the browser's download. */
|
|
41
|
+
readonly signal?: AbortSignal | undefined;
|
|
42
|
+
/** Each step as it starts. */
|
|
43
|
+
readonly onStep?: ((step: SetupStep) => void) | undefined;
|
|
44
|
+
/** Skip checking the files, for a tree the caller checked a moment ago. */
|
|
45
|
+
readonly filesChecked?: boolean | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The directory Playwright's browsers live in, inside a bundle. */
|
|
49
|
+
export function browsersDir(root: string): string {
|
|
50
|
+
return join(root, "browsers");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether `MOSTRA_OFFLINE` says Mostra is offline: any value but empty, `0`, `false` or `no`. */
|
|
54
|
+
export function offline(env: Environment): boolean {
|
|
55
|
+
const value = env["MOSTRA_OFFLINE"]?.trim().toLowerCase();
|
|
56
|
+
return (
|
|
57
|
+
value !== undefined && value !== "" && value !== "0" && value !== "false" && value !== "no"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function invalid(message: string): RpcError {
|
|
62
|
+
return new RpcError(createDiagnostic("MOSTRA_ENV_BUNDLE_INVALID", { message }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reads `bundle.json` at `root`. Throws `MOSTRA_ENV_BUNDLE_INVALID` when it isn't a bundle's. */
|
|
66
|
+
export async function readBundle(root: string): Promise<Bundle> {
|
|
67
|
+
let text: string;
|
|
68
|
+
try {
|
|
69
|
+
text = await readFile(join(root, "bundle.json"), "utf8");
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw invalid(`${root} has no readable bundle.json (${(error as Error).message}).`);
|
|
72
|
+
}
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
} catch {
|
|
77
|
+
throw invalid(`${join(root, "bundle.json")} isn't JSON.`);
|
|
78
|
+
}
|
|
79
|
+
const result = BundleInfo.safeParse(parsed);
|
|
80
|
+
if (!result.success) throw invalid(`${join(root, "bundle.json")} isn't a runtime bundle's.`);
|
|
81
|
+
const info = result.data;
|
|
82
|
+
const paths = [
|
|
83
|
+
info.node.path,
|
|
84
|
+
...Object.values(info.entries),
|
|
85
|
+
info.files.path,
|
|
86
|
+
info.browser.installer,
|
|
87
|
+
];
|
|
88
|
+
if (!paths.every(safeRelative))
|
|
89
|
+
throw invalid(`${join(root, "bundle.json")} names a path outside the bundle.`);
|
|
90
|
+
return { root, info };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The setup marker, when it describes exactly this bundle. */
|
|
94
|
+
export async function readSetup(bundle: Bundle): Promise<BundleSetup | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
const marker = BundleSetup.parse(
|
|
97
|
+
JSON.parse(await readFile(join(bundle.root, BUNDLE_SETUP_MARKER), "utf8")),
|
|
98
|
+
);
|
|
99
|
+
const { info } = bundle;
|
|
100
|
+
return marker.files === info.files.sha256 &&
|
|
101
|
+
marker.version === info.version &&
|
|
102
|
+
marker.platform === info.platform &&
|
|
103
|
+
marker.browser.revision === info.browser.revision
|
|
104
|
+
? marker
|
|
105
|
+
: undefined;
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What `checkTree` may find besides the listed files: the bundle's own records and browsers. */
|
|
112
|
+
export function setupExtras(path: string): boolean {
|
|
113
|
+
return (
|
|
114
|
+
path === "bundle.json" ||
|
|
115
|
+
path === "files.json" ||
|
|
116
|
+
path === "browsers" ||
|
|
117
|
+
path.startsWith("browsers/") ||
|
|
118
|
+
path === BUNDLE_SETUP_MARKER ||
|
|
119
|
+
path.startsWith(`${BUNDLE_SETUP_MARKER}.tmp-`)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Checks the bundle's files; throws `MOSTRA_ENV_BUNDLE_INVALID` naming what differs. */
|
|
124
|
+
export async function checkFiles(bundle: Bundle): Promise<void> {
|
|
125
|
+
const { root, info } = bundle;
|
|
126
|
+
const listing = join(root, info.files.path);
|
|
127
|
+
const { sha256 } = await hashFile(listing).catch(() => ({ sha256: "" }));
|
|
128
|
+
if (sha256 !== info.files.sha256) {
|
|
129
|
+
throw invalid(`${info.files.path} doesn't match the SHA-256 that bundle.json pins.`);
|
|
130
|
+
}
|
|
131
|
+
const manifest = BundleFiles.safeParse(JSON.parse(await readFile(listing, "utf8")));
|
|
132
|
+
if (!manifest.success) throw invalid(`${info.files.path} isn't a bundle's file list.`);
|
|
133
|
+
const problems = await checkTree(root, manifest.data, { allowExtra: setupExtras });
|
|
134
|
+
if (problems.length > 0) {
|
|
135
|
+
throw invalid(
|
|
136
|
+
`Mostra ${info.version}'s runtime bundle for ${info.platform} differs from its file list: ${problems.join("; ")}.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The browser files that differ from the pin, relative to `browsers/`. */
|
|
142
|
+
export async function checkBrowser(bundle: Bundle): Promise<readonly string[]> {
|
|
143
|
+
const problems = await checkTree(
|
|
144
|
+
browsersDir(bundle.root),
|
|
145
|
+
{ schemaVersion: 1, files: bundle.info.browser.files, links: [] },
|
|
146
|
+
// Playwright's own markers, its `.links` registry and any other browser it fetched.
|
|
147
|
+
{ allowExtra: () => true },
|
|
148
|
+
).catch((error: unknown) => [`browsers: ${(error as Error).message}`]);
|
|
149
|
+
return problems;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The environment the browser install runs in: proxies and Playwright's mirror pass through. */
|
|
153
|
+
function installEnvironment(bundle: Bundle, env: Environment): NodeJS.ProcessEnv {
|
|
154
|
+
const out: NodeJS.ProcessEnv = {};
|
|
155
|
+
for (const [name, value] of Object.entries(env)) {
|
|
156
|
+
if (value !== undefined && !name.startsWith("MOSTRA_") && name !== "PLAYWRIGHT_BROWSERS_PATH")
|
|
157
|
+
out[name] = value;
|
|
158
|
+
}
|
|
159
|
+
out["PLAYWRIGHT_BROWSERS_PATH"] = browsersDir(bundle.root);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The variables whose URLs may carry credentials the browser's fetch uses. */
|
|
164
|
+
const CREDENTIAL_URLS = [
|
|
165
|
+
"PLAYWRIGHT_DOWNLOAD_HOST",
|
|
166
|
+
"HTTPS_PROXY",
|
|
167
|
+
"https_proxy",
|
|
168
|
+
"HTTP_PROXY",
|
|
169
|
+
"http_proxy",
|
|
170
|
+
"ALL_PROXY",
|
|
171
|
+
"all_proxy",
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/** `text` without URLs' credentials or queries, and without any password `env`'s URLs carry. */
|
|
175
|
+
export function scrubSecrets(text: string, env: Environment): string {
|
|
176
|
+
let out = scrubUrls(text);
|
|
177
|
+
for (const name of CREDENTIAL_URLS) {
|
|
178
|
+
const value = env[name];
|
|
179
|
+
if (!value) continue;
|
|
180
|
+
// A proxy is often written without a scheme (`user:pass@host:3128`), which `URL` would read
|
|
181
|
+
// as the scheme `user:`.
|
|
182
|
+
const spelled = value.includes("://") ? value : `http://${value}`;
|
|
183
|
+
const parsed = URL.canParse(spelled) ? new URL(spelled) : undefined;
|
|
184
|
+
for (const secret of [parsed?.password, parsed?.username]) {
|
|
185
|
+
if (!secret) continue;
|
|
186
|
+
for (const form of new Set([secret, decodeURIComponent(secret)]))
|
|
187
|
+
if (form.length >= 3) out = out.split(form).join("***");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Runs the bundle's Playwright to fetch the pinned browser; at most 15 minutes. */
|
|
194
|
+
async function installBrowser(bundle: Bundle, options: SetupOptions): Promise<void> {
|
|
195
|
+
const { root, info } = bundle;
|
|
196
|
+
const output: string[] = [];
|
|
197
|
+
const code = await new Promise<number | string>((resolve) => {
|
|
198
|
+
const child = spawn(
|
|
199
|
+
join(root, info.node.path),
|
|
200
|
+
[join(root, info.browser.installer), "install", "chromium-headless-shell"],
|
|
201
|
+
{
|
|
202
|
+
cwd: root,
|
|
203
|
+
env: installEnvironment(bundle, options.env ?? process.env),
|
|
204
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
205
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
206
|
+
timeout: 15 * 60_000,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
const keep = (chunk: Buffer): void => {
|
|
210
|
+
output.push(chunk.toString("utf8"));
|
|
211
|
+
if (output.length > 40) output.shift();
|
|
212
|
+
};
|
|
213
|
+
child.stdout.on("data", keep);
|
|
214
|
+
child.stderr.on("data", keep);
|
|
215
|
+
child.on("error", (error) => resolve(error.message));
|
|
216
|
+
child.on("close", (exit, signal) => resolve(exit ?? signal ?? "unknown"));
|
|
217
|
+
});
|
|
218
|
+
if (code === 0) return;
|
|
219
|
+
// Playwright prints the URLs it fetches, and a mirror's or proxy's may carry credentials
|
|
220
|
+
// (Opus review finding 3): every URL is reduced to its printable form, and any password the
|
|
221
|
+
// environment names is removed wherever it appears.
|
|
222
|
+
const tail = scrubSecrets(
|
|
223
|
+
output.join("").trim().split("\n").slice(-6).join(" | "),
|
|
224
|
+
options.env ?? process.env,
|
|
225
|
+
);
|
|
226
|
+
throw new RpcError(
|
|
227
|
+
createDiagnostic("MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED", {
|
|
228
|
+
message: `Fetching the verify browser (${info.browser.name} ${info.browser.browserVersion}) failed (${code}): ${tail}`,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Makes the bundle at `root` ready: checks its files, fetches and checks the browser when it is
|
|
235
|
+
* missing or differs, and writes `.mostra-setup.json`. Idempotent: a ready bundle returns its
|
|
236
|
+
* marker after the checks. Throws `MOSTRA_ENV_BUNDLE_INVALID`, `MOSTRA_ENV_BUNDLE_UNAVAILABLE`
|
|
237
|
+
* (offline with no browser) or `MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED`.
|
|
238
|
+
*/
|
|
239
|
+
export async function setupBundle(root: string, options: SetupOptions = {}): Promise<BundleSetup> {
|
|
240
|
+
const env = options.env ?? process.env;
|
|
241
|
+
const bundle = await readBundle(root);
|
|
242
|
+
if (!options.filesChecked) {
|
|
243
|
+
options.onStep?.("files");
|
|
244
|
+
await checkFiles(bundle);
|
|
245
|
+
}
|
|
246
|
+
options.onStep?.("browser");
|
|
247
|
+
if ((await checkBrowser(bundle)).length > 0) {
|
|
248
|
+
if (offline(env)) {
|
|
249
|
+
throw new RpcError(
|
|
250
|
+
createDiagnostic("MOSTRA_ENV_BUNDLE_UNAVAILABLE", {
|
|
251
|
+
message: `The runtime bundle at ${root} has no verify browser yet, and MOSTRA_OFFLINE keeps Mostra from fetching it.`,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
// Fresh, never over what's there: a directory that differs from the pin may still carry
|
|
256
|
+
// Playwright's "installation complete" marker, and Playwright would then fetch nothing.
|
|
257
|
+
await rm(browsersDir(root), { recursive: true, force: true });
|
|
258
|
+
await installBrowser(bundle, options);
|
|
259
|
+
const problems = await checkBrowser(bundle);
|
|
260
|
+
if (problems.length > 0) {
|
|
261
|
+
throw invalid(
|
|
262
|
+
`The verify browser Playwright fetched differs from the bundle's pin: ${problems.join("; ")}.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const marker: BundleSetup = {
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
version: bundle.info.version,
|
|
269
|
+
platform: bundle.info.platform,
|
|
270
|
+
files: bundle.info.files.sha256,
|
|
271
|
+
browser: { revision: bundle.info.browser.revision },
|
|
272
|
+
at: new Date().toISOString(),
|
|
273
|
+
};
|
|
274
|
+
const temporary = join(root, `${BUNDLE_SETUP_MARKER}.tmp-${process.pid}`);
|
|
275
|
+
await writeFile(temporary, `${JSON.stringify(marker, null, 2)}\n`);
|
|
276
|
+
await rename(temporary, join(root, BUNDLE_SETUP_MARKER));
|
|
277
|
+
return marker;
|
|
278
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundle's files against its manifest (RT-01): one walk that both writes `files.json` (the
|
|
3
|
+
* assembler) and checks an extracted bundle against it (setup), so the two can't disagree about
|
|
4
|
+
* what counts. Links must stay inside the bundle; nothing may be missing, changed or extra.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { lstat, readdir, readlink } from "node:fs/promises";
|
|
9
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
10
|
+
import type { BundleFileEntry, BundleFiles, BundleLinkEntry } from "@mostra/protocol";
|
|
11
|
+
|
|
12
|
+
/** The SHA-256 and size of one file, read as a stream. */
|
|
13
|
+
export async function hashFile(path: string): Promise<{ sha256: string; size: number }> {
|
|
14
|
+
const hash = createHash("sha256");
|
|
15
|
+
let size = 0;
|
|
16
|
+
for await (const chunk of createReadStream(path)) {
|
|
17
|
+
hash.update(chunk as Buffer);
|
|
18
|
+
size += (chunk as Buffer).length;
|
|
19
|
+
}
|
|
20
|
+
return { sha256: hash.digest("hex"), size };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One entry of a walk: a regular file or a link, by `/`-separated path from the root. */
|
|
24
|
+
type Walked =
|
|
25
|
+
| { readonly kind: "file"; readonly path: string; readonly executable: boolean }
|
|
26
|
+
| { readonly kind: "link"; readonly path: string; readonly target: string }
|
|
27
|
+
| { readonly kind: "other"; readonly path: string };
|
|
28
|
+
|
|
29
|
+
/** Every file and link under `root`, sorted by path, skipping `exclude`d paths (and below). */
|
|
30
|
+
async function walk(root: string, exclude: (path: string) => boolean): Promise<Walked[]> {
|
|
31
|
+
const out: Walked[] = [];
|
|
32
|
+
const visit = async (dir: string): Promise<void> => {
|
|
33
|
+
const entries = await readdir(join(root, dir), { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
|
|
36
|
+
if (exclude(path)) continue;
|
|
37
|
+
if (entry.isDirectory()) await visit(path);
|
|
38
|
+
else if (entry.isSymbolicLink()) {
|
|
39
|
+
out.push({ kind: "link", path, target: await readlink(join(root, path)) });
|
|
40
|
+
} else if (entry.isFile()) {
|
|
41
|
+
const stats = await lstat(join(root, path));
|
|
42
|
+
out.push({ kind: "file", path, executable: (stats.mode & 0o100) !== 0 });
|
|
43
|
+
} else out.push({ kind: "other", path });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
await visit("");
|
|
47
|
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runs `task` over `items`, at most `limit` at a time, keeping the order of results. */
|
|
51
|
+
async function mapLimit<T, R>(
|
|
52
|
+
items: readonly T[],
|
|
53
|
+
limit: number,
|
|
54
|
+
task: (item: T) => Promise<R>,
|
|
55
|
+
): Promise<R[]> {
|
|
56
|
+
const results = new Array<R>(items.length);
|
|
57
|
+
let next = 0;
|
|
58
|
+
const worker = async (): Promise<void> => {
|
|
59
|
+
while (next < items.length) {
|
|
60
|
+
const index = next++;
|
|
61
|
+
results[index] = await task(items[index] as T);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a link's target is relative and resolves inside `root`. */
|
|
69
|
+
export function linkStaysInside(root: string, path: string, target: string): boolean {
|
|
70
|
+
if (isAbsolute(target)) return false;
|
|
71
|
+
const base = resolve(root);
|
|
72
|
+
const resolved = resolve(base, dirname(path), target);
|
|
73
|
+
return resolved === base || resolved.startsWith(base + sep);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a manifest path is a plain relative path: no absolute start, no `.` or `..` segment. */
|
|
77
|
+
export function safeRelative(path: string): boolean {
|
|
78
|
+
if (path === "" || isAbsolute(path) || path.includes("\\") || path.includes("\0")) return false;
|
|
79
|
+
return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The manifest of the tree under `root`: every regular file with its SHA-256, size and execute
|
|
84
|
+
* bit, every link with its target. Throws on anything else (a device, a socket, a link leaving
|
|
85
|
+
* the tree), which a bundle must never contain.
|
|
86
|
+
*/
|
|
87
|
+
export async function describeTree(
|
|
88
|
+
root: string,
|
|
89
|
+
exclude: (path: string) => boolean = () => false,
|
|
90
|
+
): Promise<BundleFiles> {
|
|
91
|
+
const walked = await walk(root, exclude);
|
|
92
|
+
const files: BundleFileEntry[] = [];
|
|
93
|
+
const links: BundleLinkEntry[] = [];
|
|
94
|
+
const regular = walked.filter((entry) => entry.kind === "file");
|
|
95
|
+
const hashed = await mapLimit(regular, 32, (entry) => hashFile(join(root, entry.path)));
|
|
96
|
+
regular.forEach((entry, index) => {
|
|
97
|
+
const { sha256, size } = hashed[index] as { sha256: string; size: number };
|
|
98
|
+
files.push({ path: entry.path, size, sha256, executable: entry.executable });
|
|
99
|
+
});
|
|
100
|
+
for (const entry of walked) {
|
|
101
|
+
if (entry.kind === "other") throw new Error(`${entry.path} is neither a file nor a link.`);
|
|
102
|
+
if (entry.kind !== "link") continue;
|
|
103
|
+
if (!linkStaysInside(root, entry.path, entry.target))
|
|
104
|
+
throw new Error(`${entry.path} links outside the tree (${entry.target}).`);
|
|
105
|
+
links.push({ path: entry.path, target: entry.target });
|
|
106
|
+
}
|
|
107
|
+
return { schemaVersion: 1, files, links };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What a check found: an empty list means the tree matches. Paths are relative to the root. */
|
|
111
|
+
export type TreeProblems = readonly string[];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks the tree under `root` against `manifest`: each listed file present, regular, with its
|
|
115
|
+
* size, SHA-256 and execute bit; each link present with its target, staying inside; and nothing
|
|
116
|
+
* else besides what `allowExtra` accepts. Reports at most `limit` problems.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkTree(
|
|
119
|
+
root: string,
|
|
120
|
+
manifest: BundleFiles,
|
|
121
|
+
options: { readonly allowExtra?: (path: string) => boolean; readonly limit?: number } = {},
|
|
122
|
+
): Promise<TreeProblems> {
|
|
123
|
+
const limit = options.limit ?? 20;
|
|
124
|
+
const allowExtra = options.allowExtra ?? (() => false);
|
|
125
|
+
const problems: string[] = [];
|
|
126
|
+
const add = (problem: string): void => {
|
|
127
|
+
if (problems.length < limit) problems.push(problem);
|
|
128
|
+
};
|
|
129
|
+
const expectedFiles = new Map(manifest.files.map((entry) => [entry.path, entry]));
|
|
130
|
+
const expectedLinks = new Map(manifest.links.map((entry) => [entry.path, entry]));
|
|
131
|
+
for (const path of [...expectedFiles.keys(), ...expectedLinks.keys()]) {
|
|
132
|
+
if (!safeRelative(path)) add(`${path}: not a plain relative path`);
|
|
133
|
+
}
|
|
134
|
+
if (problems.length > 0) return problems;
|
|
135
|
+
// An allowed extra is skipped whole unless something listed lies at or below it.
|
|
136
|
+
const listedDirs = new Set<string>();
|
|
137
|
+
for (const path of [...expectedFiles.keys(), ...expectedLinks.keys()]) {
|
|
138
|
+
const segments = path.split("/");
|
|
139
|
+
for (let index = 1; index < segments.length; index++)
|
|
140
|
+
listedDirs.add(segments.slice(0, index).join("/"));
|
|
141
|
+
}
|
|
142
|
+
const walked = await walk(
|
|
143
|
+
root,
|
|
144
|
+
(path) =>
|
|
145
|
+
allowExtra(path) &&
|
|
146
|
+
!expectedFiles.has(path) &&
|
|
147
|
+
!expectedLinks.has(path) &&
|
|
148
|
+
!listedDirs.has(path),
|
|
149
|
+
);
|
|
150
|
+
const found = new Map(walked.map((entry) => [entry.path, entry]));
|
|
151
|
+
for (const entry of walked) {
|
|
152
|
+
if (!expectedFiles.has(entry.path) && !expectedLinks.has(entry.path)) {
|
|
153
|
+
if (!allowExtra(entry.path)) add(`${entry.path}: not in the bundle's file list`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const [path, link] of expectedLinks) {
|
|
157
|
+
const actual = found.get(path);
|
|
158
|
+
if (actual?.kind !== "link") add(`${path}: missing link`);
|
|
159
|
+
else if (actual.target !== link.target)
|
|
160
|
+
add(`${path}: links to ${actual.target}, not ${link.target}`);
|
|
161
|
+
else if (!linkStaysInside(root, path, link.target)) add(`${path}: links outside the bundle`);
|
|
162
|
+
}
|
|
163
|
+
const files = [...expectedFiles.values()];
|
|
164
|
+
const results = await mapLimit(files, 32, async (entry) => {
|
|
165
|
+
const actual = found.get(entry.path);
|
|
166
|
+
if (actual?.kind !== "file") return `${entry.path}: missing file`;
|
|
167
|
+
if (actual.executable !== entry.executable)
|
|
168
|
+
return `${entry.path}: ${entry.executable ? "not executable" : "executable"}`;
|
|
169
|
+
const { sha256, size } = await hashFile(join(root, entry.path));
|
|
170
|
+
if (size !== entry.size) return `${entry.path}: ${size} bytes, not ${entry.size}`;
|
|
171
|
+
if (sha256 !== entry.sha256) return `${entry.path}: its SHA-256 differs`;
|
|
172
|
+
return undefined;
|
|
173
|
+
});
|
|
174
|
+
for (const result of results) if (result !== undefined) add(result);
|
|
175
|
+
return problems;
|
|
176
|
+
}
|