@daugasauron/dolly 0.1.4 → 0.1.5-rc.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/README.md +134 -14
- package/coi-serviceworker.js +28 -0
- package/dist/dolly-build-id.mjs +1 -1
- package/dist/dolly-dso-0.wasm +0 -0
- package/dist/dolly-image-build-id.mjs +1 -1
- package/dist/dolly-process-0.wasm +0 -0
- package/dist/dolly-process-abi.mjs +1 -1
- package/dist/dolly-process-dso.mjs +382 -53
- package/dist/dolly-process-gate-0.wasm +0 -0
- package/dist/dolly-process-worker.mjs +164 -35
- package/dist/dolly.wasm +0 -0
- package/host/dso/abi.mjs +3 -1
- package/host/dso/dso.mjs +1 -2
- package/host/http/abi.mjs +2 -2
- package/host/http/broker.mjs +18 -8
- package/host/http/http.mjs +0 -1
- package/host/http/policy.mjs +8 -4
- package/host/input/abi.mjs +47 -0
- package/host/modules.mjs +4 -3
- package/host/nena/abi.mjs +1 -1
- package/host/nena/nena.mjs +16 -18
- package/host/packages/service.mjs +35 -15
- package/host/packages/worker.mjs +21 -0
- package/host/runtime/abi.mjs +1 -0
- package/host/runtime/boot-files.mjs +8 -2
- package/host/runtime/grower.mjs +11 -8
- package/host/runtime/lease-bridge.mjs +8 -5
- package/host/runtime/module.json +1 -1
- package/host/runtime/runtime.mjs +37 -18
- package/host/snapshot/abi.mjs +7 -9
- package/host/snapshot/snapshot.mjs +14 -12
- package/host/snapshot/transport.mjs +4 -12
- package/host/snapshot/ui.mjs +17 -7
- package/host/stack/stack.mjs +1 -2
- package/host/threads/abi.mjs +1 -1
- package/host/threads/threads.mjs +2 -1
- package/package.json +1 -1
- package/src/browser.mjs +34 -37
- package/src/build-log.mjs +13 -2
- package/src/build-worker.mjs +182 -0
- package/src/custom-image.mjs +59 -0
- package/src/dollyfile-graph.mjs +127 -0
- package/src/dollyfile-view.mjs +335 -0
- package/src/image-artifact.mjs +248 -0
- package/src/image-build.mjs +89 -0
- package/src/image-builder.mjs +63 -0
- package/src/images.mjs +13 -2
- package/src/pack-store.mjs +141 -0
- package/src/packaged-snapshot.mjs +263 -91
- package/src/page-chords.mjs +4 -2
- package/src/process-constants.mjs +4 -2
- package/src/process-supervisor.mjs +209 -122
- package/src/runtime-worker.mjs +9 -10
- package/src/session-store.mjs +5 -7
- package/src/sha256.mjs +65 -0
- package/src/snapshot-records.mjs +135 -23
- package/src/static-asset.mjs +29 -2
- package/src/terminal-text.mjs +8 -5
- package/src/version.mjs +1 -1
- package/src/worker-image.mjs +3 -5
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { buildHost, hostRegistry } from "../host/modules.mjs";
|
|
2
|
+
import { hostRequirement } from "../host/requirements.mjs";
|
|
3
|
+
import { imageBase, imageDefinition } from "./images.mjs";
|
|
4
|
+
import { inspectDollyfile } from "./dollyfile-view.mjs";
|
|
5
|
+
|
|
6
|
+
// Disposable Wasm userspace on the runtime the image declares, with the build
|
|
7
|
+
// host's modules, configured as the caller's host says (host.builder): the
|
|
8
|
+
// caller's browser policy and no display, file picker, local service or ENTRY.
|
|
9
|
+
// MODULES are the URLs of the module.json of each host module the page has.
|
|
10
|
+
// Used for dependencies and Studio builds. ARTIFACTS are the build's inputs,
|
|
11
|
+
// each one's bytes a Blob the Worker reads; it resolves to the built artifact,
|
|
12
|
+
// verified and in the image cache.
|
|
13
|
+
export async function buildImage(image, artifacts, configuration, report, { modules, customSource, signal } = {}) {
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
const declared = image === "custom" ? inspectDollyfile(customSource).hostRequirements
|
|
16
|
+
: imageDefinition(image).hostRequirements;
|
|
17
|
+
let worker;
|
|
18
|
+
// The build registers the build host and every module the image and its
|
|
19
|
+
// inputs declare: it enables only the runtime and the build host, but a step
|
|
20
|
+
// may run a program linked against a declared module's client (transfer's
|
|
21
|
+
// download --help), which the Worker admits by that module's ABI.
|
|
22
|
+
const needed = new Set([...buildHost, ...declared, ...artifacts.flatMap(artifact => artifact.hostRequirements)]
|
|
23
|
+
.map(value => hostRequirement(value).name));
|
|
24
|
+
const manifests = await Promise.all(modules.map(async url => ({ url, ...(await import(url, { with: { type: "json" } })).default })));
|
|
25
|
+
const registry = await hostRegistry(manifests.filter(({ name }) => needed.has(name)).map(({ url }) => url));
|
|
26
|
+
const host = await registry.createHost("browser", registry.buildHostFor(declared),
|
|
27
|
+
{ send: message => worker.postMessage(message), configuration });
|
|
28
|
+
let abort, result;
|
|
29
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
30
|
+
try {
|
|
31
|
+
signal?.throwIfAborted();
|
|
32
|
+
worker = new Worker(new URL("./build-worker.mjs", import.meta.url), {
|
|
33
|
+
type: "module", name: `dolly-build-${image}`,
|
|
34
|
+
});
|
|
35
|
+
result = await new Promise((resolve, reject) => {
|
|
36
|
+
abort = () => reject(signal.reason);
|
|
37
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
38
|
+
worker.addEventListener("error", event => reject(new Error(event.message || "Image build worker failed")), { once: true });
|
|
39
|
+
worker.addEventListener("message", ({ data: message }) => {
|
|
40
|
+
try {
|
|
41
|
+
void host.handle(message).catch(reject);
|
|
42
|
+
if (message.type === "bootstrap") report(message.text);
|
|
43
|
+
else if (message.type === "bootstrap-bytes") report(decoder.decode(message.bytes, { stream: true }));
|
|
44
|
+
else if (message.type === "image-built") resolve(message.artifact);
|
|
45
|
+
else if (message.type === "error") reject(new Error(message.message));
|
|
46
|
+
} catch (error) { reject(error); }
|
|
47
|
+
});
|
|
48
|
+
// An input's Blob is cloned by reference: every Worker reads the one copy.
|
|
49
|
+
worker.postMessage({ type: "configure", mode: "rebuild", image, buildOnly: true,
|
|
50
|
+
artifacts: artifacts.map(({ buildId, recipeSha256, sha256, bytes }) => ({ buildId, recipeSha256, sha256, bytes })),
|
|
51
|
+
images: imageBase.href, modules: registry.urls, hostModules: host.enabled, hostConfiguration: host.configuration,
|
|
52
|
+
...(customSource === undefined ? {} : { customSource }) });
|
|
53
|
+
});
|
|
54
|
+
} finally {
|
|
55
|
+
signal?.removeEventListener("abort", abort);
|
|
56
|
+
host.dispose();
|
|
57
|
+
worker?.terminate();
|
|
58
|
+
const tail = decoder.decode();
|
|
59
|
+
if (tail && !signal?.aborted) report(tail);
|
|
60
|
+
}
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
|
+
return result;
|
|
63
|
+
}
|
package/src/images.mjs
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
// The images a page runs and where their files are. An image directory holds
|
|
2
|
-
// dist/dolly-images.
|
|
2
|
+
// dist/dolly-images.json (its catalog), each image's metadata and packs under
|
|
3
3
|
// dist/, and the recipes and sources at their site paths. The site is one such
|
|
4
4
|
// directory; an embedding names its own (boot's `images`). The page and the
|
|
5
5
|
// Worker each load it once, before anything reads it.
|
|
6
6
|
export let DOLLY_IMAGES = [], DOLLY_STATIC_SOURCES = [], imageBase;
|
|
7
7
|
export async function loadImages(base) {
|
|
8
8
|
imageBase = new URL(base);
|
|
9
|
-
({ DOLLY_IMAGES, DOLLY_STATIC_SOURCES } =
|
|
9
|
+
({ images: DOLLY_IMAGES, staticSources: DOLLY_STATIC_SOURCES } =
|
|
10
|
+
(await import(new URL("dist/dolly-images.json", imageBase).href, { with: { type: "json" } })).default);
|
|
10
11
|
}
|
|
12
|
+
|
|
13
|
+
// The catalog's record of an image, by name; undefined for one it lacks.
|
|
14
|
+
export const imageDefinition = name => DOLLY_IMAGES.find(definition => definition.image === name);
|
|
15
|
+
|
|
16
|
+
// The files of the directory a build reads by site path, with their sizes:
|
|
17
|
+
// every recipe and every source (http@0's trusted sources).
|
|
18
|
+
export const bootstrapSources = () => [
|
|
19
|
+
...DOLLY_IMAGES.map(definition => ({ path: `/${definition.dollyfile}`, byteLength: definition.byteLength })),
|
|
20
|
+
...DOLLY_STATIC_SOURCES,
|
|
21
|
+
];
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The packs this browser has read, kept in origin-private storage so that an
|
|
2
|
+
// image starts again, and a session saved on it opens, without downloading
|
|
3
|
+
// them. A pack is a file named by its SHA-256, written as its bytes stream to
|
|
4
|
+
// the reader and kept only once the reader has checked them against that
|
|
5
|
+
// digest; a kept pack is checked the same way at every use, and removed when
|
|
6
|
+
// it fails. Every image read leaves a record of its packs; once no image is
|
|
7
|
+
// being read on the origin, what the records of this runtime's images do not
|
|
8
|
+
// name is removed. Only the page and its Workers write here, from an image's
|
|
9
|
+
// trusted metadata; nothing of the guest reaches it.
|
|
10
|
+
import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
|
|
11
|
+
import { imageBase, imageDefinition } from "./images.mjs";
|
|
12
|
+
import { sha256 } from "./static-asset.mjs";
|
|
13
|
+
|
|
14
|
+
const STORE = "dolly-packs", LOCK = "dolly-packs";
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
|
|
17
|
+
async function storeDirectories() {
|
|
18
|
+
const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(STORE, { create: true });
|
|
19
|
+
const [packs, records] = await Promise.all(["packs", "images"].map(name => root.getDirectoryHandle(name, { create: true })));
|
|
20
|
+
return { packs, records };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Holds the store's lock in MODE until the function it resolves to is
|
|
24
|
+
// called, which resolves once the lock is released.
|
|
25
|
+
const hold = mode => new Promise((held, refused) => {
|
|
26
|
+
const released = navigator.locks.request(LOCK, { mode }, () => new Promise(release => held(() => {
|
|
27
|
+
release();
|
|
28
|
+
return released;
|
|
29
|
+
})));
|
|
30
|
+
released.catch(refused);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function writeFile(directory, name, data) {
|
|
34
|
+
const writable = await (await directory.getFileHandle(name, { create: true })).createWritable();
|
|
35
|
+
await writable.write(data);
|
|
36
|
+
await writable.close();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Where the browser refuses origin-private storage or Web Locks (and in
|
|
40
|
+
// Node), every pack is downloaded.
|
|
41
|
+
const noStore = {
|
|
42
|
+
kept: async () => null,
|
|
43
|
+
keeping: async (pack, body) => ({ body, keep() {}, discard() {} }),
|
|
44
|
+
close() {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// The store as the reader of IMAGE's PACKS uses it: kept(pack) is a pack's
|
|
48
|
+
// part read from here, or null; keeping(pack, body) the part of a download,
|
|
49
|
+
// written here as it is read. A part's keep() is called once its bytes match
|
|
50
|
+
// the pack's digest, discard() otherwise. close(complete) ends the reading;
|
|
51
|
+
// while it lasts, nothing is removed.
|
|
52
|
+
export async function openPackStore(image, packs) {
|
|
53
|
+
let store, release;
|
|
54
|
+
try {
|
|
55
|
+
store = await storeDirectories();
|
|
56
|
+
release = await hold("shared");
|
|
57
|
+
const record = { directory: imageBase.href, image, buildId: DOLLY_IMAGE_BUILD_ID, packs: packs.map(pack => pack.sha256) };
|
|
58
|
+
await writeFile(store.records, await sha256(encoder.encode(`${record.directory}\0${image}`)), JSON.stringify(record));
|
|
59
|
+
} catch {
|
|
60
|
+
release?.();
|
|
61
|
+
return noStore;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
async kept(pack) {
|
|
65
|
+
try {
|
|
66
|
+
const file = await (await store.packs.getFileHandle(pack.sha256)).getFile();
|
|
67
|
+
if (file.size !== pack.encodedByteLength) return null;
|
|
68
|
+
return { body: file.stream(), keep() {}, discard: () => store.packs.removeEntry(pack.sha256).catch(() => {}) };
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
},
|
|
71
|
+
async keeping(pack, body) {
|
|
72
|
+
// The file holds what was written only once it is closed. A write that
|
|
73
|
+
// fails, such as one over the quota, leaves the pack unkept.
|
|
74
|
+
let writable = await store.packs.getFileHandle(pack.sha256, { create: true })
|
|
75
|
+
.then(handle => handle.createWritable()).catch(() => null);
|
|
76
|
+
const discard = async () => {
|
|
77
|
+
const unkept = writable;
|
|
78
|
+
writable = null;
|
|
79
|
+
await unkept?.abort().catch(() => {});
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
body: body.pipeThrough(new TransformStream({
|
|
83
|
+
async transform(chunk, controller) {
|
|
84
|
+
await writable?.write(chunk).catch(discard);
|
|
85
|
+
controller.enqueue(chunk);
|
|
86
|
+
},
|
|
87
|
+
})),
|
|
88
|
+
keep: () => writable?.close().catch(discard),
|
|
89
|
+
discard,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
close(complete) {
|
|
93
|
+
void release().then(() => complete &&
|
|
94
|
+
navigator.locks.request(LOCK, { ifAvailable: true }, lock => lock && prune(store))).catch(() => {});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Removes the records of images no page of this runtime starts (another
|
|
100
|
+
// runtime's, and those this directory's catalog no longer has), then every
|
|
101
|
+
// pack the remaining records do not name. An image rebuilt since it was last
|
|
102
|
+
// read keeps its former packs until it is read again.
|
|
103
|
+
async function prune({ packs, records }) {
|
|
104
|
+
const used = new Set(), stale = [], unused = [];
|
|
105
|
+
for await (const [name, handle] of records) {
|
|
106
|
+
try {
|
|
107
|
+
const record = JSON.parse(await (await handle.getFile()).text());
|
|
108
|
+
if (record.buildId === DOLLY_IMAGE_BUILD_ID && (record.directory !== imageBase.href || imageDefinition(record.image))) {
|
|
109
|
+
for (const pack of record.packs) used.add(pack);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
} catch {}
|
|
113
|
+
stale.push(name);
|
|
114
|
+
}
|
|
115
|
+
for (const name of stale) await records.removeEntry(name).catch(() => {});
|
|
116
|
+
for await (const name of packs.keys()) if (!used.has(name)) unused.push(name);
|
|
117
|
+
for (const name of unused) await packs.removeEntry(name).catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// What the store holds: its packs and their bytes.
|
|
121
|
+
export async function packStoreSize() {
|
|
122
|
+
let count = 0, bytes = 0;
|
|
123
|
+
try {
|
|
124
|
+
const store = await (await navigator.storage.getDirectory()).getDirectoryHandle(STORE);
|
|
125
|
+
for await (const handle of (await store.getDirectoryHandle("packs")).values()) {
|
|
126
|
+
count += 1;
|
|
127
|
+
bytes += (await handle.getFile()).size;
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error.name !== "NotFoundError") throw error;
|
|
131
|
+
}
|
|
132
|
+
return { count, bytes };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Removes the store once no image is being read on the origin.
|
|
136
|
+
export async function clearPackStore() {
|
|
137
|
+
await navigator.locks.request(LOCK, async () =>
|
|
138
|
+
(await navigator.storage.getDirectory()).removeEntry(STORE, { recursive: true }).catch(error => {
|
|
139
|
+
if (error.name !== "NotFoundError") throw error;
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// The images of an image directory as the page and the Worker load them: each
|
|
2
2
|
// one's metadata, checked against the catalog and the recipes the directory
|
|
3
|
-
// serves, and its packs
|
|
3
|
+
// serves, and its packs: streamed into a booting kernel, or merged in path
|
|
4
|
+
// order into the image's canonical snapshot.
|
|
4
5
|
import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
|
|
5
6
|
import { DOLLY_IMAGES, imageBase } from "./images.mjs";
|
|
6
7
|
import { imageInputs, imageInputsMatch } from "./image-inputs.mjs";
|
|
7
8
|
import { hostRequirements } from "../host/requirements.mjs";
|
|
8
9
|
import { siteReference, sitePath, decodeStaticAsset, sha256 } from "./static-asset.mjs";
|
|
9
|
-
import {
|
|
10
|
+
import { scanSnapshotRecords, validateSnapshotPacks, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "./snapshot-records.mjs";
|
|
10
11
|
import { unretainedPath } from "./image-names.mjs";
|
|
12
|
+
import { openPackStore } from "./pack-store.mjs";
|
|
13
|
+
import { Sha256 } from "./sha256.mjs";
|
|
11
14
|
const definitionOf = image => DOLLY_IMAGES.find(definition => definition.image === image);
|
|
12
15
|
const encoder = new TextEncoder();
|
|
13
16
|
const expectedRecipes = image => definitionOf(image).recipes;
|
|
@@ -20,50 +23,74 @@ function validSnapshotPath(path) {
|
|
|
20
23
|
!unretainedPath(path);
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
// The recipes the directory serves for IMAGE (its page shows them) are the ones
|
|
27
|
+
// that built the image a boot runs. They are fetched together.
|
|
28
|
+
export async function verifyVisibleRecipes(image) {
|
|
29
|
+
await Promise.all(expectedRecipes(image).map(async recipe => {
|
|
25
30
|
const response = await fetch(new URL(sitePath(recipe.sourcePath).slice(1), imageBase), {
|
|
26
|
-
|
|
31
|
+
credentials: "same-origin", redirect: "error",
|
|
27
32
|
});
|
|
28
33
|
if (!response.ok) throw new Error(`${recipe.sourcePath} returned HTTP ${response.status}`);
|
|
29
34
|
const bytes = await response.arrayBuffer();
|
|
30
35
|
if (bytes.byteLength !== recipe.byteLength || await sha256(bytes) !== recipe.sha256) {
|
|
31
36
|
throw new Error(`${recipe.sourcePath} does not match the packaged snapshot recipe`);
|
|
32
37
|
}
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The image a FROM, COPY or INSTALL row names: its recipe at that path and pin.
|
|
42
|
+
const referenced = ({ location, sha256 }) => DOLLY_IMAGES.find(candidate =>
|
|
43
|
+
siteReference(candidate.dollyfile) === location && candidate.sha256 === sha256);
|
|
44
|
+
|
|
45
|
+
// The images IMAGE is built from, each once and before those built from it,
|
|
46
|
+
// IMAGE last; a cycle is refused.
|
|
47
|
+
function inputClosure(image, order = [], path = []) {
|
|
48
|
+
if (path.includes(image)) throw new Error("packaged image cycle");
|
|
49
|
+
if (order.includes(image)) return order;
|
|
50
|
+
for (const reference of definitionOf(image).artifacts) {
|
|
51
|
+
const dependency = referenced(reference);
|
|
52
|
+
if (!dependency) throw new Error("packaged image input is missing");
|
|
53
|
+
inputClosure(dependency.image, order, [...path, image]);
|
|
33
54
|
}
|
|
55
|
+
order.push(image);
|
|
56
|
+
return order;
|
|
34
57
|
}
|
|
35
58
|
|
|
36
|
-
|
|
37
|
-
if (checked.has(image)) return checked.get(image);
|
|
38
|
-
if (active.has(image)) throw new Error("packaged image cycle");
|
|
39
|
-
active.add(image);
|
|
40
|
-
const metadataUrl = new URL(
|
|
41
|
-
`dist/dolly-${image}-system-snapshot.mjs`, imageBase,
|
|
42
|
-
);
|
|
43
|
-
let metadata;
|
|
59
|
+
async function importMetadata(image) {
|
|
44
60
|
try {
|
|
45
|
-
({
|
|
61
|
+
return (await import(new URL(`dist/dolly-${image}-system-snapshot.json`, imageBase).href, { with: { type: "json" } })).default;
|
|
46
62
|
} catch {
|
|
47
63
|
throw new Error(`The packaged ${image} snapshot is missing. Run npm run image -- ${image} first.`);
|
|
48
64
|
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// An image's metadata, checked against the catalog and the metadata of every
|
|
68
|
+
// image it is built from, which are fetched together.
|
|
69
|
+
export async function loadPackagedSnapshotMetadata(image) {
|
|
70
|
+
const closure = inputClosure(image);
|
|
71
|
+
const loaded = await Promise.all(closure.map(importMetadata));
|
|
72
|
+
const checked = new Map();
|
|
73
|
+
closure.forEach((name, index) => checked.set(name, checkedMetadata(name, loaded[index], checked)));
|
|
74
|
+
return checked.get(image);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// METADATA, checked as IMAGE's against the catalog and CHECKED, the metadata
|
|
78
|
+
// of the images it is built from.
|
|
79
|
+
function checkedMetadata(image, metadata, checked) {
|
|
49
80
|
const recipes = expectedRecipes(image);
|
|
50
81
|
if (metadata === null || typeof metadata !== "object" ||
|
|
51
82
|
metadata.image !== image || metadata.buildId !== DOLLY_IMAGE_BUILD_ID ||
|
|
52
83
|
metadata.formatVersion !== 2 || metadata.identityVersion !== 2 ||
|
|
53
84
|
JSON.stringify(metadata.recipes) !== JSON.stringify(recipes) ||
|
|
54
|
-
JSON.stringify(hostRequirements(metadata.hostRequirements)) !== JSON.stringify(definitionOf(image).hostRequirements
|
|
85
|
+
JSON.stringify(hostRequirements(metadata.hostRequirements)) !== JSON.stringify(definitionOf(image).hostRequirements) ||
|
|
55
86
|
!Number.isSafeInteger(metadata.byteLength) || metadata.byteLength <= 0 ||
|
|
56
|
-
metadata.byteLength > snapshotSizeLimit ||
|
|
57
|
-
(![undefined, "gzip", "packs"].includes(metadata.encoding)) ||
|
|
58
|
-
(metadata.encoding === "gzip" &&
|
|
59
|
-
(!Number.isSafeInteger(metadata.encodedByteLength) ||
|
|
60
|
-
metadata.encodedByteLength <= 0 || metadata.encodedByteLength > snapshotSizeLimit)) ||
|
|
87
|
+
metadata.byteLength > snapshotSizeLimit || metadata.encoding !== "packs" ||
|
|
61
88
|
!/^[0-9a-f]{64}$/.test(metadata.sha256) ||
|
|
62
89
|
!Array.isArray(metadata.manifest) || metadata.manifest.length === 0 ||
|
|
63
90
|
metadata.manifest.length > 100_000) {
|
|
64
91
|
throw new Error("The packaged system snapshot metadata does not match this Dolly build");
|
|
65
92
|
}
|
|
66
|
-
|
|
93
|
+
validateSnapshotPacks(metadata);
|
|
67
94
|
const inputs = imageInputs(metadata.inputs);
|
|
68
95
|
const references = [...new Set(definitionOf(image).artifacts.map(reference => reference.sha256))].sort();
|
|
69
96
|
if (JSON.stringify(inputs.map(input => input.recipeSha256)) !== JSON.stringify(references)) {
|
|
@@ -90,86 +117,231 @@ export async function loadPackagedSnapshotMetadata(image, checked = new Map(), a
|
|
|
90
117
|
throw new Error(`The packaged system snapshot is missing ${required}`);
|
|
91
118
|
}
|
|
92
119
|
}
|
|
93
|
-
const expectedInputs =
|
|
94
|
-
|
|
95
|
-
const dependency = DOLLY_IMAGES.find(candidate =>
|
|
96
|
-
siteReference(candidate.dollyfile) === reference.location && candidate.sha256 === reference.sha256);
|
|
97
|
-
if (!dependency) throw new Error("packaged image input is missing");
|
|
98
|
-
const parent = await loadPackagedSnapshotMetadata(dependency.image, checked, active);
|
|
99
|
-
expectedInputs.push({ recipeSha256: reference.sha256, sha256: parent.sha256 });
|
|
100
|
-
}
|
|
120
|
+
const expectedInputs = definitionOf(image).artifacts.map(reference =>
|
|
121
|
+
({ recipeSha256: reference.sha256, sha256: checked.get(referenced(reference).image).sha256 }));
|
|
101
122
|
if (!imageInputsMatch(inputs, expectedInputs)) throw new Error("The packaged system snapshot has stale image inputs");
|
|
102
|
-
if (active.size === 1) await verifyVisibleRecipes(recipes);
|
|
103
|
-
active.delete(image);
|
|
104
|
-
checked.set(image, metadata);
|
|
105
123
|
return metadata;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
// An image is the records of its packs, each a gzip-compressed snapshot named
|
|
127
|
+
// by its digest, which images that hold the same files share. Each pack's
|
|
128
|
+
// compressed body, kept in this browser (pack-store.mjs) or downloaded and
|
|
129
|
+
// checked against its declared length. The consumer checks each part against
|
|
130
|
+
// its digest and then calls its verified(), which keeps a downloaded pack; a
|
|
131
|
+
// part it does not verify is not kept, or no longer.
|
|
132
|
+
async function* packagedSnapshotParts(metadata, signal) {
|
|
133
|
+
const packs = validateSnapshotPacks(metadata), store = await openPackStore(metadata.image, packs);
|
|
134
|
+
let complete = false;
|
|
135
|
+
try {
|
|
136
|
+
for (const pack of packs) {
|
|
137
|
+
signal?.throwIfAborted();
|
|
138
|
+
const part = await store.kept(pack) ?? await store.keeping(pack, await downloadedPack(pack, signal));
|
|
139
|
+
let verified = false;
|
|
140
|
+
try {
|
|
141
|
+
yield { ...pack, body: part.body, verified: () => { verified = true; return part.keep(); } };
|
|
142
|
+
} finally {
|
|
143
|
+
if (!verified) await part.discard();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
complete = true;
|
|
147
|
+
} finally {
|
|
148
|
+
store.close(complete);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function downloadedPack(pack, signal) {
|
|
153
|
+
const url = new URL(`dist/packs/${pack.sha256}.snapshot.gz`, imageBase);
|
|
154
|
+
const init = { cache: "force-cache", credentials: "same-origin", redirect: "error", signal };
|
|
155
|
+
const response = await decodeStaticAsset(await fetch(url, init), url, init, pack.encodedByteLength);
|
|
156
|
+
if (!response.ok || !response.body) throw new Error(`snapshot returned HTTP ${response.status}`);
|
|
157
|
+
const declared = response.headers.get("content-length");
|
|
158
|
+
if (declared !== null && Number(declared) !== pack.encodedByteLength) throw new Error("snapshot HTTP content length mismatch");
|
|
159
|
+
return response.body;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const gunzip = body => body.pipeThrough(new DecompressionStream("gzip"));
|
|
163
|
+
|
|
164
|
+
// The chunks of `body`; a consumer that stops early cancels it.
|
|
165
|
+
async function* chunksOf(body, signal) {
|
|
166
|
+
const reader = body.getReader();
|
|
167
|
+
let finished = false;
|
|
168
|
+
try {
|
|
169
|
+
for (;;) {
|
|
170
|
+
signal?.throwIfAborted();
|
|
171
|
+
const { done, value } = await reader.read();
|
|
172
|
+
if (done) break;
|
|
173
|
+
yield value;
|
|
174
|
+
}
|
|
175
|
+
finished = true;
|
|
176
|
+
} finally {
|
|
177
|
+
if (!finished) await reader.cancel().catch(() => {});
|
|
178
|
+
reader.releaseLock();
|
|
125
179
|
}
|
|
126
180
|
}
|
|
127
181
|
|
|
182
|
+
async function readBytes(body, length, signal) {
|
|
183
|
+
const bytes = new Uint8Array(length);
|
|
184
|
+
let offset = 0;
|
|
185
|
+
for await (const chunk of chunksOf(body, signal)) {
|
|
186
|
+
if (chunk.length > length - offset) throw new Error("snapshot exceeds declared size");
|
|
187
|
+
bytes.set(chunk, offset);
|
|
188
|
+
offset += chunk.length;
|
|
189
|
+
}
|
|
190
|
+
if (offset !== length) throw new Error("snapshot integrity mismatch");
|
|
191
|
+
return bytes;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The stream of `bytes` in pieces of at most 16 KiB, so that decompressing it
|
|
195
|
+
// holds only a little of the output at a time.
|
|
196
|
+
function streamOf(bytes) {
|
|
197
|
+
let offset = 0;
|
|
198
|
+
return new ReadableStream({
|
|
199
|
+
pull(controller) {
|
|
200
|
+
const end = Math.min(offset + 16 * 1024, bytes.length);
|
|
201
|
+
controller.enqueue(bytes.subarray(offset, end));
|
|
202
|
+
offset = end;
|
|
203
|
+
if (offset === bytes.length) controller.close();
|
|
204
|
+
},
|
|
205
|
+
}, { highWaterMark: 0 });
|
|
206
|
+
}
|
|
207
|
+
|
|
128
208
|
// The sink owns incremental hashing. Every part is checked before another is
|
|
129
209
|
// requested; the caller verifies the complete canonical image before booting.
|
|
130
|
-
export async function streamPackagedSystemSnapshot(
|
|
131
|
-
for await (const part of packagedSnapshotParts(
|
|
132
|
-
const reader = part.body.getReader();
|
|
210
|
+
export async function streamPackagedSystemSnapshot(metadata, write, endPart, signal) {
|
|
211
|
+
for await (const part of packagedSnapshotParts(metadata, signal)) {
|
|
133
212
|
let size = 0;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
213
|
+
for await (const chunk of chunksOf(gunzip(part.body), signal)) {
|
|
214
|
+
if (chunk.length > part.byteLength - size) throw new Error("snapshot exceeds declared size");
|
|
215
|
+
await write(chunk);
|
|
216
|
+
size += chunk.length;
|
|
217
|
+
}
|
|
218
|
+
if (size !== part.byteLength || await endPart() !== part.sha256) throw new Error("snapshot integrity mismatch");
|
|
219
|
+
await part.verified();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function snapshotHeader(count) {
|
|
224
|
+
const bytes = new Uint8Array(16), view = new DataView(bytes.buffer);
|
|
225
|
+
bytes.set(encoder.encode("DOLLYSNP"));
|
|
226
|
+
view.setUint32(8, 2, true);
|
|
227
|
+
view.setUint32(12, count, true);
|
|
228
|
+
return bytes;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function recordHeader(kind, path, size) {
|
|
232
|
+
const bytes = new Uint8Array(16 + path.length), view = new DataView(bytes.buffer);
|
|
233
|
+
view.setUint32(0, kind, true);
|
|
234
|
+
view.setUint32(4, path.length, true);
|
|
235
|
+
view.setBigUint64(8, BigInt(size), true);
|
|
236
|
+
bytes.set(path, 16);
|
|
237
|
+
return bytes;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function compareBytes(left, right) {
|
|
241
|
+
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
|
|
242
|
+
if (left[index] !== right[index]) return left[index] - right[index];
|
|
243
|
+
}
|
|
244
|
+
return left.length - right.length;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// One pack's bytes, decompressed again as its records are reached in path order.
|
|
248
|
+
class PackReader {
|
|
249
|
+
constructor(compressed) {
|
|
250
|
+
this.chunks = chunksOf(gunzip(streamOf(compressed)));
|
|
251
|
+
this.chunk = new Uint8Array(0);
|
|
252
|
+
this.position = 0; // the pack offset of this.chunk
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The pack's bytes [offset, offset + length), skipping those before it.
|
|
256
|
+
async *read(offset, length) {
|
|
257
|
+
const end = offset + length;
|
|
258
|
+
while (this.position < end) {
|
|
259
|
+
if (this.chunk.length === 0) {
|
|
260
|
+
const { done, value } = await this.chunks.next();
|
|
261
|
+
if (done) throw new Error("truncated snapshot pack");
|
|
262
|
+
this.chunk = value;
|
|
142
263
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
264
|
+
const start = Math.max(offset - this.position, 0), taken = Math.min(this.chunk.length, end - this.position);
|
|
265
|
+
if (taken > start) yield this.chunk.subarray(start, taken);
|
|
266
|
+
this.chunk = this.chunk.subarray(taken);
|
|
267
|
+
this.position += taken;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
close() { return this.chunks.return(); }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Checks every pack against its hash as it decompresses, keeping only its
|
|
275
|
+
// compressed bytes and where its records lie; returns the image's records in
|
|
276
|
+
// path order, each pack's read from its bytes decompressed once more.
|
|
277
|
+
async function packedRecords(metadata, signal) {
|
|
278
|
+
const records = [];
|
|
279
|
+
for await (const part of packagedSnapshotParts(metadata, signal)) {
|
|
280
|
+
const pack = { compressed: await readBytes(part.body, part.encodedByteLength, signal), unread: 0 };
|
|
281
|
+
const hash = new Sha256();
|
|
282
|
+
const scanned = await scanSnapshotRecords((async function* () {
|
|
283
|
+
for await (const chunk of chunksOf(gunzip(streamOf(pack.compressed)), signal)) {
|
|
284
|
+
hash.update(chunk);
|
|
285
|
+
yield chunk;
|
|
286
|
+
}
|
|
287
|
+
})(), () => false);
|
|
288
|
+
if (scanned.byteLength !== part.byteLength || hash.hex() !== part.sha256) throw new Error("snapshot integrity mismatch");
|
|
289
|
+
await part.verified();
|
|
290
|
+
for (const [path, { kind, offset, size }] of scanned.records) {
|
|
291
|
+
records.push({ pack, kind, path: encoder.encode(path), offset, size });
|
|
292
|
+
pack.unread += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
records.sort((left, right) => compareBytes(left.path, right.path));
|
|
296
|
+
return (async function* () {
|
|
297
|
+
yield snapshotHeader(records.length);
|
|
298
|
+
for (const { pack, kind, path, offset, size } of records) {
|
|
299
|
+
yield recordHeader(kind, path, size);
|
|
300
|
+
pack.reader ??= new PackReader(pack.compressed);
|
|
301
|
+
yield* pack.reader.read(offset, size);
|
|
302
|
+
if (--pack.unread === 0) {
|
|
303
|
+
await pack.reader.close();
|
|
304
|
+
pack.reader = pack.compressed = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Joins pieces into chunks of 1 MiB, the last one shorter.
|
|
311
|
+
async function* joined(pieces) {
|
|
312
|
+
const size = 1024 * 1024;
|
|
313
|
+
let chunk = new Uint8Array(size), used = 0;
|
|
314
|
+
for await (let piece of pieces) {
|
|
315
|
+
while (piece.length !== 0) {
|
|
316
|
+
const taken = Math.min(piece.length, size - used);
|
|
317
|
+
chunk.set(piece.subarray(0, taken), used);
|
|
318
|
+
used += taken;
|
|
319
|
+
piece = piece.subarray(taken);
|
|
320
|
+
if (used === size) {
|
|
321
|
+
yield chunk;
|
|
322
|
+
chunk = new Uint8Array(size);
|
|
323
|
+
used = 0;
|
|
164
324
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (used !== 0) yield chunk.subarray(0, used);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The image's canonical snapshot in chunks, merged from its packs as they are
|
|
331
|
+
// read: the page holds the packs' compressed bytes, not the image. Every pack
|
|
332
|
+
// matches its hash before the first chunk; the chunks end in an error, not
|
|
333
|
+
// their end, unless together they match the image's hash.
|
|
334
|
+
export async function packagedSystemSnapshot(metadata, signal) {
|
|
335
|
+
const pieces = await packedRecords(metadata, signal);
|
|
336
|
+
return (async function* () {
|
|
337
|
+
const hash = new Sha256();
|
|
338
|
+
let size = 0;
|
|
339
|
+
for await (const chunk of joined(pieces)) {
|
|
340
|
+
if (chunk.length > metadata.byteLength - size) throw new Error("snapshot exceeds declared size");
|
|
341
|
+
size += chunk.length;
|
|
342
|
+
hash.update(chunk);
|
|
343
|
+
yield chunk;
|
|
344
|
+
}
|
|
345
|
+
if (size !== metadata.byteLength || hash.hex() !== metadata.sha256) throw new Error("packed snapshot integrity mismatch");
|
|
346
|
+
})();
|
|
175
347
|
}
|