@daugasauron/dolly-build 0.0.0-stage → 0.1.5

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.
@@ -0,0 +1,69 @@
1
+ import { formatWasmType, sameWasmType } from "./wasm-interface.mjs";
2
+ import { hex } from "./static-asset.mjs";
3
+
4
+ export function unique(entries, key) {
5
+ const result = new Map();
6
+ for (const entry of entries) {
7
+ const name = key(entry);
8
+ if (result.has(name)) throw new TypeError(`duplicate process interface entry ${name}`);
9
+ result.set(name, entry);
10
+ }
11
+ return result;
12
+ }
13
+
14
+ // Both the build tools and browser admission call this exact validator on
15
+ // parsed bytes. The contract is assembled from abi/dolly-process-0.wat.
16
+ export function validateProcessInterface(contract, process, digest) {
17
+ const fail = message => { throw new TypeError(`${process.label}: ${message}`); };
18
+ const section = name => {
19
+ const entries = process.customSectionData.filter(x => x.name === name);
20
+ if (entries.length !== 1) fail(`expected exactly one ${name} section`);
21
+ return entries[0].data;
22
+ };
23
+ if (process.customSections.includes("dylink.0")) {
24
+ fail("a process executable must not be a side module");
25
+ }
26
+ const stamp = hex(section("dolly.process"));
27
+ if (stamp !== digest) fail("wrong dolly.process stamp");
28
+ const key = x => `${x.module}.${x.name}`;
29
+ const allowed = unique(contract.imports, key);
30
+ const actual = unique(process.imports, key);
31
+ if (allowed.size !== 2 || !allowed.has("env.memory") || !allowed.has("dolly_process_0.call")) {
32
+ throw new TypeError("process contract must contain only memory and call");
33
+ }
34
+ for (const name of actual.keys()) if (!allowed.has(name)) fail(`import ${name} is outside dolly-process-0`);
35
+ if (actual.size !== allowed.size) fail("expected exactly the two dolly-process-0 imports");
36
+ let memory;
37
+ for (const [name, expected] of allowed) {
38
+ const entry = actual.get(name);
39
+ if (!entry) fail(`missing required import ${name}`);
40
+ const type = entry.type;
41
+ if (name === "env.memory") {
42
+ if (type.kind !== "memory" || expected.type.kind !== "memory" ||
43
+ type.address64 !== expected.type.address64 || type.shared !== expected.type.shared ||
44
+ type.minimum < expected.type.minimum || type.maximum === null ||
45
+ expected.type.maximum === null || type.maximum > expected.type.maximum ||
46
+ type.minimum > type.maximum) {
47
+ fail(`process memory is outside ${formatWasmType(expected.type)}`);
48
+ }
49
+ const bytes = section("dolly.process.memory");
50
+ if (bytes.length !== 16) fail("expected a 16-byte dolly.process.memory section");
51
+ const record = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
52
+ if (record.getBigUint64(0, true) !== type.minimum ||
53
+ record.getBigUint64(8, true) !== type.maximum) {
54
+ fail("dolly.process.memory does not match its memory import");
55
+ }
56
+ memory = { initial: type.minimum, maximum: type.maximum };
57
+ } else if (!sameWasmType(type, expected.type)) {
58
+ fail(`import ${name} must be ${formatWasmType(expected.type)}`);
59
+ }
60
+ }
61
+ const exports = unique(process.exports, x => x.name);
62
+ for (const expected of contract.exports) {
63
+ const entry = exports.get(expected.name);
64
+ if (!entry || !sameWasmType(entry.type, expected.type)) {
65
+ fail(`missing or incompatible process export ${expected.name}`);
66
+ }
67
+ }
68
+ return memory;
69
+ }
@@ -0,0 +1,221 @@
1
+ import { unretainedPath } from "./image-names.mjs";
2
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
3
+ export const MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024 * 1024;
4
+ // Packs average 2 MiB or more (scripts/share-pages-snapshots.mjs): an image of
5
+ // MAX_SNAPSHOT_BYTES needs about 1,024, which this bound leaves room for.
6
+ const MAX_SNAPSHOT_PACKS = MAX_SNAPSHOT_BYTES / (1024 * 1024);
7
+
8
+ function compareBytes(left, right) {
9
+ for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
10
+ if (left[index] !== right[index]) return left[index] - right[index];
11
+ }
12
+ return left.length - right.length;
13
+ }
14
+
15
+ // The 16-byte snapshot header: its record count.
16
+ function decodeHeader(bytes) {
17
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
18
+ if (bytes.length < 16 || decoder.decode(bytes.subarray(0, 8)) !== "DOLLYSNP" || view.getUint32(8, true) !== 2) {
19
+ throw new Error("invalid Dolly snapshot header");
20
+ }
21
+ const count = view.getUint32(12, true);
22
+ if (count === 0 || count > 100_000) throw new Error("invalid Dolly snapshot record count");
23
+ return count;
24
+ }
25
+
26
+ // A record's 16-byte header: its kind, path length and data size.
27
+ function decodeRecordHeader(bytes) {
28
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
29
+ const kind = view.getUint32(0, true), pathLength = view.getUint32(4, true), size = view.getBigUint64(8, true);
30
+ if (kind < 1 || kind > 3 || pathLength < 2 || pathLength >= 4096 ||
31
+ size > BigInt(MAX_SNAPSHOT_BYTES) || (kind === 1 && size !== 0n) ||
32
+ (kind === 3 && (size === 0n || size >= 4096n))) throw new Error("invalid snapshot record size");
33
+ return { kind, pathLength, size: Number(size) };
34
+ }
35
+
36
+ // The path of PATHBYTES, valid and after PREVIOUS in byte order.
37
+ function decodePath(pathBytes, previous) {
38
+ const path = decoder.decode(pathBytes);
39
+ if (!path.startsWith("/") || /[\0\\\r\n]/.test(path) ||
40
+ path.slice(1).split("/").some(part => !part || part === "." || part === "..") ||
41
+ unretainedPath(path) ||
42
+ previous && compareBytes(previous, pathBytes) >= 0) throw new Error(`invalid snapshot path ${path}`);
43
+ return path;
44
+ }
45
+
46
+ function checkParents(records) {
47
+ for (const path of records.keys()) {
48
+ for (let slash = path.indexOf("/", 1); slash !== -1; slash = path.indexOf("/", slash + 1)) {
49
+ const parent = records.get(path.slice(0, slash));
50
+ if (parent && parent.kind !== 1) throw new Error(`snapshot parent is not a directory: ${path}`);
51
+ }
52
+ }
53
+ }
54
+
55
+ export function decodeSnapshotRecords(input) {
56
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
57
+ if (bytes.length > MAX_SNAPSHOT_BYTES) throw new Error("invalid Dolly snapshot header");
58
+ const count = decodeHeader(bytes.subarray(0, 16));
59
+ const records = new Map();
60
+ let offset = 16, previous;
61
+ for (let index = 0; index < count; index += 1) {
62
+ if (offset > bytes.length - 16) throw new Error("truncated snapshot record");
63
+ const { kind, pathLength, size } = decodeRecordHeader(bytes.subarray(offset, offset + 16));
64
+ offset += 16;
65
+ if (offset > bytes.length - pathLength - size) throw new Error("truncated snapshot record");
66
+ const pathBytes = bytes.subarray(offset, offset + pathLength);
67
+ const path = decodePath(pathBytes, previous);
68
+ previous = pathBytes;
69
+ offset += pathLength;
70
+ const data = bytes.subarray(offset, offset + size);
71
+ if (kind === 3 && data.includes(0)) throw new Error("invalid snapshot symlink");
72
+ records.set(path, { kind, data });
73
+ offset += size;
74
+ }
75
+ if (offset !== bytes.length) throw new Error("trailing snapshot bytes");
76
+ checkParents(records);
77
+ return records;
78
+ }
79
+
80
+ // Bytes that arrive in pieces (an iterable of Uint8Array), read in order.
81
+ class PieceReader {
82
+ constructor(pieces) {
83
+ this.pieces = pieces[Symbol.asyncIterator]?.() ?? pieces[Symbol.iterator]();
84
+ this.chunk = new Uint8Array(0);
85
+ this.position = 0;
86
+ }
87
+
88
+ async #fill() {
89
+ const { done, value } = await this.pieces.next();
90
+ if (done) return false;
91
+ if (!(value instanceof Uint8Array)) throw new TypeError("invalid snapshot piece");
92
+ this.chunk = value;
93
+ return true;
94
+ }
95
+
96
+ #advance(length) {
97
+ this.position += length;
98
+ if (this.position > MAX_SNAPSHOT_BYTES) throw new Error("snapshot exceeds its size limit");
99
+ }
100
+
101
+ // The next LENGTH bytes, a view into the current piece when they lie in one.
102
+ async take(length) {
103
+ if (length === 0) return new Uint8Array(0);
104
+ if (this.chunk.length === 0 && !await this.#fill()) throw new Error("truncated snapshot record");
105
+ if (this.chunk.length >= length) {
106
+ const bytes = this.chunk.subarray(0, length);
107
+ this.chunk = this.chunk.subarray(length);
108
+ this.#advance(length);
109
+ return bytes;
110
+ }
111
+ const bytes = new Uint8Array(length);
112
+ for (let filled = 0; filled < length;) {
113
+ if (this.chunk.length === 0 && !await this.#fill()) throw new Error("truncated snapshot record");
114
+ const taken = Math.min(this.chunk.length, length - filled);
115
+ bytes.set(this.chunk.subarray(0, taken), filled);
116
+ this.chunk = this.chunk.subarray(taken);
117
+ filled += taken;
118
+ }
119
+ this.#advance(length);
120
+ return bytes;
121
+ }
122
+
123
+ async skip(length) {
124
+ while (length !== 0) {
125
+ if (this.chunk.length === 0 && !await this.#fill()) throw new Error("truncated snapshot record");
126
+ const taken = Math.min(this.chunk.length, length);
127
+ this.chunk = this.chunk.subarray(taken);
128
+ this.#advance(taken);
129
+ length -= taken;
130
+ }
131
+ }
132
+
133
+ async ended() {
134
+ while (this.chunk.length === 0) if (!await this.#fill()) return true;
135
+ return false;
136
+ }
137
+ }
138
+
139
+ // The records of a snapshot that arrives in pieces, checked as
140
+ // decodeSnapshotRecords checks them without holding the snapshot: every path
141
+ // with its kind, where its data lies (offset, size), and the data of the paths
142
+ // KEEP selects (symlink targets always). Resolves to the records and the
143
+ // snapshot's size.
144
+ export async function scanSnapshotRecords(pieces, keep) {
145
+ const reader = new PieceReader(pieces);
146
+ const count = decodeHeader(await reader.take(16));
147
+ const records = new Map();
148
+ let previous;
149
+ for (let index = 0; index < count; index += 1) {
150
+ const { kind, pathLength, size } = decodeRecordHeader(await reader.take(16));
151
+ const pathBytes = await reader.take(pathLength);
152
+ const path = decodePath(pathBytes, previous);
153
+ previous = pathBytes;
154
+ const offset = reader.position;
155
+ let data;
156
+ if (kind === 3 || (kind === 2 && keep(path))) {
157
+ data = await reader.take(size);
158
+ if (kind === 3 && data.includes(0)) throw new Error("invalid snapshot symlink");
159
+ } else await reader.skip(size);
160
+ records.set(path, { kind, data, offset, size });
161
+ }
162
+ if (!await reader.ended()) throw new Error("trailing snapshot bytes");
163
+ checkParents(records);
164
+ return { records, byteLength: reader.position };
165
+ }
166
+
167
+ export function encodeSnapshotRecords(records) {
168
+ const encoder = new TextEncoder();
169
+ const entries = [...records].map(([path, record]) => ({ path: encoder.encode(path), ...record }))
170
+ .sort((left, right) => compareBytes(left.path, right.path));
171
+ const size = entries.reduce((sum, record) => sum + 16 + record.path.length + record.data.length, 16);
172
+ if (size > MAX_SNAPSHOT_BYTES || entries.length === 0 || entries.length > 100_000) throw new Error("snapshot exceeds record limits");
173
+ const bytes = new Uint8Array(size), view = new DataView(bytes.buffer);
174
+ bytes.set(encoder.encode("DOLLYSNP"));
175
+ view.setUint32(8, 2, true);
176
+ view.setUint32(12, entries.length, true);
177
+ let offset = 16;
178
+ for (const record of entries) {
179
+ view.setUint32(offset, record.kind, true);
180
+ view.setUint32(offset + 4, record.path.length, true);
181
+ view.setBigUint64(offset + 8, BigInt(record.data.length), true);
182
+ bytes.set(record.path, offset + 16);
183
+ bytes.set(record.data, offset + 16 + record.path.length);
184
+ offset += 16 + record.path.length + record.data.length;
185
+ }
186
+ return bytes;
187
+ }
188
+
189
+ export function mergeSnapshotRecords(parts) {
190
+ const records = new Map();
191
+ let size = 16;
192
+ for (const part of parts) {
193
+ for (const [path, record] of decodeSnapshotRecords(part)) {
194
+ if (records.has(path)) throw new Error(`duplicate packed snapshot path: ${path}`);
195
+ size += 16 + new TextEncoder().encode(path).length + record.data.length;
196
+ if (size > MAX_SNAPSHOT_BYTES || records.size >= 100_000) throw new Error("packed snapshot exceeds limits");
197
+ records.set(path, record);
198
+ }
199
+ }
200
+ const bytes = encodeSnapshotRecords(records);
201
+ decodeSnapshotRecords(bytes);
202
+ return bytes;
203
+ }
204
+
205
+ export function validateSnapshotPacks(metadata) {
206
+ if (!Array.isArray(metadata.packs) || metadata.packs.length === 0 || metadata.packs.length > MAX_SNAPSHOT_PACKS) throw new Error("invalid snapshot packs");
207
+ const hashes = new Set();
208
+ let size = 16;
209
+ for (const pack of metadata.packs) {
210
+ if (!/^[0-9a-f]{64}$/.test(pack.sha256) || hashes.has(pack.sha256) ||
211
+ !Number.isSafeInteger(pack.byteLength) || pack.byteLength < 16 || pack.byteLength > MAX_SNAPSHOT_BYTES ||
212
+ !Number.isSafeInteger(pack.encodedByteLength) || pack.encodedByteLength <= 0 || pack.encodedByteLength > MAX_SNAPSHOT_BYTES) {
213
+ throw new Error("invalid snapshot pack descriptor");
214
+ }
215
+ hashes.add(pack.sha256);
216
+ size += pack.byteLength - 16;
217
+ if (size > MAX_SNAPSHOT_BYTES) throw new Error("packed snapshot exceeds size limit");
218
+ }
219
+ if (size !== metadata.byteLength) throw new Error("snapshot pack sizes do not match image");
220
+ return metadata.packs;
221
+ }
@@ -0,0 +1,124 @@
1
+ import { MAX_SNAPSHOT_BYTES } from "./snapshot-records.mjs";
2
+ import { DOLLY_VERSION } from "./version.mjs";
3
+
4
+ // Static delivery for an already authorized bootstrap input or snapshot pack.
5
+ // Hosts cap file sizes, so assets travel as verified parts; nothing static is
6
+ // larger than the largest snapshot.
7
+ export const STATIC_PART_BYTES = 20 * 1024 * 1024;
8
+ export const staticMaxParts = () => Math.ceil(MAX_SNAPSHOT_BYTES / STATIC_PART_BYTES);
9
+
10
+ export const hex = bytes =>
11
+ Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), byte => byte.toString(16).padStart(2, "0")).join("");
12
+
13
+ export async function sha256(bytes) {
14
+ return hex(await crypto.subtle.digest("SHA-256", bytes));
15
+ }
16
+
17
+ // Recipes name a file this site publishes as a path that starts with the
18
+ // version, /v0.1.0/Dollyfile-system. Every page serves its own version's
19
+ // files wherever it is mounted, so where their bytes come from never changes
20
+ // recipe text, pins or image identity.
21
+ const sitePrefix = `/v${DOLLY_VERSION}`;
22
+ export const siteReference = path => `${sitePrefix}/${path}`;
23
+
24
+ // The public site, for the pages of another site that link to it. A recipe
25
+ // that names it by URL instead of a site path fails the lint.
26
+ export const PUBLIC_ORIGIN = "https://slopshare.dev";
27
+
28
+ // The path ("/Dollyfile-system") a site reference names among this page's
29
+ // files, or null for an absolute URL. Another version's files are not here.
30
+ export function sitePath(reference) {
31
+ if (!reference.startsWith("/")) return null;
32
+ if (!reference.startsWith(`${sitePrefix}/`)) {
33
+ throw new Error(`${reference}: this is Dolly ${DOLLY_VERSION}, which reads only ${sitePrefix}/ paths`);
34
+ }
35
+ return reference.slice(sitePrefix.length);
36
+ }
37
+
38
+ export async function boundedAssetBody(response, limit, signal) {
39
+ if (!response.ok || !response.body) throw new Error(`asset returned HTTP ${response.status}`);
40
+ const reader = response.body.getReader(), chunks = [];
41
+ let size = 0;
42
+ try {
43
+ for (;;) {
44
+ signal?.throwIfAborted();
45
+ const { done, value } = await reader.read();
46
+ if (done) break;
47
+ size += value.length;
48
+ if (size > limit) { await reader.cancel(); throw new Error("asset exceeds declared size"); }
49
+ chunks.push(value);
50
+ }
51
+ } finally { reader.releaseLock(); }
52
+ const bytes = new Uint8Array(size);
53
+ let offset = 0;
54
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
55
+ return bytes;
56
+ }
57
+
58
+ // A file a host stores in parts is a JSON manifest at its path that begins
59
+ // with these bytes, and no file stored whole does (the Pages exporter checks),
60
+ // so the page knows a manifest by its content: a host needs no header for it.
61
+ // Pages before v0.1.5 read the X-Dolly-Parts header instead.
62
+ export const PARTS_MANIFEST_START = '{"byteLength":';
63
+
64
+ // Whether a whole response's body begins as a manifest, read from a copy of it.
65
+ async function isPartsManifest(response) {
66
+ if (response.status !== 200 || !response.body) return false;
67
+ const start = new TextEncoder().encode(PARTS_MANIFEST_START), reader = response.clone().body.getReader();
68
+ const head = new Uint8Array(start.length);
69
+ let size = 0;
70
+ try {
71
+ while (size < start.length) {
72
+ const { done, value } = await reader.read();
73
+ if (done) break;
74
+ const taken = value.subarray(0, start.length - size);
75
+ head.set(taken, size);
76
+ size += taken.length;
77
+ }
78
+ } finally {
79
+ // Not awaited: cancelling a copy settles only once the response is read too.
80
+ reader.cancel().catch(() => {});
81
+ }
82
+ return size === start.length && head.every((byte, index) => byte === start[index]);
83
+ }
84
+
85
+ // Streams verified parts, holding at most one 20 MiB part per request. Each
86
+ // part matches the manifest hash before any of its bytes are delivered.
87
+ export async function decodeStaticAsset(response, target, init, maximumBytes, fetchRequest = globalThis.fetch.bind(globalThis)) {
88
+ if (!await isPartsManifest(response)) return response;
89
+ const url = new URL(target);
90
+ if ((init.method ?? "GET") !== "GET" || url.search || url.hash ||
91
+ url.username || url.password || !/^https?:$/.test(url.protocol)) throw new Error("invalid multipart asset request");
92
+ const manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await boundedAssetBody(response, 65536, init.signal)));
93
+ if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 ||
94
+ manifest.byteLength > maximumBytes || manifest.byteLength > MAX_SNAPSHOT_BYTES ||
95
+ !Array.isArray(manifest.parts) || manifest.parts.length < 2 || manifest.parts.length > staticMaxParts() ||
96
+ manifest.parts.some(part => !Number.isSafeInteger(part.byteLength) || part.byteLength <= 0 ||
97
+ part.byteLength > STATIC_PART_BYTES || !/^[0-9a-f]{64}$/.test(part.sha256)) ||
98
+ manifest.parts.reduce((size, part) => size + part.byteLength, 0) !== manifest.byteLength) {
99
+ throw new Error("invalid multipart asset manifest");
100
+ }
101
+ let index = 0;
102
+ const body = new ReadableStream({
103
+ async pull(controller) {
104
+ if (index === manifest.parts.length) { controller.close(); return; }
105
+ const part = manifest.parts[index];
106
+ // The manifest supplies no destinations. Parts are fixed siblings, with
107
+ // no guest headers, credentials, query strings or redirect authority.
108
+ const partURL = new URL(url);
109
+ partURL.pathname += `.part-${index++}`;
110
+ const result = await fetchRequest(partURL, { method: "GET", credentials: "omit", redirect: "error",
111
+ referrerPolicy: "no-referrer", cache: "force-cache", signal: init.signal });
112
+ const chunk = await boundedAssetBody(result, part.byteLength, init.signal);
113
+ if (chunk.length !== part.byteLength || await sha256(chunk) !== part.sha256) throw new Error("asset part integrity mismatch");
114
+ controller.enqueue(chunk);
115
+ },
116
+ }, { highWaterMark: 0 });
117
+ const headers = new Headers(response.headers);
118
+ headers.delete("x-dolly-parts");
119
+ headers.delete("content-encoding");
120
+ headers.set("content-length", String(manifest.byteLength));
121
+ const decoded = new Response(body, { status: response.status, statusText: response.statusText, headers });
122
+ Object.defineProperty(decoded, "url", { value: response.url });
123
+ return decoded;
124
+ }
@@ -0,0 +1,3 @@
1
+ // The version being developed: the release tag vX.Y.Z, the site path /vX.Y.Z/
2
+ // and the prefix of every recipe reference. package.json holds the same value.
3
+ export const DOLLY_VERSION = "0.1.5";