@johnhenry/sitefile 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +475 -2
  4. package/lib/src/archive-writers.d.ts +31 -0
  5. package/lib/src/archive-writers.js +197 -0
  6. package/lib/src/attest.d.ts +126 -0
  7. package/lib/src/attest.js +220 -0
  8. package/lib/src/bake.d.ts +74 -0
  9. package/lib/src/bake.js +372 -0
  10. package/lib/src/base-image.d.ts +76 -0
  11. package/lib/src/base-image.js +138 -0
  12. package/lib/src/build-args.d.ts +26 -0
  13. package/lib/src/build-args.js +164 -0
  14. package/lib/src/cache-limit.d.ts +54 -0
  15. package/lib/src/cache-limit.js +239 -0
  16. package/lib/src/casefold-data.d.ts +2 -0
  17. package/lib/src/casefold-data.js +217 -0
  18. package/lib/src/cbor.d.ts +39 -0
  19. package/lib/src/cbor.js +174 -0
  20. package/lib/src/checklinks.d.ts +42 -0
  21. package/lib/src/checklinks.js +118 -0
  22. package/lib/src/cli/bake.d.ts +2 -0
  23. package/lib/src/cli/bake.js +179 -0
  24. package/lib/src/cli/build-image.d.ts +21 -0
  25. package/lib/src/cli/build-image.js +42 -0
  26. package/lib/src/cli/cache.d.ts +8 -0
  27. package/lib/src/cli/cache.js +109 -0
  28. package/lib/src/cli/common.d.ts +29 -0
  29. package/lib/src/cli/common.js +100 -0
  30. package/lib/src/cli/git-context.d.ts +18 -0
  31. package/lib/src/cli/git-context.js +48 -0
  32. package/lib/src/cli/images.d.ts +6 -0
  33. package/lib/src/cli/images.js +285 -0
  34. package/lib/src/cli/index.d.ts +10 -0
  35. package/lib/src/cli/index.js +95 -0
  36. package/lib/src/cli/outdated.d.ts +5 -0
  37. package/lib/src/cli/outdated.js +94 -0
  38. package/lib/src/cli/registry.d.ts +2 -0
  39. package/lib/src/cli/registry.js +122 -0
  40. package/lib/src/cli/serve-worker.d.ts +2 -0
  41. package/lib/src/cli/serve-worker.js +42 -0
  42. package/lib/src/cli/servers.d.ts +15 -0
  43. package/lib/src/cli/servers.js +451 -0
  44. package/lib/src/cli/vendor.d.ts +2 -0
  45. package/lib/src/cli/vendor.js +92 -0
  46. package/lib/src/cli/watch.d.ts +26 -0
  47. package/lib/src/cli/watch.js +81 -0
  48. package/lib/src/cli.d.ts +12 -0
  49. package/lib/src/cli.js +486 -0
  50. package/lib/src/csv.d.ts +27 -0
  51. package/lib/src/csv.js +117 -0
  52. package/lib/src/deflate.d.ts +76 -0
  53. package/lib/src/deflate.js +604 -0
  54. package/lib/src/digest.d.ts +3 -0
  55. package/lib/src/digest.js +7 -0
  56. package/lib/src/errors.d.ts +34 -0
  57. package/lib/src/errors.js +20 -0
  58. package/lib/src/executor.d.ts +386 -0
  59. package/lib/src/executor.js +2193 -0
  60. package/lib/src/explain.d.ts +64 -0
  61. package/lib/src/explain.js +445 -0
  62. package/lib/src/export-formats.d.ts +16 -0
  63. package/lib/src/export-formats.js +52 -0
  64. package/lib/src/exporter.d.ts +103 -0
  65. package/lib/src/exporter.js +676 -0
  66. package/lib/src/fingerprint.d.ts +40 -0
  67. package/lib/src/fingerprint.js +168 -0
  68. package/lib/src/frontmatter.d.ts +22 -0
  69. package/lib/src/frontmatter.js +45 -0
  70. package/lib/src/index.d.ts +12 -0
  71. package/lib/src/index.js +12 -0
  72. package/lib/src/language/ast.d.ts +353 -0
  73. package/lib/src/language/ast.js +57 -0
  74. package/lib/src/language/parser.d.ts +3 -0
  75. package/lib/src/language/parser.js +612 -0
  76. package/lib/src/language/scanner.d.ts +110 -0
  77. package/lib/src/language/scanner.js +449 -0
  78. package/lib/src/language/validate.d.ts +13 -0
  79. package/lib/src/language/validate.js +367 -0
  80. package/lib/src/links.d.ts +79 -0
  81. package/lib/src/links.js +583 -0
  82. package/lib/src/lint.d.ts +10 -0
  83. package/lib/src/lint.js +223 -0
  84. package/lib/src/markdown.d.ts +29 -0
  85. package/lib/src/markdown.js +612 -0
  86. package/lib/src/media.d.ts +82 -0
  87. package/lib/src/media.js +650 -0
  88. package/lib/src/memo.d.ts +57 -0
  89. package/lib/src/memo.js +93 -0
  90. package/lib/src/oci.d.ts +178 -0
  91. package/lib/src/oci.js +380 -0
  92. package/lib/src/outdated.d.ts +46 -0
  93. package/lib/src/outdated.js +195 -0
  94. package/lib/src/pages.d.ts +10 -0
  95. package/lib/src/pages.js +54 -0
  96. package/lib/src/paths.d.ts +39 -0
  97. package/lib/src/paths.js +101 -0
  98. package/lib/src/planner.d.ts +297 -0
  99. package/lib/src/planner.js +410 -0
  100. package/lib/src/plugins/cache.d.ts +70 -0
  101. package/lib/src/plugins/cache.js +226 -0
  102. package/lib/src/plugins/exec.d.ts +51 -0
  103. package/lib/src/plugins/exec.js +144 -0
  104. package/lib/src/plugins/host.d.ts +75 -0
  105. package/lib/src/plugins/host.js +277 -0
  106. package/lib/src/plugins/wasm.d.ts +23 -0
  107. package/lib/src/plugins/wasm.js +249 -0
  108. package/lib/src/profile.d.ts +42 -0
  109. package/lib/src/profile.js +68 -0
  110. package/lib/src/ref.d.ts +40 -0
  111. package/lib/src/ref.js +79 -0
  112. package/lib/src/registry-auth.d.ts +32 -0
  113. package/lib/src/registry-auth.js +151 -0
  114. package/lib/src/registry-cache.d.ts +71 -0
  115. package/lib/src/registry-cache.js +333 -0
  116. package/lib/src/registry-ops.d.ts +51 -0
  117. package/lib/src/registry-ops.js +248 -0
  118. package/lib/src/registry.d.ts +109 -0
  119. package/lib/src/registry.js +505 -0
  120. package/lib/src/render.d.ts +134 -0
  121. package/lib/src/render.js +576 -0
  122. package/lib/src/resources/archive.d.ts +11 -0
  123. package/lib/src/resources/archive.js +306 -0
  124. package/lib/src/resources/bundle-data.d.ts +16 -0
  125. package/lib/src/resources/bundle-data.js +50 -0
  126. package/lib/src/resources/convert.d.ts +12 -0
  127. package/lib/src/resources/convert.js +63 -0
  128. package/lib/src/resources/documents.d.ts +35 -0
  129. package/lib/src/resources/documents.js +122 -0
  130. package/lib/src/resources/git.d.ts +103 -0
  131. package/lib/src/resources/git.js +720 -0
  132. package/lib/src/resources/https.d.ts +40 -0
  133. package/lib/src/resources/https.js +256 -0
  134. package/lib/src/resources/ignore.d.ts +33 -0
  135. package/lib/src/resources/ignore.js +119 -0
  136. package/lib/src/resources/local.d.ts +64 -0
  137. package/lib/src/resources/local.js +248 -0
  138. package/lib/src/resources/lockfile.d.ts +52 -0
  139. package/lib/src/resources/lockfile.js +172 -0
  140. package/lib/src/resources/netpolicy.d.ts +6 -0
  141. package/lib/src/resources/netpolicy.js +82 -0
  142. package/lib/src/resources/types.d.ts +63 -0
  143. package/lib/src/resources/types.js +13 -0
  144. package/lib/src/resources/vendor.d.ts +94 -0
  145. package/lib/src/resources/vendor.js +296 -0
  146. package/lib/src/resources/wbn-reader.d.ts +44 -0
  147. package/lib/src/resources/wbn-reader.js +302 -0
  148. package/lib/src/schema.d.ts +25 -0
  149. package/lib/src/schema.js +169 -0
  150. package/lib/src/secrets.d.ts +21 -0
  151. package/lib/src/secrets.js +103 -0
  152. package/lib/src/serve-rules.d.ts +29 -0
  153. package/lib/src/serve-rules.js +122 -0
  154. package/lib/src/server.d.ts +45 -0
  155. package/lib/src/server.js +246 -0
  156. package/lib/src/servers.d.ts +71 -0
  157. package/lib/src/servers.js +139 -0
  158. package/lib/src/site.d.ts +60 -0
  159. package/lib/src/site.js +169 -0
  160. package/lib/src/store.d.ts +151 -0
  161. package/lib/src/store.js +596 -0
  162. package/lib/src/tree.d.ts +50 -0
  163. package/lib/src/tree.js +134 -0
  164. package/lib/src/values.d.ts +7 -0
  165. package/lib/src/values.js +37 -0
  166. package/lib/src/vendor.d.ts +19 -0
  167. package/lib/src/vendor.js +145 -0
  168. package/lib/src/watch.d.ts +44 -0
  169. package/lib/src/watch.js +137 -0
  170. package/lib/src/wbn.d.ts +5 -0
  171. package/lib/src/wbn.js +147 -0
  172. package/lib/src/yaml.d.ts +34 -0
  173. package/lib/src/yaml.js +562 -0
  174. package/package.json +58 -4
