@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.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/host/abi.mjs +5 -0
- package/host/requirements.mjs +62 -0
- package/package.json +16 -4
- package/scripts/dolly-build-init/Dollyfile +54 -0
- package/scripts/dolly-build-init/public/index.html +61 -0
- package/scripts/dolly-build-init/public/theme.json +67 -0
- package/scripts/dolly-build-init/public/workspace/README.md +5 -0
- package/scripts/dolly-build.mjs +278 -0
- package/scripts/dollyfile-graph.mjs +41 -0
- package/scripts/image-directory.mjs +85 -0
- package/scripts/page-image-build.mjs +53 -0
- package/scripts/recipe-files.mjs +31 -0
- package/scripts/release-layout.mjs +29 -0
- package/scripts/serve.mjs +112 -0
- package/scripts/share-pages-snapshots.mjs +219 -0
- package/scripts/snapshot-identity.mjs +36 -0
- package/scripts/system-snapshot-format.mjs +103 -0
- package/scripts/wasm-interface.mjs +7 -0
- package/src/alphabetical.mjs +4 -0
- package/src/dollyfile-graph.mjs +127 -0
- package/src/dollyfile-view.mjs +335 -0
- package/src/image-entry.mjs +29 -0
- package/src/image-inputs.mjs +20 -0
- package/src/image-names.mjs +11 -0
- package/src/process-abi.mjs +69 -0
- package/src/snapshot-records.mjs +221 -0
- package/src/static-asset.mjs +124 -0
- package/src/version.mjs +3 -0
- package/src/wasm-interface.mjs +440 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { parseWasmInterface } from "../src/wasm-interface.mjs";
|
|
3
|
+
import { validateProcessInterface } from "../src/process-abi.mjs";
|
|
4
|
+
import { decodeImageEntry } from "../src/image-entry.mjs";
|
|
5
|
+
import { decodeSnapshotRecords } from "../src/snapshot-records.mjs";
|
|
6
|
+
|
|
7
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
8
|
+
|
|
9
|
+
export function decodeSystemSnapshot(input) {
|
|
10
|
+
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
11
|
+
const entries = decodeSnapshotRecords(bytes);
|
|
12
|
+
const files = new Map();
|
|
13
|
+
const directories = new Set(["/"]);
|
|
14
|
+
for (const [path, { kind, data }] of entries) {
|
|
15
|
+
if (kind === 2) files.set(path, data);
|
|
16
|
+
if (kind === 1) directories.add(path);
|
|
17
|
+
for (let slash = path.indexOf("/", 1); slash !== -1; slash = path.indexOf("/", slash + 1)) {
|
|
18
|
+
directories.add(path.slice(0, slash));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { files, entries, directories, manifest: [...entries.keys()] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Resolve against retained records only, never the packaging machine's files.
|
|
25
|
+
export function resolveSnapshotFile(snapshot, path) {
|
|
26
|
+
if (!path.startsWith("/") || path.includes("\0")) throw new Error("invalid snapshot file path");
|
|
27
|
+
let pending = path.split("/");
|
|
28
|
+
const resolved = [];
|
|
29
|
+
let links = 0;
|
|
30
|
+
while (pending.length) {
|
|
31
|
+
const part = pending.shift();
|
|
32
|
+
if (!part || part === ".") continue;
|
|
33
|
+
if (part === "..") { resolved.pop(); continue; }
|
|
34
|
+
const current = "/" + [...resolved, part].join("/");
|
|
35
|
+
const record = snapshot.entries.get(current);
|
|
36
|
+
if (record?.kind === 3) {
|
|
37
|
+
if (++links > 40) throw new Error(`snapshot symlink loop: ${path}`);
|
|
38
|
+
const target = decoder.decode(record.data);
|
|
39
|
+
if (target.startsWith("/")) resolved.length = 0;
|
|
40
|
+
pending = [...target.split("/"), ...pending];
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (pending.length && !snapshot.directories.has(current)) {
|
|
44
|
+
throw new Error(`snapshot parent is missing or not a directory: ${current}`);
|
|
45
|
+
}
|
|
46
|
+
if (!pending.length && record?.kind !== 2) {
|
|
47
|
+
throw new Error(`snapshot file is not retained: ${current}`);
|
|
48
|
+
}
|
|
49
|
+
resolved.push(part);
|
|
50
|
+
}
|
|
51
|
+
const record = snapshot.entries.get("/" + resolved.join("/"));
|
|
52
|
+
if (record?.kind !== 2) throw new Error(`snapshot file is not retained: ${path}`);
|
|
53
|
+
return record.data;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The ENTRY record, or null for an image that only builds.
|
|
57
|
+
export function validateSnapshotEntry(snapshot, contract, digest) {
|
|
58
|
+
if (!snapshot.files.has("/etc/dolly/entry")) return null;
|
|
59
|
+
const entry = decodeImageEntry(snapshot.files.get("/etc/dolly/entry"));
|
|
60
|
+
validateProcessInterface(contract,
|
|
61
|
+
parseWasmInterface(resolveSnapshotFile(snapshot, entry[0]), entry[0]), digest);
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export function decodeSnapshotEnvironment(bytes) {
|
|
67
|
+
if (!bytes) throw new Error("snapshot has an invalid environment record");
|
|
68
|
+
const input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
69
|
+
if (input.length < 16 ||
|
|
70
|
+
input.subarray(0, 8).toString("ascii") !== "DOLLYENV" ||
|
|
71
|
+
input.readUInt32LE(8) !== 1) {
|
|
72
|
+
throw new Error("snapshot has an invalid environment record");
|
|
73
|
+
}
|
|
74
|
+
const count = input.readUInt32LE(12);
|
|
75
|
+
if (count > 256) throw new Error("snapshot environment has too many entries");
|
|
76
|
+
let offset = 16;
|
|
77
|
+
const environment = new Map();
|
|
78
|
+
for (let index = 0; index < count; index += 1) {
|
|
79
|
+
if (offset > input.length - 8) {
|
|
80
|
+
throw new Error("snapshot environment is truncated");
|
|
81
|
+
}
|
|
82
|
+
const nameLength = input.readUInt32LE(offset);
|
|
83
|
+
const valueLength = input.readUInt32LE(offset + 4);
|
|
84
|
+
offset += 8;
|
|
85
|
+
if (nameLength === 0 || nameLength > 128 || valueLength > 64 * 1024 ||
|
|
86
|
+
offset > input.length - nameLength - valueLength) {
|
|
87
|
+
throw new Error("snapshot environment has an invalid entry");
|
|
88
|
+
}
|
|
89
|
+
const name = decoder.decode(input.subarray(offset, offset + nameLength));
|
|
90
|
+
offset += nameLength;
|
|
91
|
+
const value = decoder.decode(input.subarray(offset, offset + valueLength));
|
|
92
|
+
offset += valueLength;
|
|
93
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name) ||
|
|
94
|
+
name.includes("\0") || value.includes("\0") || environment.has(name)) {
|
|
95
|
+
throw new Error("snapshot environment has an invalid name or value");
|
|
96
|
+
}
|
|
97
|
+
environment.set(name, value);
|
|
98
|
+
}
|
|
99
|
+
if (offset !== input.length) {
|
|
100
|
+
throw new Error("snapshot environment has trailing data");
|
|
101
|
+
}
|
|
102
|
+
return environment;
|
|
103
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { parseWasmInterface } from "../src/wasm-interface.mjs";
|
|
3
|
+
export * from "../src/wasm-interface.mjs";
|
|
4
|
+
|
|
5
|
+
export async function readWasmInterface(path) {
|
|
6
|
+
return parseWasmInterface(await readFile(path), path);
|
|
7
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The one order of every list a visitor reads on the site's pages: by the
|
|
2
|
+
// visible name, ignoring case. Names that differ only in case keep one order.
|
|
3
|
+
const collator = new Intl.Collator("en", { sensitivity: "base" });
|
|
4
|
+
export const alphabetical = (left, right) => collator.compare(left, right) || (left < right ? -1 : left > right ? 1 : 0);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { inspectDollyfile, recipeFileName, imageFileName } from "./dollyfile-view.mjs";
|
|
2
|
+
import { sha256 } from "./static-asset.mjs";
|
|
3
|
+
|
|
4
|
+
const key = object => `${object.type}:${object.name}`;
|
|
5
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
6
|
+
|
|
7
|
+
// The recipe graph of one image, as /bin/dollyfile reads it: the root and the
|
|
8
|
+
// FROM/INSTALL/USE/COPY images it imports, each with its own imports. `read(url)`
|
|
9
|
+
// returns a recipe's bytes; this checks every pin. `artifacts` are the images
|
|
10
|
+
// a build of the root reads: its own rows and, before each INSTALL or USE, the rows
|
|
11
|
+
// that package's recipe installs, which the package names and does not hold. It is an inspection graph,
|
|
12
|
+
// not a dependency solver: runtime assertions may resolve against files and
|
|
13
|
+
// environment that recipes do not declare. Pass the same `recipes` cache to
|
|
14
|
+
// reuse parsed recipes across graphs.
|
|
15
|
+
export async function loadRecipeGraph(read, rootLocation, recipes = new Map()) {
|
|
16
|
+
const records = [], edges = [], artifacts = [];
|
|
17
|
+
const active = new Set(), images = new Map(), imageNames = new Map();
|
|
18
|
+
|
|
19
|
+
function parse(location) {
|
|
20
|
+
if (!recipes.has(location)) {
|
|
21
|
+
recipes.set(location, (async () => {
|
|
22
|
+
const bytes = await read(location);
|
|
23
|
+
return { ...inspectDollyfile(decoder.decode(bytes), location), sha256: await sha256(bytes) };
|
|
24
|
+
})());
|
|
25
|
+
}
|
|
26
|
+
return recipes.get(location);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function load(location, expected, root) {
|
|
30
|
+
if (active.has(location)) throw new Error(`${location}: recipe cycle`);
|
|
31
|
+
const parsed = await parse(location);
|
|
32
|
+
if (expected && parsed.sha256 !== expected) throw new Error(`${location}: stale recipe pin`);
|
|
33
|
+
// An uploaded root is labeled "Dollyfile"; every site path names its recipe's file.
|
|
34
|
+
const file = recipeFileName(location);
|
|
35
|
+
if (file !== "" && imageFileName(file) !== parsed.name) {
|
|
36
|
+
throw new Error(`${location}: ${parsed.role.toUpperCase()} ${parsed.name} must match its file name`);
|
|
37
|
+
}
|
|
38
|
+
if ((imageNames.get(parsed.name) ?? location) !== location) {
|
|
39
|
+
throw new Error(`${location}: image ${parsed.name} is already ${imageNames.get(parsed.name)}`);
|
|
40
|
+
}
|
|
41
|
+
imageNames.set(parsed.name, location);
|
|
42
|
+
if (!root && images.has(location)) return images.get(location);
|
|
43
|
+
active.add(location);
|
|
44
|
+
const record = { ...parsed, location, dependencies: [], imports: new Map(), artifactTargets: [], installs: [], installTargets: [] };
|
|
45
|
+
records.push(record);
|
|
46
|
+
const visible = new Map();
|
|
47
|
+
const published = new Map();
|
|
48
|
+
const operations = [
|
|
49
|
+
...record.artifacts,
|
|
50
|
+
...record.requirements.map(value => ({ ...value, operation: "requirement" })),
|
|
51
|
+
...record.exports.map(value => ({ ...value, operation: "export" })),
|
|
52
|
+
].sort((a, b) => a.line - b.line);
|
|
53
|
+
for (const operation of operations) {
|
|
54
|
+
if (["from", "install", "use", "copy"].includes(operation.operation)) {
|
|
55
|
+
const target = await load(operation.location, operation.sha256, false);
|
|
56
|
+
const installs = ["install", "use"].includes(operation.operation);
|
|
57
|
+
if (operation.operation === "from" ? target.role === "package" : installs && target.role !== "package") {
|
|
58
|
+
throw new Error(`${location}:${operation.line}: ${target.name} is a ${target.role}: ` +
|
|
59
|
+
"FROM takes an application or toolchain, INSTALL and USE a package");
|
|
60
|
+
}
|
|
61
|
+
// Host requirements are never inherited: a package's must be declared here.
|
|
62
|
+
if (operation.operation === "install") {
|
|
63
|
+
const missing = target.hostRequirements.filter(name => !record.hostRequirements.includes(name));
|
|
64
|
+
if (missing.length) {
|
|
65
|
+
throw new Error(`${location}:${operation.line}: ${target.name} needs REQUIRES HOST ${missing.join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
record.artifactTargets.push({ reference: operation, target });
|
|
69
|
+
if (root && installs) {
|
|
70
|
+
for (const needed of target.installs) {
|
|
71
|
+
if (!artifacts.some(held => held.sha256 === needed.sha256)) artifacts.push({ ...needed, line: operation.line });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// What a package uses is the build's alone: it is not among what arrives with it.
|
|
75
|
+
if (operation.operation === "install") {
|
|
76
|
+
const row = { operation: "install", location: operation.location, sha256: operation.sha256, line: operation.line, image: target.image };
|
|
77
|
+
for (const needed of [...target.installs, row]) {
|
|
78
|
+
if (!record.installs.some(held => held.sha256 === needed.sha256)) record.installs.push({ ...needed, line: operation.line });
|
|
79
|
+
}
|
|
80
|
+
for (const needed of [...target.installTargets, target]) {
|
|
81
|
+
if (!record.installTargets.includes(needed)) record.installTargets.push(needed);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (root) artifacts.push({ ...operation, image: target.image });
|
|
85
|
+
// A package keeps nothing of its base nor of what it installs; COPY takes files only.
|
|
86
|
+
const imports = operation.operation !== "copy" && record.role !== "package";
|
|
87
|
+
const imported = installs ? [...target.installTargets, target]
|
|
88
|
+
: operation.operation === "from" ? [target] : [];
|
|
89
|
+
for (const source of imported) {
|
|
90
|
+
for (const [name, provider] of source.scopeExporters) {
|
|
91
|
+
visible.set(name, provider);
|
|
92
|
+
if (imports) published.set(name, provider);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else if (operation.operation === "export") {
|
|
96
|
+
if (operation.details.length !== 0 || !visible.has(key(operation))) {
|
|
97
|
+
visible.set(key(operation), { module: record, exported: operation });
|
|
98
|
+
}
|
|
99
|
+
} else if (operation.operation === "requirement") {
|
|
100
|
+
const requirement = record.requirements.find(item => item.line === operation.line);
|
|
101
|
+
const provider = visible.get(key(operation));
|
|
102
|
+
if (provider) {
|
|
103
|
+
const edge = { consumer: record, requirement, provider: provider.module, exported: provider.exported };
|
|
104
|
+
edges.push(edge);
|
|
105
|
+
record.dependencies.push(edge);
|
|
106
|
+
record.imports.set(key(operation), provider);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// A recipe's own exports describe its completed state and win over imported
|
|
111
|
+
// ones. Bare TOOL and ENV names resolve against the last provider seen: a
|
|
112
|
+
// package, an inherited base, or runtime state.
|
|
113
|
+
for (const exported of record.exports) {
|
|
114
|
+
const provider = visible.get(key(exported));
|
|
115
|
+
const resolved = (exported.details.length === 0 || exported.type === "ENV") && provider && provider.module !== record
|
|
116
|
+
? { ...exported, details: provider.exported.details } : exported;
|
|
117
|
+
published.set(key(exported), { module: record, exported: resolved });
|
|
118
|
+
}
|
|
119
|
+
record.scopeExporters = published;
|
|
120
|
+
if (!root) images.set(location, record);
|
|
121
|
+
active.delete(location);
|
|
122
|
+
return record;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const root = await load(rootLocation, null, true);
|
|
126
|
+
return { root, records, edges, exporters: root.scopeExporters, artifacts };
|
|
127
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { hostRequirement, hostRequirements } from "../host/requirements.mjs";
|
|
2
|
+
import { unretainedPath, validName } from "./image-names.mjs";
|
|
3
|
+
|
|
4
|
+
export const MAX_DOLLYFILE_BYTES = 128 * 1024;
|
|
5
|
+
|
|
6
|
+
const objectTypes = new Set([
|
|
7
|
+
"TOOL", "LIB", "ENV", "FILE", "FOLDER", "HEADER",
|
|
8
|
+
]);
|
|
9
|
+
const sha256Pattern = /^[0-9a-f]{64}$/;
|
|
10
|
+
const whitespace = /[ \t\r\n\v\f]/;
|
|
11
|
+
const trim = value => value.replace(/^[ \t\r\n\v\f]+|[ \t\r\n\v\f]+$/g, "");
|
|
12
|
+
const byteLength = value => new TextEncoder().encode(value).byteLength;
|
|
13
|
+
|
|
14
|
+
function fail(label, line, message) {
|
|
15
|
+
throw new Error(`${label}:${line}: ${message}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function physicalLines(source, label) {
|
|
19
|
+
if (typeof source !== "string") throw new TypeError(`${label}: Dollyfile must be text`);
|
|
20
|
+
if (byteLength(source) > MAX_DOLLYFILE_BYTES || source.includes("\0")) {
|
|
21
|
+
throw new Error(`${label}: invalid Dollyfile text`);
|
|
22
|
+
}
|
|
23
|
+
return source.split(/\r\n|\r|\n/);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripComment(value) {
|
|
27
|
+
let quote = "";
|
|
28
|
+
let escaped = false;
|
|
29
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
30
|
+
const character = value[index];
|
|
31
|
+
if (escaped) escaped = false;
|
|
32
|
+
else if (character === "\\" && quote !== "'") escaped = true;
|
|
33
|
+
else if (quote) {
|
|
34
|
+
if (character === quote) quote = "";
|
|
35
|
+
} else if (character === "'" || character === '"') quote = character;
|
|
36
|
+
else if (character === "#" && (index === 0 || whitespace.test(value[index - 1]))) {
|
|
37
|
+
return value.slice(0, index);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The words of a row; `ends` receives the offset after each.
|
|
44
|
+
function words(value, label, line, ends = []) {
|
|
45
|
+
const result = [];
|
|
46
|
+
let word = "";
|
|
47
|
+
let quote = "";
|
|
48
|
+
let escaped = false;
|
|
49
|
+
let started = false;
|
|
50
|
+
for (let offset = 0; offset < value.length; offset += 1) {
|
|
51
|
+
const character = value[offset];
|
|
52
|
+
if (escaped) {
|
|
53
|
+
if (quote === '"' && !['$', '`', '"', "\\"].includes(character)) word += "\\";
|
|
54
|
+
word += character;
|
|
55
|
+
escaped = false;
|
|
56
|
+
started = true;
|
|
57
|
+
} else if (character === "\\" && quote !== "'") {
|
|
58
|
+
escaped = true;
|
|
59
|
+
started = true;
|
|
60
|
+
} else if (quote) {
|
|
61
|
+
if (character === quote) quote = "";
|
|
62
|
+
else word += character;
|
|
63
|
+
started = true;
|
|
64
|
+
} else if (character === "'" || character === '"') {
|
|
65
|
+
quote = character;
|
|
66
|
+
started = true;
|
|
67
|
+
} else if (whitespace.test(character)) {
|
|
68
|
+
if (started) {
|
|
69
|
+
result.push(word);
|
|
70
|
+
ends.push(offset);
|
|
71
|
+
word = "";
|
|
72
|
+
started = false;
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
word += character;
|
|
76
|
+
started = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (escaped || quote) fail(label, line, "unterminated quoted word");
|
|
80
|
+
if (started) {
|
|
81
|
+
result.push(word);
|
|
82
|
+
ends.push(value.length);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function directives(physical, label) {
|
|
88
|
+
const result = [];
|
|
89
|
+
for (let index = 0; index < physical.length; index += 1) {
|
|
90
|
+
const raw = physical[index];
|
|
91
|
+
const line = index + 1;
|
|
92
|
+
let logical = stripComment(raw).replace(/[ \t]+$/, "");
|
|
93
|
+
while (logical.endsWith("\\")) {
|
|
94
|
+
logical = logical.slice(0, -1);
|
|
95
|
+
index += 1;
|
|
96
|
+
if (index >= physical.length || index === physical.length - 1 && physical[index] === "") {
|
|
97
|
+
fail(label, line, "unterminated continuation");
|
|
98
|
+
}
|
|
99
|
+
logical += `${logical.length ? " " : ""}${stripComment(physical[index]).replace(/[ \t]+$/, "")}`;
|
|
100
|
+
}
|
|
101
|
+
if (byteLength(logical) > 64 * 1024) fail(label, line, "logical line is too long");
|
|
102
|
+
logical = trim(logical);
|
|
103
|
+
if (logical === "") continue;
|
|
104
|
+
const match = /^([^ \t\r\n\v\f]+)(?:[ \t\r\n\v\f]+(.*))?$/s.exec(logical);
|
|
105
|
+
const directive = match[1];
|
|
106
|
+
const args = match[2] ?? "";
|
|
107
|
+
let body = null;
|
|
108
|
+
let endLine = index + 1;
|
|
109
|
+
if (directive === "FILE") {
|
|
110
|
+
const bodyLines = [];
|
|
111
|
+
while (index + 1 < physical.length && physical[index + 1].startsWith(" ")) {
|
|
112
|
+
index += 1;
|
|
113
|
+
bodyLines.push(physical[index].slice(4));
|
|
114
|
+
endLine = index + 1;
|
|
115
|
+
}
|
|
116
|
+
if (bodyLines.length) body = bodyLines.join("\n") + "\n";
|
|
117
|
+
}
|
|
118
|
+
result.push({ line, endLine, directive, args, body });
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validAbsolutePath(value) {
|
|
124
|
+
return value.startsWith("/") && value.length > 1 && byteLength(value) < 4096 &&
|
|
125
|
+
!/[\\\r\n]/.test(value) && !value.endsWith("/") && !value.includes("//") &&
|
|
126
|
+
!value.split("/").some((part) => part === "." || part === "..");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A site path names a file one release of the site publishes: /vX.Y.Z/PATH,
|
|
130
|
+
// without a query. FROM, INSTALL and COPY take one whose file is
|
|
131
|
+
// Dollyfile[-NAME]; SOURCE takes one or an absolute http(s) URL without a
|
|
132
|
+
// fragment.
|
|
133
|
+
const sitePathSyntax = /^\/v\d+\.\d+\.\d+\/[^?#\\ \t\r\n\v\f]+$/;
|
|
134
|
+
const sourceURL = /^https?:\/\/[^/?#\\ \t\r\n\v\f]+(?:[/?][^#\\ \t\r\n\v\f]*)?$/;
|
|
135
|
+
// A URL parser resolves empty, "." and ".." path segments (also spelled with
|
|
136
|
+
// %2e) to another path, so a recipe names only normalized paths.
|
|
137
|
+
function normalizedPath(reference) {
|
|
138
|
+
const path = reference.split("?")[0].replace(/^https?:\/\/[^/]*/, "");
|
|
139
|
+
return !path.includes("//") && !path.split("/").some(segment => /^(?:\.|%2e){1,2}$/i.test(segment));
|
|
140
|
+
}
|
|
141
|
+
// The file a recipe's site path names, or "" when the value is not one.
|
|
142
|
+
export const recipeFileName = reference =>
|
|
143
|
+
sitePathSyntax.test(reference) && normalizedPath(reference) ? reference.slice(reference.lastIndexOf("/") + 1) : "";
|
|
144
|
+
// The image name a file "Dollyfile" or "Dollyfile-NAME" declares, or "".
|
|
145
|
+
export const imageFileName = file => file === "Dollyfile" ? "default" : file.startsWith("Dollyfile-") ? file.slice(10) : "";
|
|
146
|
+
const roles = { APPLICATION: "application", TOOLCHAIN: "toolchain", PACKAGE: "package" };
|
|
147
|
+
|
|
148
|
+
function assertObject(tokens, label, item, directive) {
|
|
149
|
+
if (tokens.length < 2 || !objectTypes.has(tokens[0])) {
|
|
150
|
+
fail(label, item.line, `invalid ${directive}; expected ${directive} <${[...objectTypes].join("|")}> NAME`);
|
|
151
|
+
}
|
|
152
|
+
if (tokens[0] === "ENV" && !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(tokens[1])) {
|
|
153
|
+
fail(label, item.line, `invalid ${directive} environment name`);
|
|
154
|
+
}
|
|
155
|
+
if (tokens[0] !== "ENV" && (tokens[1].length > 128 || !/^(?:[a-zA-Z][a-zA-Z0-9._+-]*|\[)$/.test(tokens[1]))) {
|
|
156
|
+
fail(label, item.line, `invalid ${directive}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// RUN and ENTRY share one argument vector grammar and its record limits.
|
|
161
|
+
function programWords(tokens, label, item) {
|
|
162
|
+
if (tokens.length === 0 || !validAbsolutePath(tokens[0])) fail(label, item.line, `invalid ${item.directive}`);
|
|
163
|
+
if (tokens.length > 256 || tokens.some(word => byteLength(word) > 4096) ||
|
|
164
|
+
tokens.reduce((size, word) => size + 4 + byteLength(word), 16) > 64 * 1024) {
|
|
165
|
+
fail(label, item.line, `${item.directive} exceeds its record limits`);
|
|
166
|
+
}
|
|
167
|
+
return tokens;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function inspectRecipe(source, label, rows) {
|
|
171
|
+
let role = null;
|
|
172
|
+
let name = null;
|
|
173
|
+
let entry = null;
|
|
174
|
+
const requirements = [];
|
|
175
|
+
const exports = [];
|
|
176
|
+
const sources = [];
|
|
177
|
+
const slops = [];
|
|
178
|
+
const files = [];
|
|
179
|
+
const folders = [];
|
|
180
|
+
const artifacts = [];
|
|
181
|
+
let from = null;
|
|
182
|
+
// REQUIRES HOST lines are the image's manifest: they follow the role line.
|
|
183
|
+
let operations = 0;
|
|
184
|
+
|
|
185
|
+
for (const item of rows.slice(1)) {
|
|
186
|
+
const ends = [];
|
|
187
|
+
const tokens = words(item.args, label, item.line, ends);
|
|
188
|
+
// What /bin/dollyfile executes for the row (recipeRows).
|
|
189
|
+
item.fields = [item.directive, ...tokens];
|
|
190
|
+
if (role === null && !(item.directive in roles)) {
|
|
191
|
+
fail(label, item.line, "expected APPLICATION, TOOLCHAIN or PACKAGE");
|
|
192
|
+
}
|
|
193
|
+
if (entry) fail(label, item.line, "ENTRY must be the final declaration");
|
|
194
|
+
switch (item.directive) {
|
|
195
|
+
case "APPLICATION":
|
|
196
|
+
case "TOOLCHAIN":
|
|
197
|
+
case "PACKAGE":
|
|
198
|
+
if (role || tokens.length !== 1 || !validName(tokens[0])) fail(label, item.line, `invalid ${item.directive}`);
|
|
199
|
+
role = roles[item.directive];
|
|
200
|
+
name = tokens[0];
|
|
201
|
+
break;
|
|
202
|
+
case "FROM":
|
|
203
|
+
case "INSTALL":
|
|
204
|
+
case "USE":
|
|
205
|
+
case "COPY": {
|
|
206
|
+
const operation = item.directive.toLowerCase();
|
|
207
|
+
if (tokens.length !== (operation === "copy" ? 4 : 2) ||
|
|
208
|
+
!validName(imageFileName(recipeFileName(tokens[0]))) || !sha256Pattern.test(tokens[1]) ||
|
|
209
|
+
tokens.slice(2).some(path => path !== "/" && !validAbsolutePath(path))) {
|
|
210
|
+
fail(label, item.line, `invalid ${item.directive}`);
|
|
211
|
+
}
|
|
212
|
+
if (operation === "from" && operations !== 0) {
|
|
213
|
+
fail(label, item.line, "FROM must be the first operation");
|
|
214
|
+
}
|
|
215
|
+
if (operation === "use" && role !== "package") {
|
|
216
|
+
fail(label, item.line, "USE is for a PACKAGE recipe, which keeps nothing it installs");
|
|
217
|
+
}
|
|
218
|
+
operations += 1;
|
|
219
|
+
const artifact = { location: tokens[0], sha256: tokens[1], line: item.line,
|
|
220
|
+
source: tokens[2] ?? "/", destination: tokens[3] ?? "/", operation };
|
|
221
|
+
artifacts.push(artifact);
|
|
222
|
+
if (operation === "from") from = artifact;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "SOURCE":
|
|
226
|
+
operations += 1;
|
|
227
|
+
if (tokens.length !== 3 || !(sitePathSyntax.test(tokens[0]) || sourceURL.test(tokens[0])) || !normalizedPath(tokens[0]) ||
|
|
228
|
+
!sha256Pattern.test(tokens[1]) || !validAbsolutePath(tokens[2])) fail(label, item.line, "invalid SOURCE");
|
|
229
|
+
sources.push({ location: tokens[0], sha256: tokens[1], destination: tokens[2], line: item.line });
|
|
230
|
+
break;
|
|
231
|
+
case "REQUIRES":
|
|
232
|
+
if (tokens[0] === "HOST") {
|
|
233
|
+
if (tokens.length !== 2) fail(label, item.line, "invalid REQUIRES HOST; expected REQUIRES HOST NAME@ABI");
|
|
234
|
+
if (operations !== 0) fail(label, item.line, "REQUIRES HOST lines follow the role line, before every other declaration");
|
|
235
|
+
try { hostRequirement(tokens[1]); } catch (error) { fail(label, item.line, error.message); }
|
|
236
|
+
} else {
|
|
237
|
+
assertObject(tokens, label, item, "REQUIRES");
|
|
238
|
+
if (!["TOOL", "LIB", "HEADER"].includes(tokens[0])) fail(label, item.line, "REQUIRES takes HOST, TOOL, LIB or HEADER");
|
|
239
|
+
operations += 1;
|
|
240
|
+
}
|
|
241
|
+
if (tokens.length !== 2) fail(label, item.line, "invalid REQUIRES");
|
|
242
|
+
if (tokens[0] === "TOOL" && tokens[1] === "slop") fail(label, item.line, "SLOP depends on /bin/slop by definition");
|
|
243
|
+
requirements.push({ type: tokens[0], name: tokens[1], line: item.line });
|
|
244
|
+
break;
|
|
245
|
+
case "EXPORTS": {
|
|
246
|
+
operations += 1;
|
|
247
|
+
assertObject(tokens, label, item, "EXPORTS");
|
|
248
|
+
const [type, object, ...details] = tokens;
|
|
249
|
+
if (type === "TOOL") {
|
|
250
|
+
if (details.length !== 0) fail(label, item.line, "invalid TOOL export");
|
|
251
|
+
} else if (type === "ENV") {
|
|
252
|
+
if (details.length !== 1) {
|
|
253
|
+
fail(label, item.line, "invalid ENV export");
|
|
254
|
+
}
|
|
255
|
+
} else if (details.length !== 1 || !validAbsolutePath(details[0]) || unretainedPath(details[0])) {
|
|
256
|
+
fail(label, item.line, `invalid ${type} export`);
|
|
257
|
+
}
|
|
258
|
+
exports.push({ type, name: object, details, line: item.line });
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case "FILE":
|
|
262
|
+
operations += 1;
|
|
263
|
+
if (tokens.length !== 1 || !validAbsolutePath(tokens[0])) fail(label, item.line, "invalid FILE");
|
|
264
|
+
if (unretainedPath(tokens[0]) && !tokens[0].startsWith("/tmp/")) {
|
|
265
|
+
fail(label, item.line, "FILE cannot retain mutable session state");
|
|
266
|
+
}
|
|
267
|
+
files.push({ path: tokens[0], body: item.body, line: item.line, endLine: item.endLine });
|
|
268
|
+
if (item.body !== null) item.fields.push(item.body);
|
|
269
|
+
break;
|
|
270
|
+
case "FOLDER":
|
|
271
|
+
operations += 1;
|
|
272
|
+
if (tokens.length !== 1 || !validAbsolutePath(tokens[0]) ||
|
|
273
|
+
unretainedPath(tokens[0])) fail(label, item.line, "invalid FOLDER");
|
|
274
|
+
folders.push({ path: tokens[0], line: item.line });
|
|
275
|
+
break;
|
|
276
|
+
case "RUN":
|
|
277
|
+
case "SLOP": {
|
|
278
|
+
operations += 1;
|
|
279
|
+
let cwd = "/";
|
|
280
|
+
let command = tokens;
|
|
281
|
+
if (tokens[0] === "CWD") {
|
|
282
|
+
if (tokens.length < 3 || (tokens[1] !== "/" && !validAbsolutePath(tokens[1]))) {
|
|
283
|
+
fail(label, item.line, `invalid ${item.directive} CWD`);
|
|
284
|
+
}
|
|
285
|
+
cwd = tokens[1];
|
|
286
|
+
command = tokens.slice(2);
|
|
287
|
+
}
|
|
288
|
+
if (item.directive === "RUN") {
|
|
289
|
+
item.fields = ["RUN", cwd, ...programWords(command, label, item)];
|
|
290
|
+
} else {
|
|
291
|
+
if (!command[0]) fail(label, item.line, "empty SLOP");
|
|
292
|
+
slops.push({ cwd, command, line: item.line });
|
|
293
|
+
// Slop reads the command as written, quoting and all.
|
|
294
|
+
item.fields = ["SLOP", cwd, trim(tokens[0] === "CWD" ? item.args.slice(ends[1]) : item.args)];
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "ENTRY":
|
|
299
|
+
if (!["application", "toolchain"].includes(role)) fail(label, item.line, "invalid ENTRY");
|
|
300
|
+
entry = programWords(tokens, label, item);
|
|
301
|
+
break;
|
|
302
|
+
case "DOLLY":
|
|
303
|
+
fail(label, item.line, "DOLLY may only appear on the first line");
|
|
304
|
+
break;
|
|
305
|
+
default:
|
|
306
|
+
fail(label, item.line, `unknown directive ${item.directive}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (role === null) throw new Error(`${label}: missing APPLICATION, TOOLCHAIN or PACKAGE`);
|
|
310
|
+
if (role === "application" && !entry) throw new Error(`${label}: APPLICATION is missing ENTRY`);
|
|
311
|
+
let required;
|
|
312
|
+
try { required = hostRequirements(requirements.filter(item => item.type === "HOST").map(item => item.name)); }
|
|
313
|
+
catch (error) { throw new Error(`${label}: ${error.message}`); }
|
|
314
|
+
return {
|
|
315
|
+
hostRequirements: required, role, name, image: name, entry,
|
|
316
|
+
requirements, exports, artifacts, from, sources, slops, files, folders, rows, source,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The rows /bin/dollyfile executes, which the page writes to
|
|
321
|
+
// /etc/dolly/recipe.rows: the engine parses no recipe text, so this file is the
|
|
322
|
+
// language's one parser. Every field ends with NUL, which no recipe holds:
|
|
323
|
+
// "DOLLY 7 SHA256" of the recipe's text, then for each row after DOLLY
|
|
324
|
+
// "LINE COUNT" and that many fields, the directive first.
|
|
325
|
+
export const recipeRows = (recipe, sha256) => [`DOLLY 7 ${sha256}`,
|
|
326
|
+
...recipe.rows.slice(1).flatMap(row => [`${row.line} ${row.fields.length}`, ...row.fields])]
|
|
327
|
+
.map(field => `${field}\0`).join("");
|
|
328
|
+
|
|
329
|
+
export function inspectDollyfile(source, label = "Dollyfile") {
|
|
330
|
+
const rows = directives(physicalLines(source, label), label);
|
|
331
|
+
if (rows[0]?.directive !== "DOLLY" || rows[0].args !== "7") {
|
|
332
|
+
throw new Error(`${label}:${rows[0]?.line ?? 1}: first declaration must be DOLLY 7`);
|
|
333
|
+
}
|
|
334
|
+
return inspectRecipe(source, label, rows);
|
|
335
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Shared by packaging and browser admission; empty arguments are valid.
|
|
2
|
+
export function decodeImageEntry(bytes) {
|
|
3
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 16 || bytes.byteLength > 64 * 1024) {
|
|
4
|
+
throw new TypeError("invalid image ENTRY record");
|
|
5
|
+
}
|
|
6
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
7
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
8
|
+
if (decoder.decode(bytes.subarray(0, 8)) !== "DOLLYENT" || view.getUint32(8, true) !== 1) {
|
|
9
|
+
throw new TypeError("invalid image ENTRY version");
|
|
10
|
+
}
|
|
11
|
+
const count = view.getUint32(12, true);
|
|
12
|
+
if (count === 0 || count > 256) throw new TypeError("invalid image ENTRY count");
|
|
13
|
+
let offset = 16;
|
|
14
|
+
const arguments_ = [];
|
|
15
|
+
for (let index = 0; index < count; ++index) {
|
|
16
|
+
if (offset > bytes.byteLength - 4) throw new TypeError("truncated image ENTRY");
|
|
17
|
+
const size = view.getUint32(offset, true);
|
|
18
|
+
offset += 4;
|
|
19
|
+
if (size > 4096 || offset > bytes.byteLength - size) throw new TypeError("invalid image ENTRY argument");
|
|
20
|
+
const value = decoder.decode(bytes.subarray(offset, offset + size));
|
|
21
|
+
if (value.includes("\0")) throw new TypeError("image ENTRY has a NUL argument");
|
|
22
|
+
arguments_.push(value);
|
|
23
|
+
offset += size;
|
|
24
|
+
}
|
|
25
|
+
if (offset !== bytes.byteLength || !arguments_[0].startsWith("/")) {
|
|
26
|
+
throw new TypeError("invalid image ENTRY payload");
|
|
27
|
+
}
|
|
28
|
+
return arguments_;
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Recipe pins identify instructions; artifact digests identify their results.
|
|
2
|
+
export function imageInputs(artifacts = []) {
|
|
3
|
+
if (!Array.isArray(artifacts) || artifacts.length > 256) throw new Error("invalid image inputs");
|
|
4
|
+
const inputs = new Map();
|
|
5
|
+
for (const artifact of artifacts) {
|
|
6
|
+
const { recipeSha256, sha256 } = artifact ?? {};
|
|
7
|
+
if (!/^[0-9a-f]{64}$/.test(recipeSha256) || !/^[0-9a-f]{64}$/.test(sha256)) {
|
|
8
|
+
throw new Error("invalid image input digest");
|
|
9
|
+
}
|
|
10
|
+
if (inputs.has(recipeSha256) && inputs.get(recipeSha256).sha256 !== sha256) {
|
|
11
|
+
throw new Error("conflicting image inputs");
|
|
12
|
+
}
|
|
13
|
+
inputs.set(recipeSha256, { recipeSha256, sha256 });
|
|
14
|
+
}
|
|
15
|
+
return [...inputs.values()].sort((a, b) => a.recipeSha256.localeCompare(b.recipeSha256));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function imageInputsMatch(actual, expected) {
|
|
19
|
+
return JSON.stringify(imageInputs(actual)) === JSON.stringify(imageInputs(expected));
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// What a recipe may name: an image, and the paths no image retains.
|
|
2
|
+
|
|
3
|
+
// Image names: [a-z][a-z0-9]*(-[a-z0-9]+|\.[0-9]+)*, at most 32 bytes. A dot
|
|
4
|
+
// starts a run of version digits, so a documentation copy such as
|
|
5
|
+
// Dollyfile-example.txt is never a recipe.
|
|
6
|
+
export const validName = name => /^[a-z][a-z0-9]*(?:-[a-z0-9]+|\.[0-9]+)*$/.test(name) && name.length <= 32;
|
|
7
|
+
|
|
8
|
+
// No image retains scratch space.
|
|
9
|
+
export function unretainedPath(value) {
|
|
10
|
+
return ["/tmp", "/workspace"].some((prefix) => value === prefix || value.startsWith(`${prefix}/`));
|
|
11
|
+
}
|