@johnhenry/sitefile 0.0.0-stage → 0.0.1
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/CHANGELOG.md +152 -0
- package/LICENSE +21 -0
- package/README.md +513 -2
- package/lib/src/archive-writers.d.ts +31 -0
- package/lib/src/archive-writers.js +197 -0
- package/lib/src/attest.d.ts +126 -0
- package/lib/src/attest.js +220 -0
- package/lib/src/bake.d.ts +74 -0
- package/lib/src/bake.js +372 -0
- package/lib/src/base-image.d.ts +76 -0
- package/lib/src/base-image.js +138 -0
- package/lib/src/build-args.d.ts +26 -0
- package/lib/src/build-args.js +164 -0
- package/lib/src/cache-limit.d.ts +54 -0
- package/lib/src/cache-limit.js +239 -0
- package/lib/src/casefold-data.d.ts +2 -0
- package/lib/src/casefold-data.js +217 -0
- package/lib/src/cbor.d.ts +39 -0
- package/lib/src/cbor.js +174 -0
- package/lib/src/checklinks.d.ts +42 -0
- package/lib/src/checklinks.js +120 -0
- package/lib/src/cli/bake.d.ts +2 -0
- package/lib/src/cli/bake.js +179 -0
- package/lib/src/cli/build-image.d.ts +21 -0
- package/lib/src/cli/build-image.js +42 -0
- package/lib/src/cli/cache.d.ts +8 -0
- package/lib/src/cli/cache.js +119 -0
- package/lib/src/cli/common.d.ts +29 -0
- package/lib/src/cli/common.js +100 -0
- package/lib/src/cli/git-context.d.ts +18 -0
- package/lib/src/cli/git-context.js +48 -0
- package/lib/src/cli/images.d.ts +6 -0
- package/lib/src/cli/images.js +285 -0
- package/lib/src/cli/index.d.ts +10 -0
- package/lib/src/cli/index.js +95 -0
- package/lib/src/cli/network.d.ts +34 -0
- package/lib/src/cli/network.js +56 -0
- package/lib/src/cli/outdated.d.ts +5 -0
- package/lib/src/cli/outdated.js +99 -0
- package/lib/src/cli/registry.d.ts +2 -0
- package/lib/src/cli/registry.js +127 -0
- package/lib/src/cli/serve-worker.d.ts +2 -0
- package/lib/src/cli/serve-worker.js +42 -0
- package/lib/src/cli/servers.d.ts +15 -0
- package/lib/src/cli/servers.js +467 -0
- package/lib/src/cli/vendor.d.ts +2 -0
- package/lib/src/cli/vendor.js +97 -0
- package/lib/src/cli/watch.d.ts +26 -0
- package/lib/src/cli/watch.js +81 -0
- package/lib/src/cli.d.ts +12 -0
- package/lib/src/cli.js +522 -0
- package/lib/src/csv.d.ts +27 -0
- package/lib/src/csv.js +117 -0
- package/lib/src/deflate.d.ts +76 -0
- package/lib/src/deflate.js +604 -0
- package/lib/src/digest.d.ts +3 -0
- package/lib/src/digest.js +7 -0
- package/lib/src/errors.d.ts +56 -0
- package/lib/src/errors.js +29 -0
- package/lib/src/executor.d.ts +393 -0
- package/lib/src/executor.js +2201 -0
- package/lib/src/explain.d.ts +64 -0
- package/lib/src/explain.js +445 -0
- package/lib/src/export-formats.d.ts +23 -0
- package/lib/src/export-formats.js +63 -0
- package/lib/src/exporter.d.ts +103 -0
- package/lib/src/exporter.js +676 -0
- package/lib/src/fingerprint.d.ts +40 -0
- package/lib/src/fingerprint.js +168 -0
- package/lib/src/frontmatter.d.ts +22 -0
- package/lib/src/frontmatter.js +45 -0
- package/lib/src/index.d.ts +13 -0
- package/lib/src/index.js +13 -0
- package/lib/src/language/ast.d.ts +360 -0
- package/lib/src/language/ast.js +57 -0
- package/lib/src/language/parser.d.ts +3 -0
- package/lib/src/language/parser.js +612 -0
- package/lib/src/language/scanner.d.ts +110 -0
- package/lib/src/language/scanner.js +449 -0
- package/lib/src/language/validate.d.ts +13 -0
- package/lib/src/language/validate.js +367 -0
- package/lib/src/links.d.ts +79 -0
- package/lib/src/links.js +583 -0
- package/lib/src/lint.d.ts +10 -0
- package/lib/src/lint.js +223 -0
- package/lib/src/markdown.d.ts +29 -0
- package/lib/src/markdown.js +612 -0
- package/lib/src/media.d.ts +82 -0
- package/lib/src/media.js +650 -0
- package/lib/src/memo.d.ts +57 -0
- package/lib/src/memo.js +93 -0
- package/lib/src/oci.d.ts +178 -0
- package/lib/src/oci.js +380 -0
- package/lib/src/outdated.d.ts +46 -0
- package/lib/src/outdated.js +195 -0
- package/lib/src/pages.d.ts +10 -0
- package/lib/src/pages.js +54 -0
- package/lib/src/paths.d.ts +39 -0
- package/lib/src/paths.js +101 -0
- package/lib/src/planner.d.ts +297 -0
- package/lib/src/planner.js +410 -0
- package/lib/src/plugins/cache.d.ts +96 -0
- package/lib/src/plugins/cache.js +325 -0
- package/lib/src/plugins/exec.d.ts +51 -0
- package/lib/src/plugins/exec.js +144 -0
- package/lib/src/plugins/host.d.ts +75 -0
- package/lib/src/plugins/host.js +277 -0
- package/lib/src/plugins/wasm.d.ts +23 -0
- package/lib/src/plugins/wasm.js +249 -0
- package/lib/src/profile.d.ts +42 -0
- package/lib/src/profile.js +68 -0
- package/lib/src/ref.d.ts +40 -0
- package/lib/src/ref.js +79 -0
- package/lib/src/registry-auth.d.ts +32 -0
- package/lib/src/registry-auth.js +151 -0
- package/lib/src/registry-cache.d.ts +71 -0
- package/lib/src/registry-cache.js +333 -0
- package/lib/src/registry-ops.d.ts +51 -0
- package/lib/src/registry-ops.js +248 -0
- package/lib/src/registry.d.ts +115 -0
- package/lib/src/registry.js +524 -0
- package/lib/src/render.d.ts +134 -0
- package/lib/src/render.js +576 -0
- package/lib/src/resources/archive.d.ts +11 -0
- package/lib/src/resources/archive.js +306 -0
- package/lib/src/resources/bundle-data.d.ts +16 -0
- package/lib/src/resources/bundle-data.js +50 -0
- package/lib/src/resources/convert.d.ts +12 -0
- package/lib/src/resources/convert.js +63 -0
- package/lib/src/resources/documents.d.ts +35 -0
- package/lib/src/resources/documents.js +122 -0
- package/lib/src/resources/git.d.ts +103 -0
- package/lib/src/resources/git.js +720 -0
- package/lib/src/resources/https.d.ts +74 -0
- package/lib/src/resources/https.js +413 -0
- package/lib/src/resources/ignore.d.ts +33 -0
- package/lib/src/resources/ignore.js +119 -0
- package/lib/src/resources/local.d.ts +64 -0
- package/lib/src/resources/local.js +248 -0
- package/lib/src/resources/lockfile.d.ts +52 -0
- package/lib/src/resources/lockfile.js +172 -0
- package/lib/src/resources/netpolicy.d.ts +6 -0
- package/lib/src/resources/netpolicy.js +82 -0
- package/lib/src/resources/network.d.ts +70 -0
- package/lib/src/resources/network.js +304 -0
- package/lib/src/resources/types.d.ts +69 -0
- package/lib/src/resources/types.js +13 -0
- package/lib/src/resources/vendor.d.ts +94 -0
- package/lib/src/resources/vendor.js +296 -0
- package/lib/src/resources/wbn-reader.d.ts +44 -0
- package/lib/src/resources/wbn-reader.js +302 -0
- package/lib/src/schema.d.ts +25 -0
- package/lib/src/schema.js +169 -0
- package/lib/src/secrets.d.ts +21 -0
- package/lib/src/secrets.js +103 -0
- package/lib/src/serve-rules.d.ts +29 -0
- package/lib/src/serve-rules.js +123 -0
- package/lib/src/server.d.ts +45 -0
- package/lib/src/server.js +246 -0
- package/lib/src/servers.d.ts +85 -0
- package/lib/src/servers.js +225 -0
- package/lib/src/site.d.ts +60 -0
- package/lib/src/site.js +169 -0
- package/lib/src/store.d.ts +151 -0
- package/lib/src/store.js +596 -0
- package/lib/src/tree.d.ts +50 -0
- package/lib/src/tree.js +134 -0
- package/lib/src/values.d.ts +7 -0
- package/lib/src/values.js +37 -0
- package/lib/src/vendor.d.ts +19 -0
- package/lib/src/vendor.js +145 -0
- package/lib/src/watch.d.ts +44 -0
- package/lib/src/watch.js +137 -0
- package/lib/src/wbn.d.ts +26 -0
- package/lib/src/wbn.js +241 -0
- package/lib/src/yaml.d.ts +34 -0
- package/lib/src/yaml.js +562 -0
- package/package.json +59 -4
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { INTEGRITY_RE, sha256 } from '../digest.js';
|
|
5
|
+
import { SitefileError, isSitefileError } from '../errors.js';
|
|
6
|
+
import { MEDIA, parseManifest } from '../oci.js';
|
|
7
|
+
import { selectPlatform } from '../registry-ops.js';
|
|
8
|
+
import { extractArchive } from './archive.js';
|
|
9
|
+
/**
|
|
10
|
+
* The vendor directory written by `sitefile vendor` (issue #96): every remote input's bytes under
|
|
11
|
+
* content-addressed names, plus a manifest that maps URLs and image tags to them.
|
|
12
|
+
*
|
|
13
|
+
* <dir>/manifest.json {"version":1,"resources":{url:{integrity,size,resolved}},"images":{ref:{digest,platform,manifest}}}
|
|
14
|
+
* <dir>/blobs/sha256/<hex> IMPORT bodies and BASE image blobs (index, manifest, config, layers)
|
|
15
|
+
*
|
|
16
|
+
* Builds prefer these copies over the network and verify each one against its digest (and, as for any
|
|
17
|
+
* fetch, against INTEGRITY and Sitefile.lock). Nothing secret is written: URLs with credentials are
|
|
18
|
+
* rejected before any fetch, and registry tokens or passwords never leave memory.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_VENDOR_DIR = 'vendor/sitefile';
|
|
21
|
+
export const VENDOR_MANIFEST = 'manifest.json';
|
|
22
|
+
/** Validate a vendor directory: a relative path inside the build context. Returns it normalized (POSIX, no trailing slash). */
|
|
23
|
+
export function vendorDirRel(input) {
|
|
24
|
+
const n = path.posix.normalize(input).replace(/\/+$/, '');
|
|
25
|
+
if (input === '' || path.posix.isAbsolute(input) || input.includes('\\') || n === '.' || n === '..' || n.startsWith('../')) {
|
|
26
|
+
throw new SitefileError('E_USAGE', `--vendor-dir expects a relative path inside the build context, got '${input}'`);
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
const blobRel = (dir, digest) => `${dir}/blobs/sha256/${digest.slice('sha256:'.length)}`;
|
|
31
|
+
/**
|
|
32
|
+
* The vendor directory a build uses: `explicit` (which must have a manifest), else the default one if
|
|
33
|
+
* its manifest exists, else none.
|
|
34
|
+
*/
|
|
35
|
+
export async function findVendorDir(contextReal, explicit) {
|
|
36
|
+
const dir = vendorDirRel(explicit ?? DEFAULT_VENDOR_DIR);
|
|
37
|
+
const exists = await fs.stat(path.join(contextReal, ...dir.split('/'), VENDOR_MANIFEST)).then((st) => st.isFile(), () => false);
|
|
38
|
+
if (exists)
|
|
39
|
+
return dir;
|
|
40
|
+
if (explicit !== undefined) {
|
|
41
|
+
throw new SitefileError('E_NOT_FOUND', `vendor directory '${dir}' has no ${VENDOR_MANIFEST}; run 'sitefile vendor --vendor-dir ${dir}' first`);
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function isObject(v) {
|
|
46
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
47
|
+
}
|
|
48
|
+
/** Parse and check a vendor manifest. Errors are E_VALIDATION naming the file. */
|
|
49
|
+
export function parseVendorManifest(bytes, shown) {
|
|
50
|
+
const bad = (why) => new SitefileError('E_VALIDATION', `malformed vendor manifest ${shown}: ${why}`);
|
|
51
|
+
let data;
|
|
52
|
+
try {
|
|
53
|
+
data = JSON.parse(new TextDecoder().decode(bytes));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw bad('invalid JSON');
|
|
57
|
+
}
|
|
58
|
+
if (!isObject(data))
|
|
59
|
+
throw bad('expected a JSON object');
|
|
60
|
+
if (data.version !== 1)
|
|
61
|
+
throw bad(`unsupported version ${JSON.stringify(data.version) ?? 'undefined'} (expected 1)`);
|
|
62
|
+
const resources = {};
|
|
63
|
+
const images = {};
|
|
64
|
+
const rs = data.resources ?? {};
|
|
65
|
+
if (!isObject(rs))
|
|
66
|
+
throw bad('"resources" must be an object');
|
|
67
|
+
for (const [url, e] of Object.entries(rs)) {
|
|
68
|
+
if (!isObject(e) || typeof e.integrity !== 'string' || !INTEGRITY_RE.test(e.integrity) || typeof e.size !== 'number' || !Number.isSafeInteger(e.size) || e.size < 0 || typeof e.resolved !== 'string') {
|
|
69
|
+
throw bad(`entry for ${url} needs "integrity" (sha256:<64 hex>), "size" and "resolved"`);
|
|
70
|
+
}
|
|
71
|
+
resources[url] = { integrity: e.integrity, size: e.size, resolved: e.resolved };
|
|
72
|
+
}
|
|
73
|
+
const is = data.images ?? {};
|
|
74
|
+
if (!isObject(is))
|
|
75
|
+
throw bad('"images" must be an object');
|
|
76
|
+
for (const [ref, e] of Object.entries(is)) {
|
|
77
|
+
if (!isObject(e) || typeof e.digest !== 'string' || !INTEGRITY_RE.test(e.digest) || typeof e.platform !== 'string' || typeof e.manifest !== 'string' || !INTEGRITY_RE.test(e.manifest)) {
|
|
78
|
+
throw bad(`image entry for ${ref} needs "digest", "platform" and "manifest"`);
|
|
79
|
+
}
|
|
80
|
+
images[ref] = { digest: e.digest, platform: e.platform, manifest: e.manifest };
|
|
81
|
+
}
|
|
82
|
+
const git = {};
|
|
83
|
+
const gs = data.git ?? {};
|
|
84
|
+
if (!isObject(gs))
|
|
85
|
+
throw bad('"git" must be an object');
|
|
86
|
+
for (const [url, e] of Object.entries(gs)) {
|
|
87
|
+
if (!isObject(e) || typeof e.commit !== 'string' || !/^[0-9a-f]{40}$/.test(e.commit) || (e.kind !== 'tree' && e.kind !== 'file') ||
|
|
88
|
+
typeof e.integrity !== 'string' || !INTEGRITY_RE.test(e.integrity) || typeof e.size !== 'number' || !Number.isSafeInteger(e.size) || e.size < 0 ||
|
|
89
|
+
(e.kind === 'file' ? e.mode !== '644' && e.mode !== '755' : e.mode !== undefined)) {
|
|
90
|
+
throw bad(`git entry for ${url} needs "commit", "kind" (tree or file), "integrity", "size" (and "mode" for a file)`);
|
|
91
|
+
}
|
|
92
|
+
git[url] = { commit: e.commit, kind: e.kind, integrity: e.integrity, size: e.size, ...(e.kind === 'file' ? { mode: e.mode } : {}) };
|
|
93
|
+
}
|
|
94
|
+
return { version: 1, resources, images, ...(Object.keys(git).length ? { git } : {}) };
|
|
95
|
+
}
|
|
96
|
+
/** The manifest as `sitefile vendor` writes it: sorted keys, two-space indent, trailing newline. */
|
|
97
|
+
export function serializeVendorManifest(m) {
|
|
98
|
+
const resources = {};
|
|
99
|
+
for (const url of Object.keys(m.resources).sort()) {
|
|
100
|
+
const e = m.resources[url];
|
|
101
|
+
resources[url] = { integrity: e.integrity, size: e.size, resolved: e.resolved };
|
|
102
|
+
}
|
|
103
|
+
const images = {};
|
|
104
|
+
for (const ref of Object.keys(m.images).sort()) {
|
|
105
|
+
const e = m.images[ref];
|
|
106
|
+
images[ref] = { digest: e.digest, platform: e.platform, manifest: e.manifest };
|
|
107
|
+
}
|
|
108
|
+
const git = {};
|
|
109
|
+
for (const url of Object.keys(m.git ?? {}).sort()) {
|
|
110
|
+
const e = m.git[url];
|
|
111
|
+
git[url] = { commit: e.commit, kind: e.kind, integrity: e.integrity, size: e.size, ...(e.mode !== undefined ? { mode: e.mode } : {}) };
|
|
112
|
+
}
|
|
113
|
+
return JSON.stringify({ version: 1, resources, images, ...(Object.keys(git).length ? { git } : {}) }, null, 2) + '\n';
|
|
114
|
+
}
|
|
115
|
+
function isIndex(bytes) {
|
|
116
|
+
try {
|
|
117
|
+
const j = JSON.parse(new TextDecoder().decode(bytes));
|
|
118
|
+
if (typeof j.mediaType === 'string')
|
|
119
|
+
return j.mediaType === MEDIA.index || j.mediaType === MEDIA.dockerList;
|
|
120
|
+
return Array.isArray(j.manifests);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** A vendor directory opened through the build's sandboxed context reader. */
|
|
127
|
+
export class Vendor {
|
|
128
|
+
dir;
|
|
129
|
+
local;
|
|
130
|
+
manifest;
|
|
131
|
+
constructor(
|
|
132
|
+
/** Context-relative directory. */
|
|
133
|
+
dir, local, manifest) {
|
|
134
|
+
this.dir = dir;
|
|
135
|
+
this.local = local;
|
|
136
|
+
this.manifest = manifest;
|
|
137
|
+
}
|
|
138
|
+
static async open(local, dir) {
|
|
139
|
+
const rel = `${dir}/${VENDOR_MANIFEST}`;
|
|
140
|
+
const f = await local.readFile(rel);
|
|
141
|
+
return new Vendor(dir, local, parseVendorManifest(f.bytes, rel));
|
|
142
|
+
}
|
|
143
|
+
resource(url) {
|
|
144
|
+
return Object.hasOwn(this.manifest.resources, url) ? this.manifest.resources[url] : undefined;
|
|
145
|
+
}
|
|
146
|
+
image(ref) {
|
|
147
|
+
return Object.hasOwn(this.manifest.images, ref) ? this.manifest.images[ref] : undefined;
|
|
148
|
+
}
|
|
149
|
+
git(url) {
|
|
150
|
+
const g = this.manifest.git;
|
|
151
|
+
return g && Object.hasOwn(g, url) ? g[url] : undefined;
|
|
152
|
+
}
|
|
153
|
+
/** The vendored content of a Git IMPORT, verified against its digest. */
|
|
154
|
+
async readGit(url, e, basename, limits, span) {
|
|
155
|
+
const bytes = await this.readBlob(e.integrity, `vendored copy of '${url}'`, span);
|
|
156
|
+
if (e.kind === 'file')
|
|
157
|
+
return { kind: 'file', bytes, mode: e.mode === '755' ? 0o755 : 0o644, basename };
|
|
158
|
+
const tree = await extractArchive(bytes, `vendored copy of '${url}'`, limits, span);
|
|
159
|
+
return { kind: 'tree', files: tree.entries().map(([p, n]) => [p, { bytes: n.bytes, mode: n.mode }]) };
|
|
160
|
+
}
|
|
161
|
+
/** The vendored body of `url`, verified against its digest. */
|
|
162
|
+
async readResource(url, e, span) {
|
|
163
|
+
return { bytes: await this.readBlob(e.integrity, `vendored copy of '${url}'`, span), finalUrl: e.resolved };
|
|
164
|
+
}
|
|
165
|
+
async readBlob(digest, what, span) {
|
|
166
|
+
const rel = blobRel(this.dir, digest);
|
|
167
|
+
const { bytes } = await this.local.readFile(rel, span);
|
|
168
|
+
const got = sha256(bytes);
|
|
169
|
+
if (got !== digest) {
|
|
170
|
+
throw new SitefileError('E_INTEGRITY', `${what} (${rel}) is corrupt: expected ${digest}, got ${got}; run 'sitefile vendor' again`, span);
|
|
171
|
+
}
|
|
172
|
+
return bytes;
|
|
173
|
+
}
|
|
174
|
+
async hasBlob(digest) {
|
|
175
|
+
try {
|
|
176
|
+
return (await this.local.stat(blobRel(this.dir, digest))) === 'file';
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (isSitefileError(e, 'E_NOT_FOUND'))
|
|
180
|
+
return false;
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Copy the vendored blobs of the image `rootDigest` (for `platform`) into the store, each verified,
|
|
186
|
+
* so pulling it finds everything locally. Stops quietly at the first blob that is not vendored: the
|
|
187
|
+
* pull then fetches the rest from the registry.
|
|
188
|
+
*/
|
|
189
|
+
async seedStore(store, rootDigest, platform) {
|
|
190
|
+
if (!(await this.hasBlob(rootDigest)))
|
|
191
|
+
return;
|
|
192
|
+
const root = await this.readBlob(rootDigest, 'vendored image blob');
|
|
193
|
+
await store.putSmallBlob(root);
|
|
194
|
+
let manifest = root;
|
|
195
|
+
if (isIndex(root)) {
|
|
196
|
+
let d;
|
|
197
|
+
try {
|
|
198
|
+
d = selectPlatform(root, platform);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!(await this.hasBlob(d.digest)))
|
|
204
|
+
return;
|
|
205
|
+
manifest = await this.readBlob(d.digest, 'vendored image blob');
|
|
206
|
+
await store.putSmallBlob(manifest);
|
|
207
|
+
}
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = parseManifest(manifest);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const d of [parsed.config, ...parsed.layers]) {
|
|
216
|
+
if (await store.hasBlob(d.digest, d.size))
|
|
217
|
+
continue;
|
|
218
|
+
if (!(await this.hasBlob(d.digest)))
|
|
219
|
+
return;
|
|
220
|
+
const bytes = await this.readBlob(d.digest, 'vendored image blob');
|
|
221
|
+
await store.importBlob(d.digest, d.size, (async function* () {
|
|
222
|
+
yield bytes;
|
|
223
|
+
})());
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** The nearest existing ancestor's real path, with the missing tail appended. */
|
|
228
|
+
async function realpathLoose(p) {
|
|
229
|
+
const tail = [];
|
|
230
|
+
let cur = p;
|
|
231
|
+
for (;;) {
|
|
232
|
+
try {
|
|
233
|
+
return path.join(await fs.realpath(cur), ...tail.reverse());
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
const parent = path.dirname(cur);
|
|
237
|
+
if (parent === cur)
|
|
238
|
+
return p;
|
|
239
|
+
tail.push(path.basename(cur));
|
|
240
|
+
cur = parent;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const inside = (root, real) => real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
245
|
+
/**
|
|
246
|
+
* Replace `<contextReal>/<dir>` with a vendor directory holding `manifestText` and `blobs`: written to a
|
|
247
|
+
* temporary sibling, then swapped in. Refuses a directory that resolves outside the context, a symlink,
|
|
248
|
+
* and an existing directory that is not a vendor directory (no manifest.json).
|
|
249
|
+
*/
|
|
250
|
+
export async function writeVendorDir(contextReal, dir, manifestText, blobs) {
|
|
251
|
+
const target = path.join(contextReal, ...dir.split('/'));
|
|
252
|
+
const parent = path.dirname(target);
|
|
253
|
+
if (!inside(contextReal, await realpathLoose(target))) {
|
|
254
|
+
throw new SitefileError('E_PATH_ESCAPE', `vendor directory '${dir}' resolves outside the build context`);
|
|
255
|
+
}
|
|
256
|
+
const st = await fs.lstat(target).catch(() => undefined);
|
|
257
|
+
if (st?.isSymbolicLink())
|
|
258
|
+
throw new SitefileError('E_EXPORT_UNSAFE', `vendor directory '${dir}' is a symlink; refusing to replace it`);
|
|
259
|
+
if (st && !st.isDirectory())
|
|
260
|
+
throw new SitefileError('E_EXPORT_UNSAFE', `vendor directory '${dir}' exists and is not a directory`);
|
|
261
|
+
if (st) {
|
|
262
|
+
const names = await fs.readdir(target);
|
|
263
|
+
if (names.length > 0 && !names.includes(VENDOR_MANIFEST)) {
|
|
264
|
+
throw new SitefileError('E_EXPORT_UNSAFE', `'${dir}' exists and is not a sitefile vendor directory (no ${VENDOR_MANIFEST}); remove it or choose another --vendor-dir`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
await fs.mkdir(parent, { recursive: true }).catch((e) => {
|
|
268
|
+
throw new SitefileError('E_EXPORT', `cannot write vendor directory '${dir}': ${e.message}`, undefined, { cause: e });
|
|
269
|
+
});
|
|
270
|
+
const parentReal = await fs.realpath(parent);
|
|
271
|
+
if (!inside(contextReal, parentReal) && parentReal !== contextReal) {
|
|
272
|
+
throw new SitefileError('E_PATH_ESCAPE', `vendor directory '${dir}' resolves outside the build context`);
|
|
273
|
+
}
|
|
274
|
+
const tag = `${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
275
|
+
const tmp = path.join(parent, `.${path.basename(target)}.sitefile-vendor-tmp-${tag}`);
|
|
276
|
+
const old = path.join(parent, `.${path.basename(target)}.sitefile-vendor-old-${tag}`);
|
|
277
|
+
try {
|
|
278
|
+
await fs.mkdir(path.join(tmp, 'blobs', 'sha256'), { recursive: true });
|
|
279
|
+
for (const [digest, bytes] of [...blobs].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
280
|
+
await fs.writeFile(path.join(tmp, 'blobs', 'sha256', digest.slice('sha256:'.length)), bytes);
|
|
281
|
+
}
|
|
282
|
+
await fs.writeFile(path.join(tmp, VENDOR_MANIFEST), manifestText);
|
|
283
|
+
if (st)
|
|
284
|
+
await fs.rename(target, old);
|
|
285
|
+
await fs.rename(tmp, target);
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
289
|
+
if (st && !(await fs.lstat(target).catch(() => undefined)))
|
|
290
|
+
await fs.rename(old, target).catch(() => undefined);
|
|
291
|
+
throw new SitefileError('E_EXPORT', `cannot write vendor directory '${dir}': ${e.message}`, undefined, { cause: e });
|
|
292
|
+
}
|
|
293
|
+
await fs.rm(old, { recursive: true, force: true });
|
|
294
|
+
return target;
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=vendor.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type SourceSpan } from '../errors.js';
|
|
2
|
+
import { Tree } from '../tree.js';
|
|
3
|
+
import type { ArchiveLimits } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reader for unsigned Web Bundles, formats b1 and b2 (the WICG
|
|
6
|
+
* "draft-yasskin-wpack-bundled-exchanges" lineage and
|
|
7
|
+
* draft-ietf-wpack-bundled-responses). Layouts, as implemented by the WICG Go
|
|
8
|
+
* reference reader (go/bundle/decoder.go):
|
|
9
|
+
*
|
|
10
|
+
* b2: array(5) [ magic, version "b2\0\0", section-lengths, sections, length ]
|
|
11
|
+
* b1: array(6) [ magic, version "b1\0\0", primary URL (text), section-lengths, sections, length ]
|
|
12
|
+
*
|
|
13
|
+
* section-lengths is a byte string holding a CBOR array of (name, length) pairs;
|
|
14
|
+
* `sections` is an array with one item per pair; `responses` must be last. The
|
|
15
|
+
* trailing length is an 8-byte big-endian byte string equal to the bundle size.
|
|
16
|
+
* index = { url => value }: b2 value = [offset, length]; b1 value =
|
|
17
|
+
* [variants-value bstr, offset, length, ...] (empty variants-value: exactly one
|
|
18
|
+
* location). Offsets are counted from the first byte of the responses section's
|
|
19
|
+
* CBOR encoding. A response is array(2) [ bstr(CBOR map of bstr -> bstr incl.
|
|
20
|
+
* ":status"), body bstr ].
|
|
21
|
+
*
|
|
22
|
+
* Signatures, manifest and any unknown section are length-checked and ignored;
|
|
23
|
+
* nothing is verified or executed.
|
|
24
|
+
*/
|
|
25
|
+
export interface BundleEntry {
|
|
26
|
+
url: string;
|
|
27
|
+
status: number;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
body: Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
export interface WebBundle {
|
|
32
|
+
version: 'b1' | 'b2';
|
|
33
|
+
primaryUrl: string | undefined;
|
|
34
|
+
entries: BundleEntry[];
|
|
35
|
+
}
|
|
36
|
+
export declare function isWebBundle(bytes: Uint8Array): boolean;
|
|
37
|
+
export declare function readWebBundle(bytes: Uint8Array, origin: string, limits: ArchiveLimits, span?: SourceSpan): WebBundle;
|
|
38
|
+
/**
|
|
39
|
+
* Turn a bundle into a Tree. Only status-200 entries become files. When all
|
|
40
|
+
* such URLs share one origin the artifact path is the URL path; otherwise each
|
|
41
|
+
* path is prefixed with `/<host[:port]>`. A path ending in `/` maps to
|
|
42
|
+
* `<path>index.html`; segments are percent-decoded.
|
|
43
|
+
*/
|
|
44
|
+
export declare function webBundleToTree(bundle: WebBundle, origin: string, span?: SourceSpan): Tree;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { SitefileError } from '../errors.js';
|
|
2
|
+
import { normalizeVirtual } from '../paths.js';
|
|
3
|
+
import { makeFile, Tree } from '../tree.js';
|
|
4
|
+
import { CborError, decodeCbor, readCborHead } from '../cbor.js';
|
|
5
|
+
const MAGIC = [0xf0, 0x9f, 0x8c, 0x90, 0xf0, 0x9f, 0x93, 0xa6];
|
|
6
|
+
/** Magic prefix: array head, bstr(8) head, then the 8 magic bytes. */
|
|
7
|
+
const HEAD_LEN = 10;
|
|
8
|
+
/** The trailing length field: bstr(8) head + 8 bytes. */
|
|
9
|
+
const TAIL_LEN = 9;
|
|
10
|
+
const MAX_SECTION_LENGTHS = 8192;
|
|
11
|
+
function hasMagic(b) {
|
|
12
|
+
if (b.length < HEAD_LEN || (b[0] !== 0x85 && b[0] !== 0x86) || b[1] !== 0x48)
|
|
13
|
+
return false;
|
|
14
|
+
return MAGIC.every((x, i) => b[2 + i] === x);
|
|
15
|
+
}
|
|
16
|
+
export function isWebBundle(bytes) {
|
|
17
|
+
return hasMagic(bytes);
|
|
18
|
+
}
|
|
19
|
+
export function readWebBundle(bytes, origin, limits, span) {
|
|
20
|
+
const bad = (why) => new SitefileError('E_ARCHIVE', `${origin}: web bundle: ${why}`, span);
|
|
21
|
+
try {
|
|
22
|
+
return read(bytes, origin, limits, span, bad);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof CborError)
|
|
26
|
+
throw bad(`malformed CBOR: ${e.message}`);
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function read(bytes, origin, limits, span, bad) {
|
|
31
|
+
if (!hasMagic(bytes))
|
|
32
|
+
throw bad('bad magic bytes');
|
|
33
|
+
if (bytes.length < HEAD_LEN + 5)
|
|
34
|
+
throw bad('truncated header');
|
|
35
|
+
const isB1 = bytes[0] === 0x86;
|
|
36
|
+
if (bytes[HEAD_LEN] !== 0x44 || bytes[HEAD_LEN + 1] !== 0x62 || bytes[HEAD_LEN + 3] !== 0 || bytes[HEAD_LEN + 4] !== 0) {
|
|
37
|
+
throw bad('unrecognized version field');
|
|
38
|
+
}
|
|
39
|
+
const verDigit = bytes[HEAD_LEN + 2];
|
|
40
|
+
if (verDigit !== (isB1 ? 0x31 : 0x32)) {
|
|
41
|
+
throw bad(verDigit === 0x31 || verDigit === 0x32 ? 'header does not match its version' : 'unsupported version (expected b1 or b2)');
|
|
42
|
+
}
|
|
43
|
+
const version = isB1 ? 'b1' : 'b2';
|
|
44
|
+
let pos = HEAD_LEN + 5;
|
|
45
|
+
let primaryUrl;
|
|
46
|
+
if (isB1) {
|
|
47
|
+
const p = decodeCbor(bytes.subarray(pos), { maxItems: 1 });
|
|
48
|
+
if (typeof p.value !== 'string')
|
|
49
|
+
throw bad('primary URL is not a text string');
|
|
50
|
+
primaryUrl = p.value;
|
|
51
|
+
pos += p.end;
|
|
52
|
+
}
|
|
53
|
+
// section-lengths
|
|
54
|
+
const slHead = readCborHead(bytes, pos);
|
|
55
|
+
if (slHead.major !== 2 || typeof slHead.arg !== 'number')
|
|
56
|
+
throw bad('section-lengths is not a byte string');
|
|
57
|
+
if (slHead.arg >= MAX_SECTION_LENGTHS)
|
|
58
|
+
throw bad('section-lengths is too long');
|
|
59
|
+
if (slHead.next + slHead.arg > bytes.length)
|
|
60
|
+
throw bad('truncated section-lengths');
|
|
61
|
+
const sl = decodeCbor(bytes.subarray(slHead.next, slHead.next + slHead.arg), { maxItems: MAX_SECTION_LENGTHS });
|
|
62
|
+
if (!Array.isArray(sl.value) || sl.end !== slHead.arg || sl.value.length % 2 !== 0) {
|
|
63
|
+
throw bad('malformed section-lengths');
|
|
64
|
+
}
|
|
65
|
+
const sections = [];
|
|
66
|
+
pos = slHead.next + slHead.arg;
|
|
67
|
+
const secHead = readCborHead(bytes, pos);
|
|
68
|
+
if (secHead.major !== 4)
|
|
69
|
+
throw bad('sections is not an array');
|
|
70
|
+
if (secHead.arg !== sl.value.length / 2)
|
|
71
|
+
throw bad(`section-lengths declares ${sl.value.length / 2} sections but ${secHead.arg} are present`);
|
|
72
|
+
let cursor = secHead.next;
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (let i = 0; i < sl.value.length; i += 2) {
|
|
75
|
+
const name = sl.value[i];
|
|
76
|
+
const length = sl.value[i + 1];
|
|
77
|
+
// CBOR unsigned integers only: a negative length would move the cursor backwards.
|
|
78
|
+
if (typeof name !== 'string' || typeof length !== 'number' || length < 0)
|
|
79
|
+
throw bad('malformed section-lengths entry');
|
|
80
|
+
if (seen.has(name))
|
|
81
|
+
throw bad(`duplicate section '${name}'`);
|
|
82
|
+
seen.add(name);
|
|
83
|
+
sections.push({ name, length, start: cursor });
|
|
84
|
+
cursor += length;
|
|
85
|
+
if (cursor > bytes.length - TAIL_LEN)
|
|
86
|
+
throw bad(`section '${name}' extends past the end of the bundle`);
|
|
87
|
+
}
|
|
88
|
+
const last = sections[sections.length - 1];
|
|
89
|
+
if (!last || last.name !== 'responses')
|
|
90
|
+
throw bad("the last section must be 'responses'");
|
|
91
|
+
// trailing length
|
|
92
|
+
if (cursor + TAIL_LEN !== bytes.length)
|
|
93
|
+
throw bad('section lengths do not add up to the bundle size');
|
|
94
|
+
if (bytes[cursor] !== 0x48)
|
|
95
|
+
throw bad('malformed trailing length field');
|
|
96
|
+
const declared = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(cursor + 1);
|
|
97
|
+
if (declared !== BigInt(bytes.length))
|
|
98
|
+
throw bad(`length field says ${declared} bytes but the bundle is ${bytes.length}`);
|
|
99
|
+
const section = (name) => {
|
|
100
|
+
const s = sections.find((x) => x.name === name);
|
|
101
|
+
return s && bytes.subarray(s.start, s.start + s.length);
|
|
102
|
+
};
|
|
103
|
+
const primarySection = section('primary');
|
|
104
|
+
if (primarySection) {
|
|
105
|
+
const p = decodeCbor(primarySection, { maxItems: 1 });
|
|
106
|
+
if (typeof p.value !== 'string' || p.end !== primarySection.length)
|
|
107
|
+
throw bad("malformed 'primary' section");
|
|
108
|
+
primaryUrl = p.value;
|
|
109
|
+
}
|
|
110
|
+
if (primaryUrl !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
new URL(primaryUrl);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw bad(`primary URL '${primaryUrl}' is not an absolute URL`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const indexSection = section('index');
|
|
119
|
+
if (!indexSection)
|
|
120
|
+
throw bad("missing 'index' section");
|
|
121
|
+
const responses = last;
|
|
122
|
+
const indexHead = readCborHead(indexSection, 0);
|
|
123
|
+
if (indexHead.major !== 5)
|
|
124
|
+
throw bad('index is not a map');
|
|
125
|
+
if (typeof indexHead.arg !== 'number' || indexHead.arg > limits.maxEntries) {
|
|
126
|
+
throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: web bundle has more than ${limits.maxEntries} entries`, span);
|
|
127
|
+
}
|
|
128
|
+
const index = decodeCbor(indexSection);
|
|
129
|
+
if (!(index.value instanceof Map) || index.end !== indexSection.length)
|
|
130
|
+
throw bad('malformed index section');
|
|
131
|
+
const parsed = new Map();
|
|
132
|
+
const entries = [];
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (const [url, value] of index.value) {
|
|
135
|
+
if (typeof url !== 'string')
|
|
136
|
+
throw bad('index key is not a text string');
|
|
137
|
+
if (!Array.isArray(value))
|
|
138
|
+
throw bad(`index value for '${url}' is not an array`);
|
|
139
|
+
let loc;
|
|
140
|
+
if (isB1) {
|
|
141
|
+
const variants = value[0];
|
|
142
|
+
if (!(variants instanceof Uint8Array))
|
|
143
|
+
throw bad(`index value for '${url}' has no variants-value`);
|
|
144
|
+
if (value.length !== 3) {
|
|
145
|
+
throw bad(value.length % 2 === 1 && value.length > 3
|
|
146
|
+
? `'${url}' has multiple variants, which are not supported`
|
|
147
|
+
: `index value for '${url}' has an invalid size`);
|
|
148
|
+
}
|
|
149
|
+
loc = value.slice(1);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
if (value.length !== 2)
|
|
153
|
+
throw bad(`index value for '${url}' must be [offset, length]`);
|
|
154
|
+
loc = value;
|
|
155
|
+
}
|
|
156
|
+
const [off, len] = loc;
|
|
157
|
+
// A negative offset or length (CBOR major type 1) would read outside the responses section.
|
|
158
|
+
if (typeof off !== 'number' || typeof len !== 'number' || off < 0 || len < 0) {
|
|
159
|
+
throw bad(`index offset/length for '${url}' is not an unsigned integer`);
|
|
160
|
+
}
|
|
161
|
+
if (off + len > responses.length)
|
|
162
|
+
throw bad(`response for '${url}' lies outside the responses section`);
|
|
163
|
+
const key = `${off}:${len}`;
|
|
164
|
+
let r = parsed.get(key);
|
|
165
|
+
if (!r) {
|
|
166
|
+
r = parseResponse(bytes.subarray(responses.start + off, responses.start + off + len), url, bad);
|
|
167
|
+
parsed.set(key, r);
|
|
168
|
+
}
|
|
169
|
+
total += r.body.byteLength;
|
|
170
|
+
if (total > limits.maxTotalBytes) {
|
|
171
|
+
throw new SitefileError('E_ARCHIVE_LIMIT', `${origin}: web bundle expands beyond ${limits.maxTotalBytes} bytes`, span);
|
|
172
|
+
}
|
|
173
|
+
entries.push({ url, status: r.status, headers: r.headers, body: r.body });
|
|
174
|
+
}
|
|
175
|
+
entries.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
176
|
+
return { version, primaryUrl, entries };
|
|
177
|
+
}
|
|
178
|
+
const asciiOf = (b) => {
|
|
179
|
+
for (const x of b)
|
|
180
|
+
if (x > 0x7e || x < 0x20)
|
|
181
|
+
return undefined;
|
|
182
|
+
return Buffer.from(b).toString('latin1');
|
|
183
|
+
};
|
|
184
|
+
function parseResponse(b, url, bad) {
|
|
185
|
+
const r = decodeCbor(b, { maxItems: 16 });
|
|
186
|
+
if (!Array.isArray(r.value) || r.end !== b.length || r.value.length !== 2)
|
|
187
|
+
throw bad(`response for '${url}' is not [headers, body]`);
|
|
188
|
+
const [hdrBytes, body] = r.value;
|
|
189
|
+
if (!(hdrBytes instanceof Uint8Array) || !(body instanceof Uint8Array))
|
|
190
|
+
throw bad(`response for '${url}' must hold two byte strings`);
|
|
191
|
+
const h = decodeCbor(hdrBytes, { maxItems: 100_000 });
|
|
192
|
+
if (!(h.value instanceof Map) || h.end !== hdrBytes.length)
|
|
193
|
+
throw bad(`response headers for '${url}' are not a CBOR map`);
|
|
194
|
+
let status;
|
|
195
|
+
const headers = [];
|
|
196
|
+
for (const [k, v] of h.value) {
|
|
197
|
+
if (!(k instanceof Uint8Array) || !(v instanceof Uint8Array))
|
|
198
|
+
throw bad(`response headers for '${url}' must map byte strings to byte strings`);
|
|
199
|
+
const name = asciiOf(k);
|
|
200
|
+
const value = asciiOf(v);
|
|
201
|
+
if (name === undefined || value === undefined)
|
|
202
|
+
throw bad(`non-ASCII response header for '${url}'`);
|
|
203
|
+
if (name !== name.toLowerCase())
|
|
204
|
+
throw bad(`response header '${name}' for '${url}' is not lower-case`);
|
|
205
|
+
if (name.startsWith(':')) {
|
|
206
|
+
if (name !== ':status')
|
|
207
|
+
throw bad(`unexpected pseudo-header '${name}' for '${url}'`);
|
|
208
|
+
if (!/^\d{3}$/.test(value))
|
|
209
|
+
throw bad(`invalid :status '${value}' for '${url}'`);
|
|
210
|
+
status = Number(value);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
headers.push([name, value]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (status === undefined)
|
|
217
|
+
throw bad(`response for '${url}' has no :status`);
|
|
218
|
+
return { status, headers: Object.fromEntries(headers), body };
|
|
219
|
+
}
|
|
220
|
+
// --------------------------------------------------------------- to a Tree
|
|
221
|
+
/**
|
|
222
|
+
* Turn a bundle into a Tree. Only status-200 entries become files. When all
|
|
223
|
+
* such URLs share one origin the artifact path is the URL path; otherwise each
|
|
224
|
+
* path is prefixed with `/<host[:port]>`. A path ending in `/` maps to
|
|
225
|
+
* `<path>index.html`; segments are percent-decoded.
|
|
226
|
+
*/
|
|
227
|
+
export function webBundleToTree(bundle, origin, span) {
|
|
228
|
+
const unsafe = (url, why) => new SitefileError('E_ARCHIVE_UNSAFE', `${origin}: unsafe web bundle URL '${url.replace(/\0/g, '\\0')}': ${why}`, span);
|
|
229
|
+
const items = [];
|
|
230
|
+
for (const entry of bundle.entries) {
|
|
231
|
+
if (entry.status !== 200)
|
|
232
|
+
continue;
|
|
233
|
+
const m = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/.exec(entry.url);
|
|
234
|
+
if (!m)
|
|
235
|
+
throw unsafe(entry.url, 'not an absolute http(s) URL');
|
|
236
|
+
const scheme = m[1].toLowerCase();
|
|
237
|
+
if (scheme !== 'http' && scheme !== 'https')
|
|
238
|
+
throw unsafe(entry.url, `scheme '${scheme}:' is not allowed`);
|
|
239
|
+
if (m[5] !== undefined)
|
|
240
|
+
throw unsafe(entry.url, 'URL has a fragment');
|
|
241
|
+
if (m[2].includes('@'))
|
|
242
|
+
throw unsafe(entry.url, 'URL has credentials');
|
|
243
|
+
let u;
|
|
244
|
+
try {
|
|
245
|
+
u = new URL(`${scheme}://${m[2]}/`);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw unsafe(entry.url, 'invalid host');
|
|
249
|
+
}
|
|
250
|
+
const rawPath = m[3] === '' ? '/' : m[3];
|
|
251
|
+
if (rawPath.includes('\\'))
|
|
252
|
+
throw unsafe(entry.url, 'backslash in path');
|
|
253
|
+
if (!rawPath.startsWith('/'))
|
|
254
|
+
throw unsafe(entry.url, 'invalid path');
|
|
255
|
+
const segs = rawPath.slice(1).split('/');
|
|
256
|
+
const trailing = segs[segs.length - 1] === '';
|
|
257
|
+
if (trailing)
|
|
258
|
+
segs.pop();
|
|
259
|
+
const decoded = [];
|
|
260
|
+
for (const s of segs) {
|
|
261
|
+
let d;
|
|
262
|
+
try {
|
|
263
|
+
d = decodeURIComponent(s);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw unsafe(entry.url, 'invalid percent-encoding in path');
|
|
267
|
+
}
|
|
268
|
+
if (d === '')
|
|
269
|
+
throw unsafe(entry.url, 'empty path segment');
|
|
270
|
+
if (d === '.' || d === '..')
|
|
271
|
+
throw unsafe(entry.url, `'${d}' path segment`);
|
|
272
|
+
if (d.includes('\0'))
|
|
273
|
+
throw unsafe(entry.url, 'NUL byte in path');
|
|
274
|
+
if (d.includes('\\'))
|
|
275
|
+
throw unsafe(entry.url, 'backslash in path');
|
|
276
|
+
if (d.includes('/'))
|
|
277
|
+
throw unsafe(entry.url, 'encoded slash in path');
|
|
278
|
+
decoded.push(d);
|
|
279
|
+
}
|
|
280
|
+
if (trailing || decoded.length === 0)
|
|
281
|
+
decoded.push('index.html');
|
|
282
|
+
items.push({ entry, origin: `${scheme}://${u.host}`, host: u.host, path: '/' + decoded.join('/'), query: m[4] !== undefined });
|
|
283
|
+
}
|
|
284
|
+
const multi = new Set(items.map((i) => i.origin)).size > 1;
|
|
285
|
+
const files = new Map();
|
|
286
|
+
for (const it of items) {
|
|
287
|
+
const path = normalizeVirtual((multi ? `/${it.host}` : '') + it.path, span, 'web bundle URL path').path;
|
|
288
|
+
const prev = files.get(path);
|
|
289
|
+
if (prev && prev.url !== it.entry.url && (prev.query || it.query)) {
|
|
290
|
+
throw unsafe(it.entry.url, `its query string makes it collide with '${prev.url}' at '${path}'`);
|
|
291
|
+
}
|
|
292
|
+
if (prev) {
|
|
293
|
+
if (prev.node.digest !== makeFile(it.entry.body, '').digest) {
|
|
294
|
+
throw new SitefileError('E_CONFLICT', `${origin}: '${prev.url}' and '${it.entry.url}' both map to '${path}' with different content`, span);
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
files.set(path, { node: makeFile(it.entry.body, `${origin}:${it.entry.url}`), url: it.entry.url, query: it.query });
|
|
299
|
+
}
|
|
300
|
+
return Tree.fromFiles([...files].map(([p, f]) => [p, f.node]));
|
|
301
|
+
}
|
|
302
|
+
//# sourceMappingURL=wbn-reader.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type SourceSpan } from './errors.js';
|
|
2
|
+
export type SchemaType = 'string' | 'number' | 'boolean' | 'date' | 'list';
|
|
3
|
+
type ItemType = Exclude<SchemaType, 'list'>;
|
|
4
|
+
type EnumValue = string | number | boolean;
|
|
5
|
+
export interface SchemaRule {
|
|
6
|
+
readonly key: string;
|
|
7
|
+
readonly type?: SchemaType;
|
|
8
|
+
readonly items?: ItemType;
|
|
9
|
+
readonly enum?: readonly EnumValue[];
|
|
10
|
+
}
|
|
11
|
+
/** A compiled schema; `label` names its file in messages. */
|
|
12
|
+
export interface Schema {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly required: readonly string[];
|
|
15
|
+
readonly properties: readonly SchemaRule[];
|
|
16
|
+
}
|
|
17
|
+
/** Compile a parsed schema file. Errors are E_SCHEMA at `span` (the SCHEMA clause), prefixed with `what`. */
|
|
18
|
+
export declare function compileSchema(value: unknown, label: string, what: string, span: SourceSpan): Schema;
|
|
19
|
+
/**
|
|
20
|
+
* Check an import's value against a schema: each element of a list, or a single document. Every element must be
|
|
21
|
+
* an object (E_TYPE otherwise). The first mismatch fails with E_SCHEMA at `span`, naming the document (its `path`,
|
|
22
|
+
* or `#n` for the n-th element), the key, what was expected and what was found.
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkSchema(value: unknown, schema: Schema, what: string, csv: boolean, span: SourceSpan): void;
|
|
25
|
+
export {};
|