@@ -0,0 +1,248 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, sep, relative, posix } from 'node:path';
3
+ import { SitefileError } from '../errors.js';
4
+ import { sha256 } from '../digest.js';
5
+ import { Tree, makeFile } from '../tree.js';
6
+ import { nfc } from '../paths.js';
7
+ import { IgnoreRules } from './ignore.js';
8
+ export const SITEFILEIGNORE = '.sitefileignore';
9
+ const NON_ASCII = /[^\x00-\x7f]/;
10
+ function normalizationCollision(rel, span) {
11
+ return new SitefileError('E_PATH_COLLISION', `'${display(rel)}' names more than one file in the build context: their names differ only in Unicode normalization (NFC/NFD); rename one`, span);
12
+ }
13
+ function errCode(e) {
14
+ return e?.code;
15
+ }
16
+ async function exists(p) {
17
+ return fs.lstat(p).then(() => true, () => false);
18
+ }
19
+ function display(rel) {
20
+ return rel === '' ? '.' : './' + rel;
21
+ }
22
+ /**
23
+ * The local build context. Every read is confined to `root`: real paths are
24
+ * resolved and must stay inside it.
25
+ */
26
+ export class LocalContext {
27
+ root;
28
+ rules;
29
+ allowHardlinks;
30
+ ignore;
31
+ constructor(root, ignore, rules, allowHardlinks) {
32
+ this.root = root;
33
+ this.rules = rules;
34
+ this.allowHardlinks = allowHardlinks;
35
+ this.ignore = ignore.map((p) => posix.normalize(nfc(p)).replace(/^\.\/+/, '').replace(/\/+$/, '')).filter((p) => p !== '' && p !== '.');
36
+ }
37
+ static async open(dir, options) {
38
+ let root;
39
+ try {
40
+ root = await fs.realpath(dir);
41
+ }
42
+ catch (e) {
43
+ if (errCode(e) === 'ENOENT')
44
+ throw new SitefileError('E_NOT_FOUND', `build context '${dir}' does not exist`, undefined, { cause: e });
45
+ throw e;
46
+ }
47
+ const st = await fs.stat(root);
48
+ if (!st.isDirectory())
49
+ throw new SitefileError('E_UNSUPPORTED_FILE', `build context '${dir}' is not a directory`);
50
+ let rules = IgnoreRules.empty;
51
+ if (options?.sitefileignore !== false && (await exists(join(root, SITEFILEIGNORE)))) {
52
+ // Read through the sandbox: a symlinked .sitefileignore must stay inside the context.
53
+ const probe = new LocalContext(root, [], IgnoreRules.empty, true);
54
+ // A broken ignore file fails the build: skipping it could publish files it was meant to exclude.
55
+ const { real, st } = await probe.resolve(SITEFILEIGNORE).catch((e) => {
56
+ if (!(e instanceof SitefileError) || e.code !== 'E_NOT_FOUND')
57
+ throw e;
58
+ throw new SitefileError('E_NOT_FOUND', `'${SITEFILEIGNORE}' is a symlink to a missing file; fix or remove it (its rules would otherwise be skipped)`, undefined, { cause: e });
59
+ });
60
+ if (!st.isFile())
61
+ throw new SitefileError('E_UNSUPPORTED_FILE', `'${SITEFILEIGNORE}' is not a regular file`);
62
+ rules = IgnoreRules.parse(nfc(await fs.readFile(real, 'utf8')));
63
+ }
64
+ return new LocalContext(root, options?.ignore ?? ['.git'], rules, options?.allowHardlinks ?? false);
65
+ }
66
+ inside(real) {
67
+ return real === this.root || real.startsWith(this.root.endsWith(sep) ? this.root : this.root + sep);
68
+ }
69
+ isIgnored(rel) {
70
+ return this.ignore.some((ig) => rel === ig || rel.startsWith(ig + '/'));
71
+ }
72
+ /** Hard-linked files may alias files outside the context, which realpath cannot detect. */
73
+ checkHardlink(st, rel, span) {
74
+ if (this.allowHardlinks || !st.isFile() || st.nlink <= 1)
75
+ return;
76
+ throw new SitefileError('E_HARDLINK', `'${display(rel)}' has ${st.nlink} hard links and may alias a file outside the build context (--allow-hardlinks permits this)`, span);
77
+ }
78
+ /** Context-relative POSIX path of a real path inside root. */
79
+ relOfReal(real) {
80
+ return relative(this.root, real).split(sep).join('/');
81
+ }
82
+ /** Resolve a context-relative path to its real path and stats, enforcing the sandbox. */
83
+ /** A context file's real path and bytes, with the usual sandbox, symlink and hard-link rules. */
84
+ async realFile(rel, span) {
85
+ const { real } = await this.resolve(rel, span);
86
+ const f = await this.readFile(rel, span);
87
+ return { real, bytes: f.bytes };
88
+ }
89
+ /**
90
+ * The host path of the context-relative (NFC) path `rel`. A non-ASCII
91
+ * segment is matched by NFC form against its directory listing, so a file
92
+ * is found whichever normalization its on-disk name uses (macOS stores what
93
+ * it was given, often NFD; Linux compares bytes). Two names in one
94
+ * directory with the same NFC form are E_PATH_COLLISION. A segment with no
95
+ * match is joined as is, so the caller sees the usual ENOENT.
96
+ */
97
+ async hostPath(rel, span) {
98
+ if (rel === '')
99
+ return this.root;
100
+ if (!NON_ASCII.test(rel))
101
+ return join(this.root, ...rel.split('/'));
102
+ let cur = this.root;
103
+ let done = '';
104
+ for (const seg of rel.split('/')) {
105
+ done = done === '' ? seg : `${done}/${seg}`;
106
+ if (!NON_ASCII.test(seg)) {
107
+ cur = join(cur, seg);
108
+ continue;
109
+ }
110
+ const names = await fs.readdir(cur).catch(() => []);
111
+ const matches = names.filter((n) => nfc(n) === seg);
112
+ if (matches.length > 1)
113
+ throw normalizationCollision(done, span);
114
+ cur = join(cur, matches[0] ?? seg);
115
+ }
116
+ return cur;
117
+ }
118
+ async resolve(rel, span, linkName) {
119
+ let real;
120
+ try {
121
+ real = await fs.realpath(await this.hostPath(rel, span));
122
+ }
123
+ catch (e) {
124
+ if (e instanceof SitefileError)
125
+ throw e;
126
+ const code = errCode(e);
127
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
128
+ throw new SitefileError('E_NOT_FOUND', linkName ? `symlink '${linkName}' is dangling` : `'${display(rel)}' does not exist in the build context`, span, { cause: e });
129
+ }
130
+ if (code === 'ELOOP') {
131
+ throw new SitefileError('E_UNSUPPORTED_FILE', `'${display(rel)}' is a symlink loop`, span, { cause: e });
132
+ }
133
+ throw new SitefileError('E_NOT_FOUND', `cannot resolve '${display(rel)}': ${e.message}`, span, { cause: e });
134
+ }
135
+ if (!this.inside(real)) {
136
+ throw new SitefileError('E_PATH_ESCAPE', linkName
137
+ ? `symlink '${linkName}' points outside the build context`
138
+ : `'${display(rel)}' resolves outside the build context`, span);
139
+ }
140
+ const st = await fs.stat(real);
141
+ return { real, st };
142
+ }
143
+ static kindOf(st, rel, span) {
144
+ if (st.isFile())
145
+ return 'file';
146
+ if (st.isDirectory())
147
+ return 'directory';
148
+ throw new SitefileError('E_UNSUPPORTED_FILE', `'${display(rel)}' is not a regular file or directory (sockets, fifos and devices are not supported)`, span);
149
+ }
150
+ async stat(rel, span) {
151
+ const { st } = await this.resolve(rel, span);
152
+ return LocalContext.kindOf(st, rel, span);
153
+ }
154
+ /** The real path of a context-relative path, with the sandbox checks of every read (an imported Sitefile's context). */
155
+ async realPath(rel, span) {
156
+ return (await this.resolve(rel, span)).real;
157
+ }
158
+ async readFile(rel, span) {
159
+ const { real, st } = await this.resolve(rel, span);
160
+ if (LocalContext.kindOf(st, rel, span) !== 'file') {
161
+ throw new SitefileError('E_UNSUPPORTED_FILE', `'${display(rel)}' is a directory, not a file`, span);
162
+ }
163
+ this.checkHardlink(st, rel, span);
164
+ const bytes = new Uint8Array(await fs.readFile(real));
165
+ return {
166
+ kind: 'file',
167
+ bytes,
168
+ mode: st.mode & 0o111 ? 0o755 : 0o644,
169
+ basename: posix.basename(rel),
170
+ provenance: { uri: './' + rel, digest: sha256(bytes) },
171
+ };
172
+ }
173
+ /** `exclude` (COPY --exclude) skips paths relative to `rel`, like `.sitefileignore` rules (excluded directories are not read). */
174
+ async readTree(rel, origin, span, exclude) {
175
+ const { real, st } = await this.resolve(rel, span);
176
+ if (LocalContext.kindOf(st, rel, span) !== 'directory') {
177
+ throw new SitefileError('E_UNSUPPORTED_FILE', `'${display(rel)}' is a file, not a directory`, span);
178
+ }
179
+ const files = [];
180
+ await this.walk(real, rel, '', origin, new Set([real]), files, span, exclude);
181
+ // With the instruction's span: two names that differ only in case (possible on a
182
+ // case-sensitive file system) fail at the line that read the directory.
183
+ const tree = Tree.empty.withFiles(files, 'error', span);
184
+ return { kind: 'tree', tree, provenance: { uri: rel === '' ? './' : './' + rel, digest: tree.digest() } };
185
+ }
186
+ /**
187
+ * @param realDir real path of the directory being walked
188
+ * @param ctxRel logical context-relative path of that directory
189
+ * @param treeRel path of that directory inside the resulting tree ('' = root)
190
+ * @param ancestors real paths of directories on the current walk path (cycle detection)
191
+ */
192
+ async walk(realDir, ctxRel, treeRel, origin, ancestors, out, span, exclude) {
193
+ const entries = await fs.readdir(realDir, { withFileTypes: true });
194
+ const names = entries.map((d) => d.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
195
+ const byName = new Map(entries.map((d) => [d.name, d]));
196
+ // Tree paths are NFC, so two names that differ only in normalization (possible
197
+ // on Linux, not on macOS) would be one path: refuse rather than pick one.
198
+ const seen = new Set();
199
+ for (const raw of names) {
200
+ const dirent = byName.get(raw);
201
+ const name = nfc(raw);
202
+ if (seen.has(name))
203
+ throw normalizationCollision(ctxRel === '' ? name : `${ctxRel}/${name}`, span);
204
+ seen.add(name);
205
+ const childCtx = ctxRel === '' ? name : `${ctxRel}/${name}`;
206
+ const childTree = treeRel === '' ? name : `${treeRel}/${name}`;
207
+ if (this.isIgnored(childCtx))
208
+ continue;
209
+ let childReal = join(realDir, raw);
210
+ let st;
211
+ if (dirent.isSymbolicLink()) {
212
+ const r = await this.resolve(childCtx, span, display(childCtx));
213
+ childReal = r.real;
214
+ st = r.st;
215
+ // A link to an ignored location (e.g. .git) is skipped too.
216
+ if (this.isIgnored(this.relOfReal(childReal)))
217
+ continue;
218
+ }
219
+ else {
220
+ st = await fs.lstat(childReal);
221
+ }
222
+ if (this.rules.ignores(childCtx, st.isDirectory()))
223
+ continue;
224
+ if (exclude?.ignores(childTree, st.isDirectory()))
225
+ continue;
226
+ if (st.isFile()) {
227
+ this.checkHardlink(st, childCtx, span);
228
+ const bytes = new Uint8Array(await fs.readFile(childReal));
229
+ out.push(['/' + childTree, makeFile(bytes, origin, st.mode)]);
230
+ }
231
+ else if (st.isDirectory()) {
232
+ if (ancestors.has(childReal))
233
+ continue; // directory symlink cycle
234
+ ancestors.add(childReal);
235
+ try {
236
+ await this.walk(childReal, childCtx, childTree, origin, ancestors, out, span, exclude);
237
+ }
238
+ finally {
239
+ ancestors.delete(childReal);
240
+ }
241
+ }
242
+ else {
243
+ throw new SitefileError('E_UNSUPPORTED_FILE', `'${display(childCtx)}' is not a regular file or directory (sockets, fifos and devices are not supported)`, span);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ //# sourceMappingURL=local.js.map
@@ -0,0 +1,52 @@
1
+ export interface LockEntry {
2
+ /** `sha256:<hex>` of the response body. */
3
+ readonly integrity: string;
4
+ /** Body size in bytes. */
5
+ readonly size: number;
6
+ /** Final URL after redirects. */
7
+ readonly resolved: string;
8
+ }
9
+ /** A container image reference (`BASE`) pinned to the digest its tag resolved to. */
10
+ export interface ImageLockEntry {
11
+ /** `sha256:<hex>` of what the tag pointed at: an image index or a manifest. */
12
+ readonly digest: string;
13
+ }
14
+ /** A Git IMPORT (`git+https://…#ref:path`, keyed by the URI as written) pinned to the commit its ref resolved to. */
15
+ export interface GitLockEntry {
16
+ /** 40 lowercase hex digits. */
17
+ readonly commit: string;
18
+ }
19
+ /** `Sitefile.lock`: pinned digests of remote resources. */
20
+ export declare class Lockfile {
21
+ readonly path: string;
22
+ private existed;
23
+ private originalText;
24
+ private readonly entries;
25
+ private readonly used;
26
+ private readonly images;
27
+ private readonly usedImages;
28
+ private readonly gits;
29
+ private readonly usedGits;
30
+ private constructor();
31
+ static load(path: string): Promise<Lockfile>;
32
+ /** The commit a Git IMPORT URI was locked to. */
33
+ getGit(url: string): GitLockEntry | undefined;
34
+ recordGit(url: string, entry: GitLockEntry): void;
35
+ /** The pinned digest for a canonical image reference (`docker.io/library/nginx:alpine`). */
36
+ getImage(ref: string): ImageLockEntry | undefined;
37
+ recordImage(ref: string, entry: ImageLockEntry): void;
38
+ markImageUsed(ref: string): void;
39
+ get(url: string): LockEntry | undefined;
40
+ /** Add or replace an entry and mark it used in this build. */
41
+ record(url: string, entry: LockEntry): void;
42
+ /** Keep an existing entry in the saved lockfile. */
43
+ markUsed(url: string): void;
44
+ /** The JSON text `save()` would write. */
45
+ serialize(): string;
46
+ /**
47
+ * Write the entries used in this build. Returns true if the file was
48
+ * written. Does nothing when the content is unchanged, and never creates
49
+ * a lockfile that would have no entries.
50
+ */
51
+ save(): Promise<boolean>;
52
+ }
@@ -0,0 +1,172 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { SitefileError } from '../errors.js';
3
+ import { INTEGRITY_RE } from '../digest.js';
4
+ function isObject(v) {
5
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
6
+ }
7
+ /** `Sitefile.lock`: pinned digests of remote resources. */
8
+ export class Lockfile {
9
+ path;
10
+ existed;
11
+ originalText;
12
+ entries;
13
+ used = new Set();
14
+ images = new Map();
15
+ usedImages = new Set();
16
+ gits = new Map();
17
+ usedGits = new Set();
18
+ constructor(path, entries, existed, originalText) {
19
+ this.path = path;
20
+ this.existed = existed;
21
+ this.originalText = originalText;
22
+ this.entries = entries;
23
+ }
24
+ static async load(path) {
25
+ let text;
26
+ try {
27
+ text = await fs.readFile(path, 'utf8');
28
+ }
29
+ catch (e) {
30
+ if (e.code === 'ENOENT')
31
+ return new Lockfile(path, new Map(), false, undefined);
32
+ throw e;
33
+ }
34
+ const bad = (why, cause) => new SitefileError('E_VALIDATION', `malformed lockfile ${path}: ${why}`, undefined, cause ? { cause } : undefined);
35
+ let data;
36
+ try {
37
+ data = JSON.parse(text);
38
+ }
39
+ catch (e) {
40
+ throw bad(e.message, e);
41
+ }
42
+ if (!isObject(data))
43
+ throw bad('expected a JSON object');
44
+ if (data.version !== 1)
45
+ throw bad(`unsupported version ${JSON.stringify(data.version)} (expected 1)`);
46
+ const resources = data.resources ?? {};
47
+ if (!isObject(resources))
48
+ throw bad('"resources" must be an object');
49
+ const entries = new Map();
50
+ for (const [url, raw] of Object.entries(resources)) {
51
+ if (!isObject(raw))
52
+ throw bad(`entry for ${url} must be an object`);
53
+ const { integrity, size, resolved } = raw;
54
+ if (typeof integrity !== 'string' || !INTEGRITY_RE.test(integrity)) {
55
+ throw bad(`entry for ${url} has an invalid "integrity" (expected sha256:<64 hex>)`);
56
+ }
57
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0) {
58
+ throw bad(`entry for ${url} has an invalid "size"`);
59
+ }
60
+ if (typeof resolved !== 'string')
61
+ throw bad(`entry for ${url} has an invalid "resolved"`);
62
+ entries.set(url, { integrity, size, resolved });
63
+ }
64
+ const lock = new Lockfile(path, entries, true, text);
65
+ const images = data.images ?? {};
66
+ if (!isObject(images))
67
+ throw bad('"images" must be an object');
68
+ for (const [ref, raw] of Object.entries(images)) {
69
+ if (!isObject(raw) || typeof raw.digest !== 'string' || !INTEGRITY_RE.test(raw.digest)) {
70
+ throw bad(`image entry for ${ref} needs a "digest" (sha256:<64 hex>)`);
71
+ }
72
+ lock.images.set(ref, { digest: raw.digest });
73
+ }
74
+ const gits = data.git ?? {};
75
+ if (!isObject(gits))
76
+ throw bad('"git" must be an object');
77
+ for (const [url, raw] of Object.entries(gits)) {
78
+ if (!isObject(raw) || typeof raw.commit !== 'string' || !/^[0-9a-f]{40}$/.test(raw.commit)) {
79
+ throw bad(`git entry for ${url} needs a "commit" (40 lowercase hex digits)`);
80
+ }
81
+ lock.gits.set(url, { commit: raw.commit });
82
+ }
83
+ return lock;
84
+ }
85
+ /** The commit a Git IMPORT URI was locked to. */
86
+ getGit(url) {
87
+ return this.gits.get(url);
88
+ }
89
+ recordGit(url, entry) {
90
+ this.gits.set(url, { commit: entry.commit });
91
+ this.usedGits.add(url);
92
+ }
93
+ /** The pinned digest for a canonical image reference (`docker.io/library/nginx:alpine`). */
94
+ getImage(ref) {
95
+ return this.images.get(ref);
96
+ }
97
+ recordImage(ref, entry) {
98
+ this.images.set(ref, { digest: entry.digest });
99
+ this.usedImages.add(ref);
100
+ }
101
+ markImageUsed(ref) {
102
+ if (this.images.has(ref))
103
+ this.usedImages.add(ref);
104
+ }
105
+ get(url) {
106
+ return this.entries.get(url);
107
+ }
108
+ /** Add or replace an entry and mark it used in this build. */
109
+ record(url, entry) {
110
+ this.entries.set(url, { integrity: entry.integrity, size: entry.size, resolved: entry.resolved });
111
+ this.used.add(url);
112
+ }
113
+ /** Keep an existing entry in the saved lockfile. */
114
+ markUsed(url) {
115
+ if (this.entries.has(url))
116
+ this.used.add(url);
117
+ }
118
+ /** The JSON text `save()` would write. */
119
+ serialize() {
120
+ const resources = {};
121
+ for (const url of [...this.used].sort()) {
122
+ const e = this.entries.get(url);
123
+ if (e)
124
+ resources[url] = { integrity: e.integrity, size: e.size, resolved: e.resolved };
125
+ }
126
+ const data = { version: 1, resources };
127
+ if (this.usedImages.size > 0) {
128
+ const images = {};
129
+ for (const ref of [...this.usedImages].sort()) {
130
+ const e = this.images.get(ref);
131
+ if (e)
132
+ images[ref] = { digest: e.digest };
133
+ }
134
+ data.images = images;
135
+ }
136
+ if (this.usedGits.size > 0) {
137
+ const gits = {};
138
+ for (const url of [...this.usedGits].sort()) {
139
+ const e = this.gits.get(url);
140
+ if (e)
141
+ gits[url] = { commit: e.commit };
142
+ }
143
+ data.git = gits;
144
+ }
145
+ return JSON.stringify(data, null, 2) + '\n';
146
+ }
147
+ /**
148
+ * Write the entries used in this build. Returns true if the file was
149
+ * written. Does nothing when the content is unchanged, and never creates
150
+ * a lockfile that would have no entries.
151
+ */
152
+ async save() {
153
+ const text = this.serialize();
154
+ if (!this.existed && this.used.size === 0 && this.usedImages.size === 0 && this.usedGits.size === 0)
155
+ return false;
156
+ if (this.existed && text === this.originalText)
157
+ return false;
158
+ const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
159
+ try {
160
+ await fs.writeFile(tmp, text);
161
+ await fs.rename(tmp, this.path);
162
+ this.existed = true;
163
+ this.originalText = text;
164
+ }
165
+ catch (e) {
166
+ await fs.rm(tmp, { force: true });
167
+ throw e;
168
+ }
169
+ return true;
170
+ }
171
+ }
172
+ //# sourceMappingURL=lockfile.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * True when `address` is an IP literal that is publicly routable. Loopback,
3
+ * private, link-local, CGNAT, documentation, multicast, reserved and every
4
+ * IPv4-mapped IPv6 address are not public. Non-IP strings return false.
5
+ */
6
+ export declare function isPublicAddress(address: string): boolean;
@@ -0,0 +1,82 @@
1
+ import { BlockList, isIP } from 'node:net';
2
+ // Separate lists: a BlockList checks IPv4 addresses against IPv4-mapped IPv6
3
+ // rules too, so `::ffff:0:0/96` in a shared list would block every IPv4.
4
+ const blocked4 = new BlockList();
5
+ const blocked6 = new BlockList();
6
+ const V4 = [
7
+ ['0.0.0.0', 8],
8
+ ['10.0.0.0', 8],
9
+ ['100.64.0.0', 10],
10
+ ['127.0.0.0', 8],
11
+ ['169.254.0.0', 16],
12
+ ['172.16.0.0', 12],
13
+ ['192.0.0.0', 24],
14
+ ['192.0.2.0', 24],
15
+ ['192.168.0.0', 16],
16
+ ['198.18.0.0', 15],
17
+ ['198.51.100.0', 24],
18
+ ['203.0.113.0', 24],
19
+ ['224.0.0.0', 4],
20
+ ['240.0.0.0', 4], // includes 255.255.255.255
21
+ ];
22
+ const V6 = [
23
+ ['::', 128],
24
+ ['::1', 128],
25
+ ['::ffff:0:0', 96], // all IPv4-mapped addresses
26
+ ['64:ff9b:1::', 48],
27
+ ['100::', 64],
28
+ ['2001:db8::', 32],
29
+ ['fc00::', 7],
30
+ ['fe80::', 10],
31
+ ['ff00::', 8],
32
+ ];
33
+ for (const [net, prefix] of V4)
34
+ blocked4.addSubnet(net, prefix, 'ipv4');
35
+ for (const [net, prefix] of V6)
36
+ blocked6.addSubnet(net, prefix, 'ipv6');
37
+ /**
38
+ * True when `address` is an IP literal that is publicly routable. Loopback,
39
+ * private, link-local, CGNAT, documentation, multicast, reserved and every
40
+ * IPv4-mapped IPv6 address are not public. Non-IP strings return false.
41
+ */
42
+ export function isPublicAddress(address) {
43
+ let a = address;
44
+ if (a.startsWith('[') && a.endsWith(']'))
45
+ a = a.slice(1, -1);
46
+ // Zone IDs (fe80::1%en0) are link-local by definition.
47
+ if (a.includes('%'))
48
+ return false;
49
+ const family = isIP(a);
50
+ if (family === 4)
51
+ return !blocked4.check(a, 'ipv4');
52
+ if (family === 6) {
53
+ if (blocked6.check(a, 'ipv6'))
54
+ return false;
55
+ // NAT64 well-known prefix 64:ff9b::/96 embeds an IPv4 address in the low
56
+ // 32 bits; judge it by that address (64:ff9b::7f00:1 is 127.0.0.1).
57
+ const groups = expandIPv6(a);
58
+ if (groups && groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0)) {
59
+ const hi = groups[6];
60
+ const lo = groups[7];
61
+ return !blocked4.check(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`, 'ipv4');
62
+ }
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ /** Eight 16-bit groups of a valid IPv6 literal (which may end in dotted IPv4). */
68
+ function expandIPv6(a) {
69
+ let s = a.toLowerCase();
70
+ const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s);
71
+ if (v4) {
72
+ const [b0, b1, b2, b3] = v4.slice(1).map(Number);
73
+ s = s.slice(0, v4.index) + ((b0 << 8) | b1).toString(16) + ':' + ((b2 << 8) | b3).toString(16);
74
+ }
75
+ const [head, tail] = s.split('::');
76
+ const h = head ? head.split(':') : [];
77
+ const t = tail ? tail.split(':') : [];
78
+ const zeros = tail === undefined ? [] : new Array(8 - h.length - t.length).fill('0');
79
+ const all = [...h, ...zeros, ...t].map((g) => parseInt(g, 16));
80
+ return all.length === 8 ? all : undefined;
81
+ }
82
+ //# sourceMappingURL=netpolicy.js.map
@@ -0,0 +1,63 @@
1
+ import type { Tree } from '../tree.js';
2
+ export interface Provenance {
3
+ /** The URI as written (after variable interpolation), or `./relative/path`. */
4
+ readonly uri: string;
5
+ /** Final URL after redirects, for remote resources. */
6
+ readonly resolvedUrl?: string;
7
+ /** `sha256:<hex>`: file bytes, tree digest, or the raw bytes a value was parsed from. */
8
+ readonly digest: string;
9
+ /** Git imports: the commit the content was read from (40 hex). */
10
+ readonly gitCommit?: string;
11
+ }
12
+ export interface FileResource {
13
+ readonly kind: 'file';
14
+ readonly bytes: Uint8Array;
15
+ readonly mode: number;
16
+ /** Suggested basename when copied to a directory destination. */
17
+ readonly basename: string;
18
+ readonly provenance: Provenance;
19
+ }
20
+ export interface TreeResource {
21
+ readonly kind: 'tree';
22
+ readonly tree: Tree;
23
+ readonly provenance: Provenance;
24
+ }
25
+ export interface ValueResource {
26
+ readonly kind: 'value';
27
+ /** `markdown`: one document object, or a list of them for a directory. */
28
+ /** `html`: the text of an `IMPORT … AS html` fragment. */
29
+ readonly format: 'text' | 'json' | 'markdown' | 'yaml' | 'csv' | 'wbn' | 'html';
30
+ readonly value: unknown;
31
+ readonly provenance: Provenance;
32
+ }
33
+ export type Resource = FileResource | TreeResource | ValueResource;
34
+ /** Network policy for remote resources. Defaults are secure. */
35
+ export interface FetchPolicy {
36
+ /** Permit plain `http:` (default false). */
37
+ readonly allowHttp: boolean;
38
+ /** Permit loopback, private, link-local and other non-public addresses (default false). */
39
+ readonly allowPrivateNetwork: boolean;
40
+ /** Maximum response body size in bytes (default 50 MiB). */
41
+ readonly maxBytes: number;
42
+ /** Total time budget per resource including redirects, ms (default 30 000). */
43
+ readonly timeoutMs: number;
44
+ /** Maximum redirects followed (default 5). */
45
+ readonly maxRedirects: number;
46
+ /**
47
+ * Extra PEM-encoded CA certificate(s) to trust for https, in addition to
48
+ * Node's built-in roots (never instead of them). For tests and enterprise
49
+ * private CAs; certificate and hostname verification stay on.
50
+ */
51
+ readonly ca?: string | Buffer | undefined;
52
+ }
53
+ export declare const DEFAULT_FETCH_POLICY: FetchPolicy;
54
+ /** Limits applied when an archive is extracted with `IMPORT … AS tree`. Defaults are secure. */
55
+ export interface ArchiveLimits {
56
+ /** Maximum number of entries (default 10 000). */
57
+ readonly maxEntries: number;
58
+ /** Maximum total uncompressed bytes (default 500 MiB). */
59
+ readonly maxTotalBytes: number;
60
+ /** Maximum uncompressed:compressed ratio of the gzip/zip data (default 100). */
61
+ readonly maxRatio: number;
62
+ }
63
+ export declare const DEFAULT_ARCHIVE_LIMITS: ArchiveLimits;
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_FETCH_POLICY = {
2
+ allowHttp: false,
3
+ allowPrivateNetwork: false,
4
+ maxBytes: 50 * 1024 * 1024,
5
+ timeoutMs: 30_000,
6
+ maxRedirects: 5,
7
+ };
8
+ export const DEFAULT_ARCHIVE_LIMITS = {
9
+ maxEntries: 10_000,
10
+ maxTotalBytes: 500 * 1024 * 1024,
11
+ maxRatio: 100,
12
+ };
13
+ //# sourceMappingURL=types.js.map