@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +21 -0
  3. package/README.md +513 -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 +120 -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 +119 -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/network.d.ts +34 -0
  37. package/lib/src/cli/network.js +56 -0
  38. package/lib/src/cli/outdated.d.ts +5 -0
  39. package/lib/src/cli/outdated.js +99 -0
  40. package/lib/src/cli/registry.d.ts +2 -0
  41. package/lib/src/cli/registry.js +127 -0
  42. package/lib/src/cli/serve-worker.d.ts +2 -0
  43. package/lib/src/cli/serve-worker.js +42 -0
  44. package/lib/src/cli/servers.d.ts +15 -0
  45. package/lib/src/cli/servers.js +467 -0
  46. package/lib/src/cli/vendor.d.ts +2 -0
  47. package/lib/src/cli/vendor.js +97 -0
  48. package/lib/src/cli/watch.d.ts +26 -0
  49. package/lib/src/cli/watch.js +81 -0
  50. package/lib/src/cli.d.ts +12 -0
  51. package/lib/src/cli.js +522 -0
  52. package/lib/src/csv.d.ts +27 -0
  53. package/lib/src/csv.js +117 -0
  54. package/lib/src/deflate.d.ts +76 -0
  55. package/lib/src/deflate.js +604 -0
  56. package/lib/src/digest.d.ts +3 -0
  57. package/lib/src/digest.js +7 -0
  58. package/lib/src/errors.d.ts +56 -0
  59. package/lib/src/errors.js +29 -0
  60. package/lib/src/executor.d.ts +393 -0
  61. package/lib/src/executor.js +2201 -0
  62. package/lib/src/explain.d.ts +64 -0
  63. package/lib/src/explain.js +445 -0
  64. package/lib/src/export-formats.d.ts +23 -0
  65. package/lib/src/export-formats.js +63 -0
  66. package/lib/src/exporter.d.ts +103 -0
  67. package/lib/src/exporter.js +676 -0
  68. package/lib/src/fingerprint.d.ts +40 -0
  69. package/lib/src/fingerprint.js +168 -0
  70. package/lib/src/frontmatter.d.ts +22 -0
  71. package/lib/src/frontmatter.js +45 -0
  72. package/lib/src/index.d.ts +13 -0
  73. package/lib/src/index.js +13 -0
  74. package/lib/src/language/ast.d.ts +360 -0
  75. package/lib/src/language/ast.js +57 -0
  76. package/lib/src/language/parser.d.ts +3 -0
  77. package/lib/src/language/parser.js +612 -0
  78. package/lib/src/language/scanner.d.ts +110 -0
  79. package/lib/src/language/scanner.js +449 -0
  80. package/lib/src/language/validate.d.ts +13 -0
  81. package/lib/src/language/validate.js +367 -0
  82. package/lib/src/links.d.ts +79 -0
  83. package/lib/src/links.js +583 -0
  84. package/lib/src/lint.d.ts +10 -0
  85. package/lib/src/lint.js +223 -0
  86. package/lib/src/markdown.d.ts +29 -0
  87. package/lib/src/markdown.js +612 -0
  88. package/lib/src/media.d.ts +82 -0
  89. package/lib/src/media.js +650 -0
  90. package/lib/src/memo.d.ts +57 -0
  91. package/lib/src/memo.js +93 -0
  92. package/lib/src/oci.d.ts +178 -0
  93. package/lib/src/oci.js +380 -0
  94. package/lib/src/outdated.d.ts +46 -0
  95. package/lib/src/outdated.js +195 -0
  96. package/lib/src/pages.d.ts +10 -0
  97. package/lib/src/pages.js +54 -0
  98. package/lib/src/paths.d.ts +39 -0
  99. package/lib/src/paths.js +101 -0
  100. package/lib/src/planner.d.ts +297 -0
  101. package/lib/src/planner.js +410 -0
  102. package/lib/src/plugins/cache.d.ts +96 -0
  103. package/lib/src/plugins/cache.js +325 -0
  104. package/lib/src/plugins/exec.d.ts +51 -0
  105. package/lib/src/plugins/exec.js +144 -0
  106. package/lib/src/plugins/host.d.ts +75 -0
  107. package/lib/src/plugins/host.js +277 -0
  108. package/lib/src/plugins/wasm.d.ts +23 -0
  109. package/lib/src/plugins/wasm.js +249 -0
  110. package/lib/src/profile.d.ts +42 -0
  111. package/lib/src/profile.js +68 -0
  112. package/lib/src/ref.d.ts +40 -0
  113. package/lib/src/ref.js +79 -0
  114. package/lib/src/registry-auth.d.ts +32 -0
  115. package/lib/src/registry-auth.js +151 -0
  116. package/lib/src/registry-cache.d.ts +71 -0
  117. package/lib/src/registry-cache.js +333 -0
  118. package/lib/src/registry-ops.d.ts +51 -0
  119. package/lib/src/registry-ops.js +248 -0
  120. package/lib/src/registry.d.ts +115 -0
  121. package/lib/src/registry.js +524 -0
  122. package/lib/src/render.d.ts +134 -0
  123. package/lib/src/render.js +576 -0
  124. package/lib/src/resources/archive.d.ts +11 -0
  125. package/lib/src/resources/archive.js +306 -0
  126. package/lib/src/resources/bundle-data.d.ts +16 -0
  127. package/lib/src/resources/bundle-data.js +50 -0
  128. package/lib/src/resources/convert.d.ts +12 -0
  129. package/lib/src/resources/convert.js +63 -0
  130. package/lib/src/resources/documents.d.ts +35 -0
  131. package/lib/src/resources/documents.js +122 -0
  132. package/lib/src/resources/git.d.ts +103 -0
  133. package/lib/src/resources/git.js +720 -0
  134. package/lib/src/resources/https.d.ts +74 -0
  135. package/lib/src/resources/https.js +413 -0
  136. package/lib/src/resources/ignore.d.ts +33 -0
  137. package/lib/src/resources/ignore.js +119 -0
  138. package/lib/src/resources/local.d.ts +64 -0
  139. package/lib/src/resources/local.js +248 -0
  140. package/lib/src/resources/lockfile.d.ts +52 -0
  141. package/lib/src/resources/lockfile.js +172 -0
  142. package/lib/src/resources/netpolicy.d.ts +6 -0
  143. package/lib/src/resources/netpolicy.js +82 -0
  144. package/lib/src/resources/network.d.ts +70 -0
  145. package/lib/src/resources/network.js +304 -0
  146. package/lib/src/resources/types.d.ts +69 -0
  147. package/lib/src/resources/types.js +13 -0
  148. package/lib/src/resources/vendor.d.ts +94 -0
  149. package/lib/src/resources/vendor.js +296 -0
  150. package/lib/src/resources/wbn-reader.d.ts +44 -0
  151. package/lib/src/resources/wbn-reader.js +302 -0
  152. package/lib/src/schema.d.ts +25 -0
  153. package/lib/src/schema.js +169 -0
  154. package/lib/src/secrets.d.ts +21 -0
  155. package/lib/src/secrets.js +103 -0
  156. package/lib/src/serve-rules.d.ts +29 -0
  157. package/lib/src/serve-rules.js +123 -0
  158. package/lib/src/server.d.ts +45 -0
  159. package/lib/src/server.js +246 -0
  160. package/lib/src/servers.d.ts +85 -0
  161. package/lib/src/servers.js +225 -0
  162. package/lib/src/site.d.ts +60 -0
  163. package/lib/src/site.js +169 -0
  164. package/lib/src/store.d.ts +151 -0
  165. package/lib/src/store.js +596 -0
  166. package/lib/src/tree.d.ts +50 -0
  167. package/lib/src/tree.js +134 -0
  168. package/lib/src/values.d.ts +7 -0
  169. package/lib/src/values.js +37 -0
  170. package/lib/src/vendor.d.ts +19 -0
  171. package/lib/src/vendor.js +145 -0
  172. package/lib/src/watch.d.ts +44 -0
  173. package/lib/src/watch.js +137 -0
  174. package/lib/src/wbn.d.ts +26 -0
  175. package/lib/src/wbn.js +241 -0
  176. package/lib/src/yaml.d.ts +34 -0
  177. package/lib/src/yaml.js +562 -0
  178. package/package.json +59 -4
