@twango/twiki 0.0.0-stage → 0.1.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/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- package/site/wrangler.jsonc +37 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
createReadStream,
|
|
7
|
+
createWriteStream,
|
|
8
|
+
existsSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
linkSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readSync,
|
|
16
|
+
realpathSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
statSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
writeSync
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { Readable, Transform } from 'node:stream';
|
|
26
|
+
import { pipeline } from 'node:stream/promises';
|
|
27
|
+
import { createGzip, createGunzip } from 'node:zlib';
|
|
28
|
+
import { readContentIndex, type ContentEdition } from './content-cache';
|
|
29
|
+
import { parseCourseCache, type CourseCacheRecord } from './course-cache';
|
|
30
|
+
import { historyCachePath } from './cache-selection';
|
|
31
|
+
import {
|
|
32
|
+
assetHashAlgorithmVersion,
|
|
33
|
+
captureAssetHashes,
|
|
34
|
+
rememberVerifiedAsset,
|
|
35
|
+
flushAssetHashCache
|
|
36
|
+
} from './asset-hash-cache';
|
|
37
|
+
import { wranglerAssetHash } from './deployment-manifest';
|
|
38
|
+
|
|
39
|
+
const digest = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
|
|
40
|
+
const sha = /^[a-f0-9]{64}$/;
|
|
41
|
+
const magic = Buffer.from('WISCONSIN_PRODUCT_CACHE_1\n');
|
|
42
|
+
const maximumManifest = 8 * 1024 * 1024;
|
|
43
|
+
const maximumEntry = 128 * 1024 * 1024;
|
|
44
|
+
const maximumPayload = 512 * 1024 * 1024;
|
|
45
|
+
const maximumFiles = 100_000;
|
|
46
|
+
export type ProductKind = 'global' | 'course' | 'search' | 'application';
|
|
47
|
+
export type ProductNamespace = 'production' | 'benchmark';
|
|
48
|
+
export interface ProductGroup {
|
|
49
|
+
id: string;
|
|
50
|
+
key: string;
|
|
51
|
+
archive: string;
|
|
52
|
+
inputs: string;
|
|
53
|
+
restoreKeys: string[];
|
|
54
|
+
namespace?: ProductNamespace;
|
|
55
|
+
}
|
|
56
|
+
export interface ProductPlan {
|
|
57
|
+
schema: 1;
|
|
58
|
+
namespace: ProductNamespace;
|
|
59
|
+
runtime: string;
|
|
60
|
+
groups: ProductGroup[];
|
|
61
|
+
}
|
|
62
|
+
interface Selection extends ProductGroup {
|
|
63
|
+
files: string[];
|
|
64
|
+
kind: ProductKind;
|
|
65
|
+
}
|
|
66
|
+
interface Entry {
|
|
67
|
+
path: string;
|
|
68
|
+
bytes: number;
|
|
69
|
+
sha256: string;
|
|
70
|
+
assetHash?: string;
|
|
71
|
+
assetHashVersion?: string;
|
|
72
|
+
}
|
|
73
|
+
interface Envelope {
|
|
74
|
+
schema: 1;
|
|
75
|
+
runtime: string;
|
|
76
|
+
group: string;
|
|
77
|
+
inputs: string;
|
|
78
|
+
files: Entry[];
|
|
79
|
+
namespace?: ProductNamespace;
|
|
80
|
+
}
|
|
81
|
+
export interface ProductOptions {
|
|
82
|
+
restore?: boolean;
|
|
83
|
+
kind?: ProductKind;
|
|
84
|
+
group?: string;
|
|
85
|
+
namespace?: ProductNamespace;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeRelative(file: unknown): file is string {
|
|
89
|
+
return (
|
|
90
|
+
typeof file === 'string' &&
|
|
91
|
+
file.length < 4096 &&
|
|
92
|
+
!/[\\:\x00-\x1f\x7f]/.test(file) &&
|
|
93
|
+
file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
function regular(cache: string, relative: string) {
|
|
97
|
+
if (!safeRelative(relative)) throw new Error('Unsafe cache path');
|
|
98
|
+
const file = path.join(cache, relative);
|
|
99
|
+
if (!lstatSync(file).isFile() || realpathSync(file) !== file)
|
|
100
|
+
throw new Error('Nonregular cache file');
|
|
101
|
+
return file;
|
|
102
|
+
}
|
|
103
|
+
function readJson(cache: string, file: string) {
|
|
104
|
+
return JSON.parse(readFileSync(regular(cache, file), 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
function sameFiles(actual: unknown, expected: string[]) {
|
|
107
|
+
return (
|
|
108
|
+
Array.isArray(actual) &&
|
|
109
|
+
actual.length === expected.length &&
|
|
110
|
+
actual.every((file) => typeof file === 'string') &&
|
|
111
|
+
JSON.stringify([...actual].sort()) === JSON.stringify([...expected].sort())
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
function idKind(id: string): ProductKind | undefined {
|
|
115
|
+
if (/^(?:global|files-global)-(?:public|full)$/.test(id)) return 'global';
|
|
116
|
+
if (/^course-(?:files-)?(?:public|full)-[a-f0-9]{16}$/.test(id)) return 'course';
|
|
117
|
+
if (/^search-(?:public|full)$/.test(id)) return 'search';
|
|
118
|
+
if (id === 'application') return 'application';
|
|
119
|
+
}
|
|
120
|
+
const groupEdition = (id: string) => id.match(/(?:^|-)(public|full)(?:-|$)/)?.[1] as ContentEdition;
|
|
121
|
+
const courseMetadataPath = /^course-files\/(public|full)\/([\w-]+)\.json$/;
|
|
122
|
+
function ownedFile(id: string, file: string) {
|
|
123
|
+
if (!safeRelative(file)) return false;
|
|
124
|
+
const kind = idKind(id);
|
|
125
|
+
const edition = groupEdition(id);
|
|
126
|
+
if (id.startsWith('files-global-'))
|
|
127
|
+
return (
|
|
128
|
+
file === `course-files-current-${edition}.json` ||
|
|
129
|
+
(courseMetadataPath.test(file) && courseMetadataPath.exec(file)![1] === edition)
|
|
130
|
+
);
|
|
131
|
+
if (id.startsWith('course-files-'))
|
|
132
|
+
return (
|
|
133
|
+
(courseMetadataPath.test(file) && courseMetadataPath.exec(file)![1] === edition) ||
|
|
134
|
+
historyCachePath.test(file)
|
|
135
|
+
);
|
|
136
|
+
if (kind === 'global')
|
|
137
|
+
return [`content-current-${edition}.json`, `content-files-current-${edition}.json`].includes(
|
|
138
|
+
file
|
|
139
|
+
);
|
|
140
|
+
if (kind === 'course')
|
|
141
|
+
return new RegExp(`^content/${edition}/(?:bodies|docs)/[a-f0-9]{64}\\.json$`).test(file);
|
|
142
|
+
if (kind === 'search')
|
|
143
|
+
return (
|
|
144
|
+
file === `search-current-${edition}.json` ||
|
|
145
|
+
new RegExp(`^search/${edition}/[a-f0-9]{64}/(?:manifest\\.json|files/.+)$`).test(file)
|
|
146
|
+
);
|
|
147
|
+
if (kind === 'application')
|
|
148
|
+
return (
|
|
149
|
+
file === 'application-current.json' ||
|
|
150
|
+
/^application\/[a-f0-9]{64}\/(?:manifest\.json|kit\/.+)$/.test(file)
|
|
151
|
+
);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const mutable = (file: string) => !file.includes('/') || courseMetadataPath.test(file);
|
|
155
|
+
|
|
156
|
+
function courseMetadata(cache: string, file: string, edition: ContentEdition) {
|
|
157
|
+
const match = courseMetadataPath.exec(file);
|
|
158
|
+
if (!match || match[1] !== edition) throw new Error('Invalid portable course metadata path');
|
|
159
|
+
const record = parseCourseCache(readJson(cache, file), match[2]);
|
|
160
|
+
if (!record || record.files.some((entry) => /\.rmd$/i.test(entry.path)))
|
|
161
|
+
throw new Error('Invalid portable course metadata');
|
|
162
|
+
return record;
|
|
163
|
+
}
|
|
164
|
+
function courseHistory(record: CourseCacheRecord) {
|
|
165
|
+
const history = new Map<string, { path: string; bytes: number; sha256: string }>();
|
|
166
|
+
for (const output of record.outputs) {
|
|
167
|
+
if (output.origin.kind !== 'history') continue;
|
|
168
|
+
const entry = { path: output.origin.cachePath, bytes: output.bytes, sha256: output.sha256 };
|
|
169
|
+
if (history.has(entry.path)) throw new Error('Duplicate course history dependency');
|
|
170
|
+
history.set(entry.path, entry);
|
|
171
|
+
}
|
|
172
|
+
return [...history.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
173
|
+
}
|
|
174
|
+
function fileSelection(
|
|
175
|
+
cache: string,
|
|
176
|
+
edition: ContentEdition,
|
|
177
|
+
selected: (course: string) => boolean = () => true
|
|
178
|
+
) {
|
|
179
|
+
const selector = `course-files-current-${edition}.json`;
|
|
180
|
+
const files = readJson(cache, selector);
|
|
181
|
+
if (
|
|
182
|
+
!Array.isArray(files) ||
|
|
183
|
+
new Set(files).size !== files.length ||
|
|
184
|
+
!files.every(
|
|
185
|
+
(file) =>
|
|
186
|
+
typeof file === 'string' && ownedFile(`files-global-${edition}`, file) && file !== selector
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
throw new Error('Invalid portable course selection');
|
|
190
|
+
return {
|
|
191
|
+
selector,
|
|
192
|
+
records: files
|
|
193
|
+
.filter((file: string) => selected(courseMetadataPath.exec(file)![2]))
|
|
194
|
+
.sort()
|
|
195
|
+
.map((file: string) => ({ file, record: courseMetadata(cache, file, edition) }))
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function fileGlobalInputs(cache: string, files: string[]) {
|
|
199
|
+
return digest(
|
|
200
|
+
JSON.stringify(
|
|
201
|
+
[...files].sort().map((file) => [file, digest(readFileSync(regular(cache, file)))])
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
function fileCourseInputs(
|
|
206
|
+
cache: string,
|
|
207
|
+
file: string,
|
|
208
|
+
record: CourseCacheRecord,
|
|
209
|
+
edition: ContentEdition
|
|
210
|
+
) {
|
|
211
|
+
return digest(
|
|
212
|
+
JSON.stringify([
|
|
213
|
+
edition,
|
|
214
|
+
record.course,
|
|
215
|
+
digest(readFileSync(regular(cache, file))),
|
|
216
|
+
courseHistory(record)
|
|
217
|
+
])
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function directoryParents(directory: string, root: string) {
|
|
222
|
+
for (
|
|
223
|
+
let current = directory;
|
|
224
|
+
current === root || current.startsWith(root + path.sep);
|
|
225
|
+
current = path.dirname(current)
|
|
226
|
+
) {
|
|
227
|
+
const information = lstatSync(current, { throwIfNoEntry: false });
|
|
228
|
+
if (information && (!information.isDirectory() || realpathSync(current) !== current))
|
|
229
|
+
throw new Error('Unsafe cache directory');
|
|
230
|
+
if (current === root) break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Transport compatibility supplements producer-specific renderer/input checks. */
|
|
235
|
+
export function productRuntimeIdentity(site: string) {
|
|
236
|
+
return digest(
|
|
237
|
+
JSON.stringify([
|
|
238
|
+
'product-cache-runtime-v1',
|
|
239
|
+
process.platform,
|
|
240
|
+
process.arch,
|
|
241
|
+
process.version,
|
|
242
|
+
process.versions.bun ?? '',
|
|
243
|
+
execFileSync('node', ['--version'], { encoding: 'utf8', stdio: 'pipe' }).trim(),
|
|
244
|
+
digest(readFileSync(path.join(site, 'bun.lock'))),
|
|
245
|
+
digest(readFileSync(path.join(site, 'package.json')))
|
|
246
|
+
])
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function selections(site: string, options: ProductOptions = {}) {
|
|
251
|
+
const namespace = options.namespace ?? 'production';
|
|
252
|
+
if (!['production', 'benchmark'].includes(namespace))
|
|
253
|
+
throw new Error('Invalid product namespace');
|
|
254
|
+
const runtime = productRuntimeIdentity(site);
|
|
255
|
+
const cache = path.resolve(site, 'build/generated/cache');
|
|
256
|
+
if (existsSync(cache) && realpathSync(cache) !== cache) throw new Error('Nonregular cache root');
|
|
257
|
+
const groups: Selection[] = [];
|
|
258
|
+
const wanted = (id: string) =>
|
|
259
|
+
(!options.group || options.group === id) && (!options.kind || options.kind === idKind(id));
|
|
260
|
+
const wantedCourses = (prefix: string) =>
|
|
261
|
+
(!options.kind || options.kind === 'course') &&
|
|
262
|
+
(!options.group || (options.group.startsWith(prefix) && idKind(options.group) === 'course'));
|
|
263
|
+
const add = (id: string, inputs: string, files: string[], exact = false) => {
|
|
264
|
+
if (!wanted(id)) return;
|
|
265
|
+
const kind = idKind(id);
|
|
266
|
+
if (!kind || !sha.test(inputs) || !files.every((file) => ownedFile(id, file)))
|
|
267
|
+
throw new Error('Invalid product selection');
|
|
268
|
+
const prefix = `wisconsin-products-${namespace === 'production' ? '' : 'benchmark-'}v1-${runtime}-${id}-`;
|
|
269
|
+
groups.push({
|
|
270
|
+
id,
|
|
271
|
+
kind,
|
|
272
|
+
namespace,
|
|
273
|
+
inputs,
|
|
274
|
+
key: prefix + inputs,
|
|
275
|
+
restoreKeys: exact ? [] : [prefix],
|
|
276
|
+
archive: path.resolve(
|
|
277
|
+
site,
|
|
278
|
+
'build/generated/product-caches',
|
|
279
|
+
...(namespace === 'benchmark' ? ['benchmark'] : []),
|
|
280
|
+
`${id}.gpg`
|
|
281
|
+
),
|
|
282
|
+
files: [...new Set(files)].sort()
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
for (const edition of ['public', 'full'] as ContentEdition[]) {
|
|
286
|
+
if (wanted(`global-${edition}`) || wantedCourses(`course-${edition}-`)) {
|
|
287
|
+
try {
|
|
288
|
+
const indexName = `content-current-${edition}.json`;
|
|
289
|
+
const listName = `content-files-current-${edition}.json`;
|
|
290
|
+
const candidate = readJson(cache, indexName);
|
|
291
|
+
if (!sha.test(candidate.renderer)) throw new Error('Invalid content renderer');
|
|
292
|
+
const index = readContentIndex(cache, edition, candidate.renderer);
|
|
293
|
+
if (!index) throw new Error('Invalid content index');
|
|
294
|
+
const references = [
|
|
295
|
+
...new Set([
|
|
296
|
+
indexName,
|
|
297
|
+
...Object.values(index.pages).flatMap((page) =>
|
|
298
|
+
[page.body, page.document].filter((file): file is string => !!file)
|
|
299
|
+
)
|
|
300
|
+
])
|
|
301
|
+
];
|
|
302
|
+
if (!sameFiles(readJson(cache, listName), references))
|
|
303
|
+
throw new Error('Incomplete content selection');
|
|
304
|
+
if (wanted(`global-${edition}`))
|
|
305
|
+
add(
|
|
306
|
+
`global-${edition}`,
|
|
307
|
+
digest(
|
|
308
|
+
Buffer.concat([
|
|
309
|
+
readFileSync(regular(cache, indexName)),
|
|
310
|
+
readFileSync(regular(cache, listName))
|
|
311
|
+
])
|
|
312
|
+
),
|
|
313
|
+
[indexName, listName]
|
|
314
|
+
);
|
|
315
|
+
if (wantedCourses(`course-${edition}-`)) {
|
|
316
|
+
const courses = new Map<string, Set<string>>();
|
|
317
|
+
for (const page of Object.values(index.pages)) {
|
|
318
|
+
const course = page.summary.rel.includes('/')
|
|
319
|
+
? page.summary.rel.split('/')[0]
|
|
320
|
+
: '_root';
|
|
321
|
+
const files = courses.get(course) ?? new Set<string>();
|
|
322
|
+
if (page.body) files.add(page.body);
|
|
323
|
+
if (page.document) files.add(page.document);
|
|
324
|
+
courses.set(course, files);
|
|
325
|
+
}
|
|
326
|
+
for (const [course, files] of courses) {
|
|
327
|
+
if (!files.size) continue;
|
|
328
|
+
const id = `course-${edition}-${digest(course).slice(0, 16)}`;
|
|
329
|
+
if (!wanted(id)) continue;
|
|
330
|
+
const entries = [...files].sort();
|
|
331
|
+
add(id, digest(JSON.stringify([edition, course, entries])), entries, true);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
if (options.restore) add(`global-${edition}`, digest('missing-current-global'), []);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (wanted(`files-global-${edition}`) || wantedCourses(`course-files-${edition}-`)) {
|
|
339
|
+
try {
|
|
340
|
+
const { selector, records } = fileSelection(
|
|
341
|
+
cache,
|
|
342
|
+
edition,
|
|
343
|
+
(course) =>
|
|
344
|
+
wanted(`files-global-${edition}`) ||
|
|
345
|
+
wanted(`course-files-${edition}-${digest(course).slice(0, 16)}`)
|
|
346
|
+
);
|
|
347
|
+
const files = [selector, ...records.map(({ file }) => file)];
|
|
348
|
+
if (wanted(`files-global-${edition}`))
|
|
349
|
+
add(`files-global-${edition}`, fileGlobalInputs(cache, files), files);
|
|
350
|
+
for (const { file, record } of records)
|
|
351
|
+
if (wanted(`course-files-${edition}-${digest(record.course).slice(0, 16)}`))
|
|
352
|
+
add(
|
|
353
|
+
`course-files-${edition}-${digest(record.course).slice(0, 16)}`,
|
|
354
|
+
fileCourseInputs(cache, file, record, edition),
|
|
355
|
+
[file, ...courseHistory(record).map((entry) => entry.path)],
|
|
356
|
+
true
|
|
357
|
+
);
|
|
358
|
+
} catch {
|
|
359
|
+
if (options.restore)
|
|
360
|
+
add(`files-global-${edition}`, digest('missing-current-course-files'), []);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (wanted(`search-${edition}`)) {
|
|
364
|
+
try {
|
|
365
|
+
const name = `search-current-${edition}.json`;
|
|
366
|
+
const files = readJson(cache, name);
|
|
367
|
+
if (
|
|
368
|
+
!Array.isArray(files) ||
|
|
369
|
+
!files.length ||
|
|
370
|
+
!files.every((file) => ownedFile(`search-${edition}`, file) && file !== name)
|
|
371
|
+
)
|
|
372
|
+
throw new Error('Invalid search selection');
|
|
373
|
+
const manifests = files.filter((file: string) =>
|
|
374
|
+
new RegExp(`^search/${edition}/[a-f0-9]{64}/manifest\\.json$`).test(file)
|
|
375
|
+
);
|
|
376
|
+
if (manifests.length !== 1) throw new Error('Incomplete search selection');
|
|
377
|
+
const manifest = readJson(cache, manifests[0]);
|
|
378
|
+
if (
|
|
379
|
+
manifest.version !== 1 ||
|
|
380
|
+
manifest.edition !== edition ||
|
|
381
|
+
!sha.test(manifest.key) ||
|
|
382
|
+
!Array.isArray(manifest.files)
|
|
383
|
+
)
|
|
384
|
+
throw new Error('Invalid search manifest');
|
|
385
|
+
const base = `search/${edition}/${manifest.key}`;
|
|
386
|
+
const expected = [
|
|
387
|
+
`${base}/manifest.json`,
|
|
388
|
+
...manifest.files.map((entry: Entry) => `${base}/files/${entry.path}`)
|
|
389
|
+
];
|
|
390
|
+
if (!sameFiles(files, expected)) throw new Error('Incomplete search selection');
|
|
391
|
+
add(`search-${edition}`, manifest.key, [name, ...files]);
|
|
392
|
+
} catch {
|
|
393
|
+
if (options.restore) add(`search-${edition}`, digest('missing-current-search'), []);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (wanted('application')) {
|
|
398
|
+
let applicationIdentity: string | undefined;
|
|
399
|
+
try {
|
|
400
|
+
const { applicationCacheIdentity } = await import('./application-cache');
|
|
401
|
+
applicationIdentity = applicationCacheIdentity(site).identity;
|
|
402
|
+
} catch {
|
|
403
|
+
/* Older sites or incomplete dependencies cannot select an exact app artifact. */
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const pointer = readJson(cache, 'application-current.json');
|
|
407
|
+
if (pointer.schema !== 1 || !sha.test(pointer.identity) || !Array.isArray(pointer.files))
|
|
408
|
+
throw new Error('Invalid application pointer');
|
|
409
|
+
const manifestName = `application/${pointer.identity}/manifest.json`;
|
|
410
|
+
const manifest = readJson(cache, manifestName);
|
|
411
|
+
if (
|
|
412
|
+
manifest.schema !== 1 ||
|
|
413
|
+
manifest.identity !== pointer.identity ||
|
|
414
|
+
!Array.isArray(manifest.files)
|
|
415
|
+
)
|
|
416
|
+
throw new Error('Invalid application manifest');
|
|
417
|
+
const expected = [
|
|
418
|
+
manifestName,
|
|
419
|
+
...manifest.files.map((entry: Entry) => `application/${pointer.identity}/kit/${entry.path}`)
|
|
420
|
+
];
|
|
421
|
+
if (!sameFiles(pointer.files, expected)) throw new Error('Incomplete application selection');
|
|
422
|
+
if (!options.restore && applicationIdentity && pointer.identity !== applicationIdentity)
|
|
423
|
+
throw new Error('Stale application artifact');
|
|
424
|
+
add(
|
|
425
|
+
'application',
|
|
426
|
+
options.restore ? (applicationIdentity ?? pointer.identity) : pointer.identity,
|
|
427
|
+
['application-current.json', ...pointer.files],
|
|
428
|
+
true
|
|
429
|
+
);
|
|
430
|
+
} catch {
|
|
431
|
+
if (options.restore)
|
|
432
|
+
add(
|
|
433
|
+
'application',
|
|
434
|
+
applicationIdentity ?? digest('missing-current-application'),
|
|
435
|
+
[],
|
|
436
|
+
!!applicationIdentity
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
runtime,
|
|
442
|
+
namespace,
|
|
443
|
+
groups: groups
|
|
444
|
+
.filter(
|
|
445
|
+
(group) =>
|
|
446
|
+
(!options.kind || group.kind === options.kind) &&
|
|
447
|
+
(!options.group || group.id === options.group)
|
|
448
|
+
)
|
|
449
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function planProductCaches(
|
|
454
|
+
site: string,
|
|
455
|
+
options: ProductOptions = {}
|
|
456
|
+
): Promise<ProductPlan> {
|
|
457
|
+
const { runtime, namespace, groups } = await selections(path.resolve(site), options);
|
|
458
|
+
return {
|
|
459
|
+
schema: 1,
|
|
460
|
+
namespace,
|
|
461
|
+
runtime,
|
|
462
|
+
groups: groups.map(({ files: _files, kind: _kind, ...group }) => group)
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function gpgSession(secret: string) {
|
|
467
|
+
if (!secret || /[\r\n]/.test(secret)) throw new Error('Invalid cache secret');
|
|
468
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'wisconsin-product-cache-'));
|
|
469
|
+
const env = { ...process.env, GNUPGHOME: directory };
|
|
470
|
+
const args = [
|
|
471
|
+
'--batch',
|
|
472
|
+
'--yes',
|
|
473
|
+
'--pinentry-mode',
|
|
474
|
+
'loopback',
|
|
475
|
+
'--no-symkey-cache',
|
|
476
|
+
'--passphrase-fd',
|
|
477
|
+
'0'
|
|
478
|
+
];
|
|
479
|
+
return {
|
|
480
|
+
directory,
|
|
481
|
+
run: (extra: string[]) =>
|
|
482
|
+
execFileSync('gpg', [...args, ...extra], {
|
|
483
|
+
env,
|
|
484
|
+
input: secret + '\n',
|
|
485
|
+
stdio: 'pipe',
|
|
486
|
+
timeout: 180_000,
|
|
487
|
+
maxBuffer: 1024 * 1024
|
|
488
|
+
}),
|
|
489
|
+
cleanup() {
|
|
490
|
+
try {
|
|
491
|
+
execFileSync('gpgconf', ['--kill', 'gpg-agent'], { env, stdio: 'pipe', timeout: 10_000 });
|
|
492
|
+
} catch {}
|
|
493
|
+
rmSync(directory, { recursive: true, force: true });
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function validateEnvelope(value: unknown, group: ProductGroup, runtime: string): Envelope {
|
|
499
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid product envelope');
|
|
500
|
+
const envelope = value as Envelope;
|
|
501
|
+
if (
|
|
502
|
+
envelope.schema !== 1 ||
|
|
503
|
+
envelope.group !== group.id ||
|
|
504
|
+
envelope.runtime !== runtime ||
|
|
505
|
+
(envelope.namespace ?? 'production') !== (group.namespace ?? 'production') ||
|
|
506
|
+
!sha.test(envelope.inputs) ||
|
|
507
|
+
(!group.restoreKeys.length && envelope.inputs !== group.inputs) ||
|
|
508
|
+
!Array.isArray(envelope.files) ||
|
|
509
|
+
!envelope.files.length ||
|
|
510
|
+
envelope.files.length > maximumFiles
|
|
511
|
+
)
|
|
512
|
+
throw new Error('Incompatible product envelope');
|
|
513
|
+
let total = 0;
|
|
514
|
+
const names = new Set<string>();
|
|
515
|
+
for (const entry of envelope.files) {
|
|
516
|
+
if (
|
|
517
|
+
!entry ||
|
|
518
|
+
!ownedFile(group.id, entry.path) ||
|
|
519
|
+
names.has(entry.path) ||
|
|
520
|
+
!Number.isSafeInteger(entry.bytes) ||
|
|
521
|
+
entry.bytes < 0 ||
|
|
522
|
+
entry.bytes > maximumEntry ||
|
|
523
|
+
!sha.test(entry.sha256)
|
|
524
|
+
)
|
|
525
|
+
throw new Error('Invalid product entry');
|
|
526
|
+
if (
|
|
527
|
+
(entry.assetHash === undefined) !== (entry.assetHashVersion === undefined) ||
|
|
528
|
+
(entry.assetHash !== undefined &&
|
|
529
|
+
(!/^[a-f0-9]{32}$/.test(entry.assetHash) || typeof entry.assetHashVersion !== 'string'))
|
|
530
|
+
)
|
|
531
|
+
throw new Error('Invalid product asset hash');
|
|
532
|
+
total += entry.bytes;
|
|
533
|
+
names.add(entry.path);
|
|
534
|
+
}
|
|
535
|
+
if (total > maximumPayload) throw new Error('Product exceeds size limit');
|
|
536
|
+
return envelope;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function validateClosure(cache: string, group: Selection, envelope: Envelope) {
|
|
540
|
+
const entries = new Map(envelope.files.map((entry) => [entry.path, entry]));
|
|
541
|
+
const names = [...entries.keys()];
|
|
542
|
+
const edition = groupEdition(group.id);
|
|
543
|
+
if (group.id.startsWith('files-global-')) {
|
|
544
|
+
const { selector, records } = fileSelection(cache, edition);
|
|
545
|
+
if (
|
|
546
|
+
!sameFiles(names, [selector, ...records.map(({ file }) => file)]) ||
|
|
547
|
+
fileGlobalInputs(cache, names) !== envelope.inputs
|
|
548
|
+
)
|
|
549
|
+
throw new Error('Incomplete portable course metadata closure');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (group.id.startsWith('course-files-')) {
|
|
553
|
+
const metadata = names.filter((file) => courseMetadataPath.test(file));
|
|
554
|
+
if (metadata.length !== 1) throw new Error('Incomplete portable course metadata');
|
|
555
|
+
const file = metadata[0];
|
|
556
|
+
const record = courseMetadata(cache, file, edition);
|
|
557
|
+
const history = courseHistory(record);
|
|
558
|
+
if (
|
|
559
|
+
group.id !== `course-files-${edition}-${digest(record.course).slice(0, 16)}` ||
|
|
560
|
+
!sameFiles(names, [file, ...history.map((entry) => entry.path)]) ||
|
|
561
|
+
!sameFiles(names, group.files) ||
|
|
562
|
+
fileCourseInputs(cache, file, record, edition) !== envelope.inputs
|
|
563
|
+
)
|
|
564
|
+
throw new Error('Incomplete course history product closure');
|
|
565
|
+
for (const expected of history) {
|
|
566
|
+
const actual = entries.get(expected.path)!;
|
|
567
|
+
if (actual.bytes !== expected.bytes || actual.sha256 !== expected.sha256)
|
|
568
|
+
throw new Error('Course history byte identity mismatch');
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (group.kind === 'course') {
|
|
573
|
+
if (!sameFiles(names, group.files)) throw new Error('Incomplete course product closure');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (group.kind === 'global') {
|
|
577
|
+
const indexName = `content-current-${edition}.json`;
|
|
578
|
+
const listName = `content-files-current-${edition}.json`;
|
|
579
|
+
if (!sameFiles(names, [indexName, listName]))
|
|
580
|
+
throw new Error('Incomplete global product closure');
|
|
581
|
+
const candidate = readJson(cache, indexName);
|
|
582
|
+
const index = readContentIndex(cache, edition, candidate.renderer);
|
|
583
|
+
if (!index) throw new Error('Invalid global product index');
|
|
584
|
+
const files = [
|
|
585
|
+
...new Set([
|
|
586
|
+
indexName,
|
|
587
|
+
...Object.values(index.pages).flatMap((page) =>
|
|
588
|
+
[page.body, page.document].filter((file): file is string => !!file)
|
|
589
|
+
)
|
|
590
|
+
])
|
|
591
|
+
];
|
|
592
|
+
if (!sameFiles(readJson(cache, listName), files))
|
|
593
|
+
throw new Error('Invalid global product selection');
|
|
594
|
+
if (
|
|
595
|
+
digest(
|
|
596
|
+
Buffer.concat([
|
|
597
|
+
readFileSync(regular(cache, indexName)),
|
|
598
|
+
readFileSync(regular(cache, listName))
|
|
599
|
+
])
|
|
600
|
+
) !== envelope.inputs
|
|
601
|
+
)
|
|
602
|
+
throw new Error('Global product identity mismatch');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const pointerName =
|
|
606
|
+
group.kind === 'application' ? 'application-current.json' : `search-current-${edition}.json`;
|
|
607
|
+
const pointer = readJson(cache, pointerName);
|
|
608
|
+
const selected = group.kind === 'application' ? pointer.files : pointer;
|
|
609
|
+
if (!Array.isArray(selected) || !sameFiles(names, [pointerName, ...selected]))
|
|
610
|
+
throw new Error('Incomplete product pointer closure');
|
|
611
|
+
// Prepared Vite output and Pagefind payloads can themselves contain files
|
|
612
|
+
// named manifest.json. Only the producer's exact root manifest owns closure.
|
|
613
|
+
const manifestName =
|
|
614
|
+
group.kind === 'application'
|
|
615
|
+
? `application/${envelope.inputs}/manifest.json`
|
|
616
|
+
: `search/${edition}/${envelope.inputs}/manifest.json`;
|
|
617
|
+
if (!selected.includes(manifestName)) throw new Error('Incomplete product manifest');
|
|
618
|
+
const manifest = readJson(cache, manifestName);
|
|
619
|
+
let base: string;
|
|
620
|
+
if (group.kind === 'application') {
|
|
621
|
+
if (
|
|
622
|
+
pointer.schema !== 1 ||
|
|
623
|
+
pointer.identity !== envelope.inputs ||
|
|
624
|
+
manifest.schema !== 1 ||
|
|
625
|
+
manifest.identity !== pointer.identity
|
|
626
|
+
)
|
|
627
|
+
throw new Error('Incompatible application product');
|
|
628
|
+
base = `application/${pointer.identity}/kit`;
|
|
629
|
+
} else {
|
|
630
|
+
if (manifest.version !== 1 || manifest.edition !== edition || manifest.key !== envelope.inputs)
|
|
631
|
+
throw new Error('Incompatible search product');
|
|
632
|
+
base = `search/${edition}/${manifest.key}/files`;
|
|
633
|
+
}
|
|
634
|
+
if (
|
|
635
|
+
!Array.isArray(manifest.files) ||
|
|
636
|
+
!sameFiles(selected, [
|
|
637
|
+
manifestName,
|
|
638
|
+
...manifest.files.map((entry: Entry) => `${base}/${entry.path}`)
|
|
639
|
+
])
|
|
640
|
+
)
|
|
641
|
+
throw new Error('Incomplete producer manifest');
|
|
642
|
+
for (const entry of manifest.files) {
|
|
643
|
+
const actual = entries.get(`${base}/${entry.path}`);
|
|
644
|
+
if (
|
|
645
|
+
!actual ||
|
|
646
|
+
!safeRelative(entry.path) ||
|
|
647
|
+
actual.bytes !== entry.bytes ||
|
|
648
|
+
actual.sha256 !== entry.sha256
|
|
649
|
+
)
|
|
650
|
+
throw new Error('Producer byte identity mismatch');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function saveGroup(site: string, group: Selection, runtime: string, secret: string) {
|
|
655
|
+
if (!group.files.length) return false;
|
|
656
|
+
const cache = realpathSync(path.resolve(site, 'build/generated/cache'));
|
|
657
|
+
if (group.id.startsWith('course-files-')) {
|
|
658
|
+
const required = group.files.filter((file) => historyCachePath.test(file));
|
|
659
|
+
if (required.length) {
|
|
660
|
+
const current = readJson(cache, 'history-current.json');
|
|
661
|
+
if (
|
|
662
|
+
!Array.isArray(current) ||
|
|
663
|
+
!current.every((file) => typeof file === 'string' && historyCachePath.test(file)) ||
|
|
664
|
+
!required.every((file) => current.includes(file))
|
|
665
|
+
)
|
|
666
|
+
throw new Error('Course history is not selected by the current full build');
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const entries = group.files.map((file) => {
|
|
670
|
+
const source = regular(cache, file);
|
|
671
|
+
if (statSync(source).size > maximumEntry) throw new Error('Oversized product file');
|
|
672
|
+
const bytes = readFileSync(source);
|
|
673
|
+
const hash = digest(bytes);
|
|
674
|
+
const asset = captureAssetHashes(site, source);
|
|
675
|
+
if (asset.sha256 !== hash || asset.size !== bytes.length)
|
|
676
|
+
throw new Error('Product changed while hashing');
|
|
677
|
+
if (/^content\//.test(file) && path.basename(file, '.json') !== hash)
|
|
678
|
+
throw new Error('Content object hash mismatch');
|
|
679
|
+
return {
|
|
680
|
+
path: file,
|
|
681
|
+
bytes: bytes.length,
|
|
682
|
+
sha256: hash,
|
|
683
|
+
assetHash: asset.hash,
|
|
684
|
+
assetHashVersion: assetHashAlgorithmVersion
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
const envelope = validateEnvelope(
|
|
688
|
+
{
|
|
689
|
+
schema: 1,
|
|
690
|
+
runtime,
|
|
691
|
+
group: group.id,
|
|
692
|
+
namespace: group.namespace,
|
|
693
|
+
inputs: group.inputs,
|
|
694
|
+
files: entries
|
|
695
|
+
},
|
|
696
|
+
group,
|
|
697
|
+
runtime
|
|
698
|
+
);
|
|
699
|
+
validateClosure(cache, group, envelope);
|
|
700
|
+
const metadata = Buffer.from(JSON.stringify(envelope));
|
|
701
|
+
if (metadata.length > maximumManifest) throw new Error('Product manifest exceeds limit');
|
|
702
|
+
const length = Buffer.alloc(4);
|
|
703
|
+
length.writeUInt32BE(metadata.length);
|
|
704
|
+
const session = gpgSession(secret);
|
|
705
|
+
try {
|
|
706
|
+
const packed = path.join(session.directory, 'product.gz');
|
|
707
|
+
async function* chunks() {
|
|
708
|
+
yield magic;
|
|
709
|
+
yield length;
|
|
710
|
+
yield metadata;
|
|
711
|
+
for (const entry of entries) {
|
|
712
|
+
const bytes = readFileSync(regular(cache, entry.path));
|
|
713
|
+
if (bytes.length !== entry.bytes || digest(bytes) !== entry.sha256)
|
|
714
|
+
throw new Error('Product changed during save');
|
|
715
|
+
yield bytes;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
await pipeline(
|
|
719
|
+
Readable.from(chunks()),
|
|
720
|
+
createGzip({ level: 6 }),
|
|
721
|
+
createWriteStream(packed, { mode: 0o600 })
|
|
722
|
+
);
|
|
723
|
+
const encrypted = path.join(session.directory, 'product.gpg');
|
|
724
|
+
session.run([
|
|
725
|
+
'--symmetric',
|
|
726
|
+
'--cipher-algo',
|
|
727
|
+
'AES256',
|
|
728
|
+
'--compress-algo',
|
|
729
|
+
'none',
|
|
730
|
+
'--force-mdc',
|
|
731
|
+
'--output',
|
|
732
|
+
encrypted,
|
|
733
|
+
packed
|
|
734
|
+
]);
|
|
735
|
+
directoryParents(path.dirname(group.archive), path.resolve(site));
|
|
736
|
+
mkdirSync(path.dirname(group.archive), { recursive: true });
|
|
737
|
+
const temporary = group.archive + `.tmp-${process.pid}`;
|
|
738
|
+
try {
|
|
739
|
+
copyFileSync(encrypted, temporary);
|
|
740
|
+
renameSync(temporary, group.archive);
|
|
741
|
+
} finally {
|
|
742
|
+
rmSync(temporary, { force: true });
|
|
743
|
+
}
|
|
744
|
+
return true;
|
|
745
|
+
} finally {
|
|
746
|
+
session.cleanup();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function readExactly(fd: number, size: number) {
|
|
751
|
+
const bytes = Buffer.alloc(size);
|
|
752
|
+
let offset = 0;
|
|
753
|
+
while (offset < size) {
|
|
754
|
+
const read = readSync(fd, bytes, offset, size - offset, null);
|
|
755
|
+
if (!read) throw new Error('Truncated product payload');
|
|
756
|
+
offset += read;
|
|
757
|
+
}
|
|
758
|
+
return bytes;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Validate the complete archive before any maintained cache mutation. */
|
|
762
|
+
async function unpack(packed: string, staged: string, group: ProductGroup, runtime: string) {
|
|
763
|
+
const decoded = path.join(staged, 'payload');
|
|
764
|
+
let bytes = 0;
|
|
765
|
+
await pipeline(
|
|
766
|
+
createReadStream(packed),
|
|
767
|
+
createGunzip(),
|
|
768
|
+
new Transform({
|
|
769
|
+
transform(chunk, _encoding, done) {
|
|
770
|
+
bytes += chunk.length;
|
|
771
|
+
done(
|
|
772
|
+
bytes > maximumPayload + maximumManifest + magic.length + 4
|
|
773
|
+
? new Error('Oversized product payload')
|
|
774
|
+
: null,
|
|
775
|
+
chunk
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
}),
|
|
779
|
+
createWriteStream(decoded, { mode: 0o600 })
|
|
780
|
+
);
|
|
781
|
+
const fd = openSync(decoded, 'r');
|
|
782
|
+
try {
|
|
783
|
+
if (!readExactly(fd, magic.length).equals(magic)) throw new Error('Invalid product header');
|
|
784
|
+
const length = readExactly(fd, 4).readUInt32BE();
|
|
785
|
+
if (length > maximumManifest) throw new Error('Oversized product manifest');
|
|
786
|
+
const envelope = validateEnvelope(
|
|
787
|
+
JSON.parse(readExactly(fd, length).toString()),
|
|
788
|
+
group,
|
|
789
|
+
runtime
|
|
790
|
+
);
|
|
791
|
+
for (const entry of envelope.files) {
|
|
792
|
+
const file = path.join(staged, 'files', entry.path);
|
|
793
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
794
|
+
const target = openSync(file, 'wx', 0o600);
|
|
795
|
+
const hash = createHash('sha256');
|
|
796
|
+
try {
|
|
797
|
+
let remaining = entry.bytes;
|
|
798
|
+
while (remaining) {
|
|
799
|
+
const chunk = readExactly(fd, Math.min(remaining, 1024 * 1024));
|
|
800
|
+
hash.update(chunk);
|
|
801
|
+
let written = 0;
|
|
802
|
+
while (written < chunk.length)
|
|
803
|
+
written += writeSync(target, chunk, written, chunk.length - written);
|
|
804
|
+
remaining -= chunk.length;
|
|
805
|
+
}
|
|
806
|
+
} finally {
|
|
807
|
+
closeSync(target);
|
|
808
|
+
}
|
|
809
|
+
if (hash.digest('hex') !== entry.sha256) throw new Error('Product hash mismatch');
|
|
810
|
+
if (
|
|
811
|
+
entry.assetHashVersion === assetHashAlgorithmVersion &&
|
|
812
|
+
wranglerAssetHash(readFileSync(file), entry.path) !== entry.assetHash
|
|
813
|
+
)
|
|
814
|
+
throw new Error('Product asset hash mismatch');
|
|
815
|
+
if (entry.path.startsWith('content/') && path.basename(entry.path, '.json') !== entry.sha256)
|
|
816
|
+
throw new Error('Content address mismatch');
|
|
817
|
+
}
|
|
818
|
+
if (readSync(fd, Buffer.alloc(1), 0, 1, null)) throw new Error('Trailing product payload');
|
|
819
|
+
return envelope;
|
|
820
|
+
} finally {
|
|
821
|
+
closeSync(fd);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Publish validated objects without clobbering concurrent restores; roll back only current pointers. */
|
|
826
|
+
export function promoteProductFiles(cache: string, staged: string, envelope: Envelope) {
|
|
827
|
+
directoryParents(cache, path.resolve(cache, '../../..'));
|
|
828
|
+
mkdirSync(cache, { recursive: true });
|
|
829
|
+
if (realpathSync(cache) !== cache) throw new Error('Nonregular cache root');
|
|
830
|
+
for (const entry of envelope.files) {
|
|
831
|
+
const target = path.join(cache, entry.path);
|
|
832
|
+
for (
|
|
833
|
+
let parent = path.dirname(target);
|
|
834
|
+
parent.startsWith(cache);
|
|
835
|
+
parent = path.dirname(parent)
|
|
836
|
+
) {
|
|
837
|
+
if (
|
|
838
|
+
existsSync(parent) &&
|
|
839
|
+
(!lstatSync(parent).isDirectory() || realpathSync(parent) !== parent)
|
|
840
|
+
)
|
|
841
|
+
throw new Error('Unsafe cache directory');
|
|
842
|
+
if (parent === cache) break;
|
|
843
|
+
}
|
|
844
|
+
if (lstatSync(target, { throwIfNoEntry: false })) {
|
|
845
|
+
regular(cache, entry.path);
|
|
846
|
+
if (!mutable(entry.path) && digest(readFileSync(target)) !== entry.sha256)
|
|
847
|
+
throw new Error('Conflicting immutable cache object');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const transaction = mkdtempSync(path.join(path.dirname(cache), '.product-restore-'));
|
|
851
|
+
const changed: { target: string; backup?: string }[] = [];
|
|
852
|
+
let keepBackup = false;
|
|
853
|
+
try {
|
|
854
|
+
// Objects precede authoritative current pointers.
|
|
855
|
+
for (const entry of [...envelope.files].sort(
|
|
856
|
+
(a, b) => Number(mutable(a.path)) - Number(mutable(b.path))
|
|
857
|
+
)) {
|
|
858
|
+
const target = path.join(cache, entry.path);
|
|
859
|
+
if (!mutable(entry.path)) {
|
|
860
|
+
const verifyExisting = () => {
|
|
861
|
+
if (digest(readFileSync(regular(cache, entry.path))) !== entry.sha256)
|
|
862
|
+
throw new Error('Conflicting immutable cache object');
|
|
863
|
+
};
|
|
864
|
+
if (existsSync(target)) {
|
|
865
|
+
verifyExisting();
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
869
|
+
const temporary = target + `.product-tmp-${process.pid}`;
|
|
870
|
+
try {
|
|
871
|
+
copyFileSync(path.join(staged, 'files', entry.path), temporary);
|
|
872
|
+
try {
|
|
873
|
+
// A same-directory hardlink atomically publishes complete bytes and refuses
|
|
874
|
+
// to replace a winner from another restore process.
|
|
875
|
+
linkSync(temporary, target);
|
|
876
|
+
} catch (error) {
|
|
877
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
878
|
+
verifyExisting();
|
|
879
|
+
}
|
|
880
|
+
} finally {
|
|
881
|
+
rmSync(temporary, { force: true });
|
|
882
|
+
}
|
|
883
|
+
// Other successful groups may already reference this object. It remains
|
|
884
|
+
// safe and reusable even if a later file in this restore fails.
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const backup = existsSync(target)
|
|
888
|
+
? path.join(transaction, String(changed.length))
|
|
889
|
+
: undefined;
|
|
890
|
+
if (backup) copyFileSync(target, backup);
|
|
891
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
892
|
+
const temporary = target + `.product-tmp-${process.pid}`;
|
|
893
|
+
try {
|
|
894
|
+
copyFileSync(path.join(staged, 'files', entry.path), temporary);
|
|
895
|
+
renameSync(temporary, target);
|
|
896
|
+
} finally {
|
|
897
|
+
rmSync(temporary, { force: true });
|
|
898
|
+
}
|
|
899
|
+
changed.push({ target, backup });
|
|
900
|
+
}
|
|
901
|
+
} catch (failure) {
|
|
902
|
+
const errors: unknown[] = [];
|
|
903
|
+
for (const { target, backup } of changed.reverse()) {
|
|
904
|
+
try {
|
|
905
|
+
if (backup) renameSync(backup, target);
|
|
906
|
+
else rmSync(target, { force: true });
|
|
907
|
+
} catch (error) {
|
|
908
|
+
errors.push(error);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (errors.length) {
|
|
912
|
+
keepBackup = true;
|
|
913
|
+
throw new AggregateError(
|
|
914
|
+
[failure, ...errors],
|
|
915
|
+
'Product rollback incomplete; backup retained'
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
throw failure;
|
|
919
|
+
} finally {
|
|
920
|
+
if (!keepBackup) rmSync(transaction, { recursive: true, force: true });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function restoreGroup(site: string, group: Selection, runtime: string, secret: string) {
|
|
925
|
+
if (!existsSync(group.archive)) return false;
|
|
926
|
+
if (
|
|
927
|
+
!lstatSync(group.archive).isFile() ||
|
|
928
|
+
realpathSync(group.archive) !== group.archive ||
|
|
929
|
+
statSync(group.archive).size > maximumPayload
|
|
930
|
+
)
|
|
931
|
+
throw new Error('Invalid encrypted product archive');
|
|
932
|
+
const session = gpgSession(secret);
|
|
933
|
+
try {
|
|
934
|
+
const packed = path.join(session.directory, 'product.gz');
|
|
935
|
+
const status = path.join(session.directory, 'status');
|
|
936
|
+
session.run(['--status-file', status, '--output', packed, '--decrypt', group.archive]);
|
|
937
|
+
if (!readFileSync(status, 'utf8').split('\n').includes('[GNUPG:] GOODMDC'))
|
|
938
|
+
throw new Error('Unauthenticated product archive');
|
|
939
|
+
const staged = path.join(session.directory, 'unpacked');
|
|
940
|
+
mkdirSync(staged);
|
|
941
|
+
const envelope = await unpack(packed, staged, group, runtime);
|
|
942
|
+
validateClosure(path.join(staged, 'files'), group, envelope);
|
|
943
|
+
promoteProductFiles(path.resolve(site, 'build/generated/cache'), staged, envelope);
|
|
944
|
+
for (const entry of envelope.files)
|
|
945
|
+
if (entry.assetHashVersion === assetHashAlgorithmVersion && entry.assetHash)
|
|
946
|
+
rememberVerifiedAsset(site, path.resolve(site, 'build/generated/cache', entry.path), {
|
|
947
|
+
hash: entry.assetHash,
|
|
948
|
+
sha256: entry.sha256,
|
|
949
|
+
size: entry.bytes
|
|
950
|
+
});
|
|
951
|
+
return true;
|
|
952
|
+
} finally {
|
|
953
|
+
session.cleanup();
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export async function transferProductCaches(
|
|
958
|
+
site: string,
|
|
959
|
+
mode: 'save' | 'restore',
|
|
960
|
+
secret: string | undefined,
|
|
961
|
+
options: ProductOptions = {}
|
|
962
|
+
) {
|
|
963
|
+
const { runtime, namespace, groups } = await selections(path.resolve(site), {
|
|
964
|
+
...options,
|
|
965
|
+
restore: mode === 'restore'
|
|
966
|
+
});
|
|
967
|
+
const results: { id: string; ready: boolean; reason?: 'missing-secret' | 'miss' | 'invalid' }[] =
|
|
968
|
+
[];
|
|
969
|
+
const replanCourses = mode === 'restore' && !options.group && !options.kind;
|
|
970
|
+
const transfer = async (group: Selection) => {
|
|
971
|
+
if (!secret) {
|
|
972
|
+
results.push({ id: group.id, ready: false, reason: 'missing-secret' });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const ready =
|
|
977
|
+
mode === 'save'
|
|
978
|
+
? await saveGroup(site, group, runtime, secret)
|
|
979
|
+
: await restoreGroup(site, group, runtime, secret);
|
|
980
|
+
results.push({ id: group.id, ready, ...(!ready ? { reason: 'miss' as const } : {}) });
|
|
981
|
+
} catch {
|
|
982
|
+
results.push({ id: group.id, ready: false, reason: 'invalid' });
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
for (const group of groups) {
|
|
986
|
+
if (!replanCourses || group.kind !== 'course') await transfer(group);
|
|
987
|
+
}
|
|
988
|
+
if (replanCourses) {
|
|
989
|
+
// A fresh runner learns exact immutable course keys from the authenticated
|
|
990
|
+
// global indexes. Recompute after their restore, including any changed index.
|
|
991
|
+
const current = await selections(path.resolve(site), {
|
|
992
|
+
...options,
|
|
993
|
+
restore: true,
|
|
994
|
+
kind: 'course'
|
|
995
|
+
});
|
|
996
|
+
for (const group of current.groups) await transfer(group);
|
|
997
|
+
}
|
|
998
|
+
if (results.some((result) => result.ready)) {
|
|
999
|
+
try {
|
|
1000
|
+
flushAssetHashCache(site);
|
|
1001
|
+
} catch {
|
|
1002
|
+
/* Hash inventory is optional local acceleration. */
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return { schema: 1 as const, namespace, runtime, results };
|
|
1006
|
+
}
|