@@ -0,0 +1,596 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createReadStream, promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { decodeReferrersIndex, encodeReferrersIndex } from './attest.js';
6
+ import { sha256 } from './digest.js';
7
+ import { SitefileError } from './errors.js';
8
+ import { ANN, blobPathOf, buildOciImage, decodeIndex, encodeIndex, extractImageFiles, layoutTree, OCI_LAYOUT_BYTES, parseConfig, parseManifest, siteLayerIndex, } from './oci.js';
9
+ import { looksLikeImageId, parseLocalRef, refKey } from './ref.js';
10
+ const HEX64 = /^[0-9a-f]{64}$/;
11
+ export function resolveStoreHome(env = process.env) {
12
+ const h = env.SITEFILE_HOME;
13
+ return path.resolve(h !== undefined && h !== '' ? h : path.join(os.homedir(), '.sitefile'));
14
+ }
15
+ function storeError(msg, cause) {
16
+ return new SitefileError('E_STORE', msg, undefined, cause === undefined ? undefined : { cause });
17
+ }
18
+ function errCode(e) {
19
+ return e?.code;
20
+ }
21
+ export function shortId(id) {
22
+ return id.replace(/^sha256:/, '').slice(0, 12);
23
+ }
24
+ export class Store {
25
+ home;
26
+ constructor(home) {
27
+ this.home = home;
28
+ }
29
+ /** The store at `home` if one exists there (nothing is created). */
30
+ static async openExisting(home = resolveStoreHome()) {
31
+ const st = await fs.stat(path.join(home, 'blobs', 'sha256')).catch(() => undefined);
32
+ return st?.isDirectory() ? new Store(path.resolve(home)) : undefined;
33
+ }
34
+ static async open(home = resolveStoreHome()) {
35
+ const s = new Store(path.resolve(home));
36
+ await fs.mkdir(path.join(s.home, 'blobs', 'sha256'), { recursive: true, mode: 0o700 });
37
+ await fs.writeFile(path.join(s.home, 'oci-layout'), OCI_LAYOUT_BYTES, { flag: 'wx', mode: 0o600 }).catch((e) => {
38
+ if (errCode(e) !== 'EEXIST')
39
+ throw e;
40
+ });
41
+ return s;
42
+ }
43
+ blobFile(digest) {
44
+ return path.join(this.home, blobPathOf(digest));
45
+ }
46
+ // ------------------------------------------------------------ locking
47
+ /** Run `fn` holding the store lock (an exclusive lock file; stale locks are broken). */
48
+ async withLock(fn) {
49
+ const lock = path.join(this.home, 'lock');
50
+ const deadline = Date.now() + 20_000;
51
+ for (;;) {
52
+ try {
53
+ const fh = await fs.open(lock, 'wx', 0o600);
54
+ await fh.writeFile(String(process.pid));
55
+ await fh.close();
56
+ break;
57
+ }
58
+ catch (e) {
59
+ if (errCode(e) !== 'EEXIST')
60
+ throw storeError(`cannot lock the store at '${this.home}': ${e.message}`, e);
61
+ if (await this.lockIsStale(lock)) {
62
+ await fs.rm(lock, { force: true });
63
+ continue;
64
+ }
65
+ if (Date.now() > deadline)
66
+ throw storeError(`timed out waiting for the store lock '${lock}'`);
67
+ await new Promise((r) => setTimeout(r, 20 + Math.random() * 30));
68
+ }
69
+ }
70
+ try {
71
+ return await fn();
72
+ }
73
+ finally {
74
+ await fs.rm(lock, { force: true });
75
+ }
76
+ }
77
+ async lockIsStale(lock) {
78
+ try {
79
+ const [text, st] = await Promise.all([fs.readFile(lock, 'utf8'), fs.stat(lock)]);
80
+ const pid = Number(text);
81
+ if (Date.now() - st.mtimeMs > 120_000)
82
+ return true;
83
+ if (!Number.isInteger(pid) || pid <= 0)
84
+ return Date.now() - st.mtimeMs > 2_000; // still being written
85
+ try {
86
+ process.kill(pid, 0);
87
+ return false;
88
+ }
89
+ catch (e) {
90
+ return errCode(e) === 'ESRCH';
91
+ }
92
+ }
93
+ catch {
94
+ return false; // vanished or unreadable: retry the open
95
+ }
96
+ }
97
+ async writeAtomic(dest, data) {
98
+ const tmp = path.join(path.dirname(dest), `.tmp-${randomBytes(6).toString('hex')}`);
99
+ try {
100
+ await fs.writeFile(tmp, data, { mode: 0o600 });
101
+ await fs.rename(tmp, dest);
102
+ }
103
+ catch (e) {
104
+ await fs.rm(tmp, { force: true });
105
+ throw storeError(`cannot write '${dest}': ${e.message}`, e);
106
+ }
107
+ }
108
+ // ------------------------------------------------------------ index
109
+ async readIndex() {
110
+ let bytes;
111
+ try {
112
+ bytes = await fs.readFile(path.join(this.home, 'index.json'));
113
+ }
114
+ catch (e) {
115
+ if (errCode(e) === 'ENOENT')
116
+ return [];
117
+ throw storeError(`cannot read index.json: ${e.message}`, e);
118
+ }
119
+ try {
120
+ return decodeIndex(bytes);
121
+ }
122
+ catch (e) {
123
+ throw storeError(`index.json is corrupt: ${e.message}`, e);
124
+ }
125
+ }
126
+ /** Normalize (one dangling entry per untagged image, none for tagged ones), sort, write. */
127
+ async writeIndex(entries) {
128
+ const tagged = new Set(entries.filter((e) => e.refName).map((e) => e.digest));
129
+ const seenDangling = new Set();
130
+ const out = entries.filter((e) => {
131
+ if (e.refName)
132
+ return true;
133
+ if (tagged.has(e.digest) || seenDangling.has(e.digest))
134
+ return false;
135
+ seenDangling.add(e.digest);
136
+ return true;
137
+ });
138
+ out.sort((a, b) => (a.digest + (a.refName ?? '') < b.digest + (b.refName ?? '') ? -1 : 1));
139
+ await this.writeAtomic(path.join(this.home, 'index.json'), encodeIndex(out));
140
+ }
141
+ /** `name:tag` → image ID. */
142
+ async refs() {
143
+ const out = {};
144
+ for (const e of await this.readIndex())
145
+ if (e.refName)
146
+ out[e.refName] = e.digest;
147
+ return out;
148
+ }
149
+ // ------------------------------------------------------------ blobs
150
+ /** Read a blob and verify its digest (and size when given). */
151
+ async readBlob(digest, size) {
152
+ let bytes;
153
+ try {
154
+ bytes = new Uint8Array(await fs.readFile(this.blobFile(digest)));
155
+ }
156
+ catch (e) {
157
+ if (errCode(e) === 'ENOENT')
158
+ throw storeError(`blob ${digest} is missing`, e);
159
+ throw storeError(`cannot read blob ${digest}: ${e.message}`, e);
160
+ }
161
+ if ((size !== undefined && bytes.byteLength !== size) || sha256(bytes) !== digest) {
162
+ throw storeError(`blob ${digest} is corrupt: its content does not match its digest`);
163
+ }
164
+ return bytes;
165
+ }
166
+ async putBlob(digest, bytes) {
167
+ const bp = this.blobFile(digest);
168
+ const st = await fs.stat(bp).catch(() => undefined);
169
+ if (st?.isFile() && st.size === bytes.byteLength)
170
+ return this.touchBlob(digest);
171
+ await this.writeAtomic(bp, bytes);
172
+ }
173
+ /**
174
+ * Mark a blob as just used (its mtime is the recency the size limit goes by, see cache-limit.ts;
175
+ * atime is often disabled). Never fails.
176
+ */
177
+ async touchBlob(digest) {
178
+ const now = new Date();
179
+ await fs.utimes(this.blobFile(digest), now, now).catch(() => undefined);
180
+ }
181
+ /** True when the blob exists with the given size (content is verified when it was written). */
182
+ async hasBlob(digest, size) {
183
+ const st = await fs.stat(this.blobFile(digest)).catch(() => undefined);
184
+ return !!st?.isFile() && (size === undefined || st.size === size);
185
+ }
186
+ /** Stream a stored blob (for upload); write-time verification means no re-hash here. */
187
+ blobStream(digest) {
188
+ return createReadStream(this.blobFile(digest));
189
+ }
190
+ /**
191
+ * Store a blob from a stream, verifying its size and sha256 while it is written (to a temp file
192
+ * that is renamed into place only when both match). `maxBytes` bounds what is accepted.
193
+ */
194
+ async importBlob(digest, size, source, maxBytes = size) {
195
+ const dest = this.blobFile(digest);
196
+ const tmp = path.join(path.dirname(dest), `.tmp-${randomBytes(6).toString('hex')}`);
197
+ const hash = createHash('sha256');
198
+ let total = 0;
199
+ const fh = await fs.open(tmp, 'wx', 0o600);
200
+ try {
201
+ for await (const chunk of source) {
202
+ total += chunk.byteLength;
203
+ if (total > size || total > maxBytes)
204
+ throw new SitefileError('E_TOO_LARGE', `blob ${digest} is larger than its declared size (${size} bytes)`);
205
+ hash.update(chunk);
206
+ await fh.write(chunk);
207
+ }
208
+ await fh.close();
209
+ if (total !== size)
210
+ throw new SitefileError('E_INTEGRITY', `blob ${digest} is truncated: got ${total} of ${size} bytes`);
211
+ if (`sha256:${hash.digest('hex')}` !== digest)
212
+ throw new SitefileError('E_INTEGRITY', `blob ${digest}: content does not match its digest`);
213
+ await fs.rename(tmp, dest);
214
+ }
215
+ catch (e) {
216
+ await fh.close().catch(() => undefined);
217
+ await fs.rm(tmp, { force: true });
218
+ throw e;
219
+ }
220
+ }
221
+ /** Store a small blob (manifest, config, index) given its bytes; the digest is computed. */
222
+ async putSmallBlob(bytes) {
223
+ const digest = sha256(bytes);
224
+ await this.putBlob(digest, bytes);
225
+ return digest;
226
+ }
227
+ /**
228
+ * Register a pulled image manifest (all its blobs must already be stored): it becomes an index
229
+ * entry, optionally tagged with `key` (replacing that tag's previous target).
230
+ */
231
+ async addManifest(manifestBytes, tagRef, now = new Date()) {
232
+ const id = await this.putSmallBlob(manifestBytes);
233
+ let parsed;
234
+ try {
235
+ parsed = parseManifest(manifestBytes);
236
+ }
237
+ catch (e) {
238
+ throw storeError(`manifest ${id} is not a usable image manifest: ${e.message}`, e);
239
+ }
240
+ for (const d of [parsed.config, ...parsed.layers]) {
241
+ if (!(await this.hasBlob(d.digest, d.size)))
242
+ throw storeError(`image ${shortId(id)} is incomplete: blob ${d.digest} is missing`);
243
+ }
244
+ await this.withLock(async () => {
245
+ const entries = await this.readIndex();
246
+ if (!entries.some((e) => e.digest === id)) {
247
+ await this.writeIndex([...entries, { digest: id, size: manifestBytes.byteLength, stored: now.toISOString() }]);
248
+ }
249
+ });
250
+ if (tagRef)
251
+ await this.tag(id, tagRef);
252
+ return id;
253
+ }
254
+ /** Whether an image with this manifest digest is in the index. */
255
+ async hasImage(id) {
256
+ return (await this.readIndex()).some((e) => e.digest === id);
257
+ }
258
+ // ------------------------------------------------------------ images
259
+ /** Store a tree as an OCI image (idempotent: identical content and options give the same ID). Untagged. */
260
+ async addImage(tree, options = {}, now = new Date()) {
261
+ const img = buildOciImage(tree, options);
262
+ return this.withLock(async () => {
263
+ await this.putBlob(img.layerDigest, img.layerBytes);
264
+ await this.putBlob(img.configDigest, img.configBytes);
265
+ await this.putBlob(img.manifestDigest, img.manifestBytes);
266
+ const entries = await this.readIndex();
267
+ if (entries.some((e) => e.digest === img.manifestDigest))
268
+ return { id: img.manifestDigest, created: false };
269
+ await fs.rm(this.referrersFile(img.manifestDigest), { force: true }); // left over from a removed image
270
+ await this.writeIndex([...entries, { digest: img.manifestDigest, size: img.manifestBytes.byteLength, stored: now.toISOString() }]);
271
+ return { id: img.manifestDigest, created: true };
272
+ });
273
+ }
274
+ /** Manifest and config of an image, digest-verified (not its layer). */
275
+ async details(id) {
276
+ const entries = (await this.readIndex()).filter((e) => e.digest === id);
277
+ const first = entries[0];
278
+ if (!first)
279
+ throw new SitefileError('E_NOT_FOUND', `no such image: ${shortId(id)}`);
280
+ const manifestBytes = await this.readBlob(id, first.size);
281
+ let manifest;
282
+ let config;
283
+ try {
284
+ manifest = parseManifest(manifestBytes);
285
+ config = parseConfig(await this.readBlob(manifest.config.digest, manifest.config.size));
286
+ }
287
+ catch (e) {
288
+ if (e instanceof SitefileError)
289
+ throw e;
290
+ throw storeError(`image ${shortId(id)} is corrupt: ${e.message}`, e);
291
+ }
292
+ const stored = entries.map((e) => e.stored).filter((s) => !!s).sort()[0] ?? config.created;
293
+ return {
294
+ id,
295
+ tags: entries.flatMap((e) => (e.refName ? [e.refName] : [])),
296
+ created: stored,
297
+ files: Number(manifest.annotations[ANN.files] ?? 0),
298
+ size: Number(manifest.annotations[ANN.size] ?? 0),
299
+ manifest,
300
+ config,
301
+ root: config.labels[ANN.root] ?? '/',
302
+ layerSize: manifest.layers[siteLayerIndex(manifest, config)]?.size ?? manifest.layers[0]?.size ?? 0,
303
+ };
304
+ }
305
+ /**
306
+ * Load an image's files (under its root, or under `o.root`), verifying manifest, config, layer digest
307
+ * and diff_id. `o.origin` is what the files' origins and error messages name (default `image:<short id>`).
308
+ */
309
+ async loadTree(id, o = {}) {
310
+ const d = await this.details(id);
311
+ const origin = o.origin ?? `image:${shortId(id)}`;
312
+ const layer = d.manifest.layers[siteLayerIndex(d.manifest, d.config)];
313
+ if (!layer)
314
+ throw new SitefileError('E_STORE', `${origin}: not a Sitefile image (it has ${d.manifest.layers.length} layers and no site layer)`);
315
+ const bytes = await this.readBlob(layer.digest, layer.size);
316
+ return extractImageFiles(d.manifest, d.config, bytes, origin, o.root);
317
+ }
318
+ /**
319
+ * The image as a standalone OCI layout Tree (for `save`), optionally annotated with a tag.
320
+ * Includes every layer, so an image built on a BASE is self-contained.
321
+ */
322
+ async layoutOf(id, refName) {
323
+ const d = await this.details(id);
324
+ const site = d.manifest.layers[siteLayerIndex(d.manifest, d.config)];
325
+ if (!site)
326
+ throw new SitefileError('E_STORE', `image ${shortId(id)} is not a Sitefile image and cannot be saved`);
327
+ const base = new Map();
328
+ for (const l of d.manifest.layers)
329
+ if (l.digest !== site.digest)
330
+ base.set(l.digest, await this.readBlob(l.digest, l.size));
331
+ return layoutTree({
332
+ manifestBytes: await this.readBlob(id),
333
+ manifestDigest: id,
334
+ configBytes: await this.readBlob(d.manifest.config.digest),
335
+ configDigest: d.manifest.config.digest,
336
+ layerBytes: await this.readBlob(site.digest, site.size),
337
+ layerDigest: site.digest,
338
+ }, refName, base, await this.referrers(id));
339
+ }
340
+ // ------------------------------------------------------------ referrers (attestations)
341
+ referrersFile(id) {
342
+ blobPathOf(id); // validates the digest
343
+ return path.join(this.home, 'referrers', 'sha256', id.slice(7));
344
+ }
345
+ /** Descriptors of the attestations stored for image `id` (none: empty). */
346
+ async referrerDescriptors(id) {
347
+ let bytes;
348
+ try {
349
+ bytes = await fs.readFile(this.referrersFile(id));
350
+ }
351
+ catch (e) {
352
+ if (errCode(e) === 'ENOENT')
353
+ return [];
354
+ throw storeError(`cannot read the attestations of ${shortId(id)}: ${e.message}`, e);
355
+ }
356
+ try {
357
+ return decodeReferrersIndex(bytes);
358
+ }
359
+ catch (e) {
360
+ throw storeError(`the attestations of ${shortId(id)} are corrupt: ${e.message}`, e);
361
+ }
362
+ }
363
+ /** The attestations stored for image `id`, with their blobs (digest-verified). */
364
+ async referrers(id) {
365
+ const out = [];
366
+ for (const d of await this.referrerDescriptors(id)) {
367
+ const manifestBytes = await this.readBlob(d.digest, d.size);
368
+ let m;
369
+ try {
370
+ m = parseManifest(manifestBytes);
371
+ }
372
+ catch (e) {
373
+ throw storeError(`attestation ${shortId(d.digest)} of ${shortId(id)} is corrupt: ${e.message}`, e);
374
+ }
375
+ const blobs = [[d.digest, manifestBytes]];
376
+ for (const b of [m.config, ...m.layers])
377
+ blobs.push([b.digest, await this.readBlob(b.digest, b.size)]);
378
+ out.push({ descriptor: d, blobs });
379
+ }
380
+ return out;
381
+ }
382
+ /**
383
+ * Attach attestations to stored image `id`: their blobs are written, and each replaces a stored
384
+ * attestation with the same predicate type (others are kept).
385
+ */
386
+ async putReferrers(id, referrers) {
387
+ if (referrers.length === 0)
388
+ return;
389
+ await this.withLock(async () => {
390
+ if (!(await this.readIndex()).some((e) => e.digest === id))
391
+ throw new SitefileError('E_NOT_FOUND', `no such image: ${shortId(id)}`);
392
+ for (const r of referrers)
393
+ for (const [d, b] of r.blobs)
394
+ await this.putBlob(d, b);
395
+ const kind = (d) => `${d.artifactType ?? ''}\0${d.annotations?.['in-toto.io/predicate-type'] ?? ''}`;
396
+ const fresh = new Set(referrers.map((r) => kind(r.descriptor)));
397
+ const kept = (await this.referrerDescriptors(id)).filter((d) => !fresh.has(kind(d)) && !referrers.some((r) => r.descriptor.digest === d.digest));
398
+ const file = this.referrersFile(id);
399
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
400
+ await this.writeAtomic(file, encodeReferrersIndex([...kept, ...referrers.map((r) => r.descriptor)]));
401
+ });
402
+ }
403
+ /** Blob hexes of the attestations of `id` (manifests, configs, layers); unreadable ones count as none. */
404
+ async referrerBlobHexes(id) {
405
+ const out = [];
406
+ for (const d of await this.referrerDescriptors(id).catch(() => [])) {
407
+ out.push(d.digest.slice(7));
408
+ try {
409
+ const m = parseManifest(await this.readBlob(d.digest, d.size));
410
+ for (const b of [m.config, ...m.layers])
411
+ out.push(b.digest.slice(7));
412
+ }
413
+ catch {
414
+ /* missing or corrupt: nothing more to keep */
415
+ }
416
+ }
417
+ return out;
418
+ }
419
+ /** Every stored image, newest first. */
420
+ async list() {
421
+ const out = [];
422
+ for (const id of [...new Set((await this.readIndex()).map((e) => e.digest))]) {
423
+ const { id: _i, tags, created, files, size } = await this.details(id);
424
+ out.push({ id, tags, created, files, size });
425
+ }
426
+ return out.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : a.id < b.id ? -1 : 1));
427
+ }
428
+ async idsWithPrefix(input) {
429
+ const hex = input.replace(/^sha256:/, '');
430
+ return [...new Set((await this.readIndex()).map((e) => e.digest))].filter((id) => id.startsWith(`sha256:${hex}`));
431
+ }
432
+ /** Resolve a reference or (short) image ID to a full image ID. */
433
+ async resolve(input) {
434
+ if (looksLikeImageId(input)) {
435
+ const matches = await this.idsWithPrefix(input);
436
+ if (matches.length === 1)
437
+ return matches[0];
438
+ if (matches.length > 1)
439
+ throw storeError(`ambiguous image ID '${input}' matches ${matches.length} images`);
440
+ }
441
+ const ref = parseLocalRef(input);
442
+ if (ref.digest !== undefined) {
443
+ if (!(await this.readIndex()).some((e) => e.digest === ref.digest))
444
+ throw new SitefileError('E_NOT_FOUND', `no such image: ${input}`);
445
+ return ref.digest;
446
+ }
447
+ const id = (await this.refs())[refKey(ref)];
448
+ if (id === undefined)
449
+ throw new SitefileError('E_NOT_FOUND', `no such image: ${input}`);
450
+ return id;
451
+ }
452
+ /** Point `name:tag` at an image (replacing any previous target). Returns the `name:tag` key. */
453
+ async tag(id, ref) {
454
+ if (ref.digest !== undefined) {
455
+ throw new SitefileError('E_USAGE', 'a tag must be name[:tag] or registry/name[:tag] (no digest)');
456
+ }
457
+ const key = refKey(ref);
458
+ await this.withLock(async () => {
459
+ const entries = await this.readIndex();
460
+ const mine = entries.find((e) => e.digest === id);
461
+ if (!mine)
462
+ throw new SitefileError('E_NOT_FOUND', `no such image: ${shortId(id)}`);
463
+ const previous = entries.find((e) => e.refName === key);
464
+ const next = entries.filter((e) => e.refName !== key);
465
+ // The old target stays as an untagged image if this was its last tag (writeIndex adds the dangling entry).
466
+ if (previous && !next.some((e) => e.digest === previous.digest))
467
+ next.push({ ...previous, refName: undefined });
468
+ const stored = entries.find((e) => e.digest === id && e.stored)?.stored;
469
+ next.push({ digest: id, size: mine.size, refName: key, stored });
470
+ await this.writeIndex(next);
471
+ });
472
+ return key;
473
+ }
474
+ /**
475
+ * `rmi`: remove a tag, or an image by ID. The image leaves the index when no tag references it
476
+ * (blobs are reclaimed by `prune`). Removing by ID while several tags point at it needs `force`.
477
+ */
478
+ async removeImage(input, force = false) {
479
+ return this.withLock(async () => {
480
+ let entries = await this.readIndex();
481
+ const untagged = [];
482
+ let id;
483
+ const idMatches = looksLikeImageId(input) ? await this.idsWithPrefix(input) : [];
484
+ if (idMatches.length > 0 || /^sha256:[0-9a-f]{64}$/.test(input)) {
485
+ id = await this.resolve(input);
486
+ const tags = entries.filter((e) => e.digest === id && e.refName).map((e) => e.refName);
487
+ if (tags.length > 1 && !force) {
488
+ throw storeError(`image ${shortId(id)} is referenced by ${tags.length} tags (${tags.join(', ')}); remove a tag, or use -f`);
489
+ }
490
+ untagged.push(...tags);
491
+ entries = entries.filter((e) => e.digest !== id);
492
+ }
493
+ else {
494
+ const ref = parseLocalRef(input);
495
+ if (ref.digest !== undefined)
496
+ throw new SitefileError('E_USAGE', 'rmi takes name[:tag] or an image ID, not name@digest');
497
+ const key = refKey(ref);
498
+ const target = entries.find((e) => e.refName === key);
499
+ if (!target)
500
+ throw new SitefileError('E_NOT_FOUND', `no such image: ${input}`);
501
+ id = target.digest;
502
+ untagged.push(key);
503
+ entries = entries.filter((e) => e.refName !== key);
504
+ if (entries.some((e) => e.digest === id)) {
505
+ await this.writeIndex(entries);
506
+ return { untagged, deleted: undefined };
507
+ }
508
+ }
509
+ await this.writeIndex(entries);
510
+ await fs.rm(this.referrersFile(id), { force: true });
511
+ return { untagged, deleted: id };
512
+ });
513
+ }
514
+ /**
515
+ * What the size limit may evict: `live` holds the blob hexes of tagged images (never evicted),
516
+ * `untagged` each untagged image with the blobs it uses. An unreadable tagged manifest throws
517
+ * (its blobs are unknown, so nothing may be evicted).
518
+ */
519
+ async gcView() {
520
+ const entries = await this.readIndex();
521
+ const blobsOf = async (id) => {
522
+ let m;
523
+ try {
524
+ m = parseManifest(await this.readBlob(id));
525
+ }
526
+ catch (e) {
527
+ if (e instanceof SitefileError)
528
+ throw e;
529
+ throw storeError(`image ${shortId(id)} is corrupt: ${e.message}`, e);
530
+ }
531
+ return [...[id, m.config.digest, ...m.layers.map((l) => l.digest)].map((d) => d.slice(7)), ...(await this.referrerBlobHexes(id))];
532
+ };
533
+ const live = new Set();
534
+ for (const id of new Set(entries.filter((e) => e.refName).map((e) => e.digest)))
535
+ for (const h of await blobsOf(id))
536
+ live.add(h);
537
+ const untagged = [];
538
+ for (const id of new Set(entries.filter((e) => !e.refName).map((e) => e.digest))) {
539
+ if (live.has(id.slice(7)))
540
+ continue;
541
+ untagged.push({ id, blobs: await blobsOf(id).catch(() => [id.slice(7)]) });
542
+ }
543
+ return { live, untagged };
544
+ }
545
+ /** Remove an untagged image from the index, with its attestation list (its blobs stay). The caller holds the lock. */
546
+ async dropUntaggedLocked(id) {
547
+ const entries = await this.readIndex();
548
+ if (entries.some((e) => e.digest === id && e.refName))
549
+ return;
550
+ await this.writeIndex(entries.filter((e) => e.digest !== id));
551
+ await fs.rm(this.referrersFile(id), { force: true });
552
+ }
553
+ /** Drop untagged images from the index, then delete every blob no remaining image uses. */
554
+ async prune() {
555
+ return this.withLock(async () => {
556
+ const entries = await this.readIndex();
557
+ const keep = entries.filter((e) => e.refName);
558
+ const dropped = new Set(entries.filter((e) => !e.refName).map((e) => e.digest));
559
+ for (const e of keep)
560
+ dropped.delete(e.digest);
561
+ await this.writeIndex(keep);
562
+ const live = new Set();
563
+ for (const id of new Set(keep.map((e) => e.digest))) {
564
+ const d = await this.details(id);
565
+ live.add(id.slice(7));
566
+ live.add(d.manifest.config.digest.slice(7));
567
+ for (const l of d.manifest.layers)
568
+ live.add(l.digest.slice(7));
569
+ for (const h of await this.referrerBlobHexes(id))
570
+ live.add(h);
571
+ }
572
+ // Attestation lists of images that are gone.
573
+ const refDir = path.join(this.home, 'referrers', 'sha256');
574
+ for (const name of await fs.readdir(refDir).catch(() => [])) {
575
+ if (HEX64.test(name) && !keep.some((e) => e.digest === `sha256:${name}`))
576
+ await fs.rm(path.join(refDir, name), { force: true });
577
+ }
578
+ let blobs = 0;
579
+ let bytes = 0;
580
+ const dir = path.join(this.home, 'blobs', 'sha256');
581
+ for (const name of await fs.readdir(dir)) {
582
+ const full = path.join(dir, name);
583
+ const st = await fs.stat(full).catch(() => undefined);
584
+ if (!st?.isFile())
585
+ continue;
586
+ if (HEX64.test(name) ? live.has(name) : Date.now() - st.mtimeMs < 3_600_000)
587
+ continue; // keep live blobs and fresh temp files
588
+ await fs.rm(full, { force: true });
589
+ blobs++;
590
+ bytes += st.size;
591
+ }
592
+ return { manifests: dropped.size, blobs, bytes };
593
+ });
594
+ }
595
+ }
596
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,50 @@
1
+ import { type SourceSpan } from './errors.js';
2
+ /** An immutable file inside a tree. */
3
+ export interface FileNode {
4
+ readonly bytes: Uint8Array;
5
+ /** 0o644 or 0o755. */
6
+ readonly mode: number;
7
+ /** `sha256:<hex>` of bytes. */
8
+ readonly digest: string;
9
+ /** Who wrote this path, e.g. `Sitefile:4 COPY`, for conflict messages and inspect. */
10
+ readonly origin: string;
11
+ }
12
+ export type ConflictPolicy = 'error' | 'overwrite';
13
+ export declare function makeFile(bytes: Uint8Array, origin: string, mode?: number): FileNode;
14
+ /**
15
+ * Immutable snapshot of a filesystem tree. Keys are normalized absolute
16
+ * virtual paths of files; directories are implicit. Every operation returns
17
+ * a new Tree.
18
+ */
19
+ export declare class Tree {
20
+ private readonly files;
21
+ private readonly dirs;
22
+ /** foldCase(path) -> the one spelling of it in this tree (files and directories). */
23
+ private readonly folded;
24
+ static readonly empty: Tree;
25
+ private constructor();
26
+ get size(): number;
27
+ get(path: string): FileNode | undefined;
28
+ isFile(path: string): boolean;
29
+ /** True for `/` and any implicit directory. */
30
+ isDirectory(path: string): boolean;
31
+ /** Sorted [path, node] pairs. */
32
+ entries(): Array<[string, FileNode]>;
33
+ /**
34
+ * Add files. Paths are normalized to NFC. Writing identical content (digest
35
+ * and mode) is a no-op that keeps the first owner. Different content fails
36
+ * with E_CONFLICT unless the policy is 'overwrite'. A file can never replace
37
+ * a directory or vice versa. A path (or one of its directories) that equals
38
+ * an existing one under simple case folding but is spelled differently
39
+ * fails with E_PATH_COLLISION whatever the policy: case-insensitive file
40
+ * systems cannot hold both.
41
+ */
42
+ withFiles(additions: Iterable<readonly [string, FileNode]>, policy: ConflictPolicy, span?: SourceSpan): Tree;
43
+ /** The files under a directory, re-rooted at `/`. */
44
+ subtree(dir: string): Tree;
45
+ /** The files of this tree placed under `dir`. */
46
+ mountedAt(dir: string): Array<[string, FileNode]>;
47
+ /** Content digest of the whole tree (paths, modes and file digests). */
48
+ digest(): string;
49
+ static fromFiles(files: Iterable<readonly [string, FileNode]>): Tree;
50
+ }