Skip to content

Commit 2fe9c48

Browse files
committed
fix(release): admit canonical JSR publication
Oliphaunt-Release-Recovery-Of: ae3d29b
1 parent fdb5627 commit 2fe9c48

7 files changed

Lines changed: 674 additions & 79 deletions
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
#!/usr/bin/env bun
2+
3+
import { createHash } from "node:crypto";
4+
import { lstatSync, readFileSync } from "node:fs";
5+
import path from "node:path";
6+
7+
import { compareText } from "./release-graph.mjs";
8+
9+
const TOOL = "jsr-publish-normalization";
10+
const PROOF = /^(?:sha256-)[0-9a-f]{64}$/u;
11+
const SLOPPY_IMPORT_TARGETS = new Map([
12+
[".cjs", [".cts"]],
13+
[".js", [".ts", ".tsx", ".jsx"]],
14+
[".jsx", [".tsx"]],
15+
[".mjs", [".mts"]],
16+
]);
17+
const SOURCE_LOADERS = new Map([
18+
[".cjs", "js"],
19+
[".cts", "ts"],
20+
[".js", "js"],
21+
[".jsx", "jsx"],
22+
[".mjs", "js"],
23+
[".mts", "ts"],
24+
[".ts", "ts"],
25+
[".tsx", "tsx"],
26+
]);
27+
28+
const OLIPHAUNT_SLOPPY_IMPORT_PROOFS = Object.freeze({
29+
"/src/jsr.ts": Object.freeze({
30+
raw: Object.freeze({
31+
checksum: "sha256-9951733bc3dd68542ac51fef522b10121ab782562b650857e102eb42495038a0",
32+
size: 938,
33+
}),
34+
published: Object.freeze({
35+
checksum: "sha256-5deab23099b38b44af86bcafbcdc8a4fb487444880e23b260e74d1c8b6379774",
36+
size: 938,
37+
}),
38+
}),
39+
"/src/query.ts": Object.freeze({
40+
raw: Object.freeze({
41+
checksum: "sha256-6f79d1dd81f65fe64a1e8857fd6a77d50305293484c172c95f6f5af6f994ca22",
42+
size: 19095,
43+
}),
44+
published: Object.freeze({
45+
checksum: "sha256-b25ea0cf76c117e0f681a4c4dd2b506c62b4fb0abacaa0319472bd1c87186191",
46+
size: 19095,
47+
}),
48+
}),
49+
});
50+
51+
// Deno's JSR publisher rewrites Node-style `.js` specifiers when its sloppy
52+
// import resolver selects an included TypeScript source. These records are not
53+
// a general byte-equivalence escape hatch: each one is bound to the complete
54+
// immutable lock/source/carrier/version identity and exact raw/published file
55+
// proofs. Every file outside the exact record remains raw-byte strict.
56+
const JSR_PUBLISH_NORMALIZATIONS = Object.freeze([
57+
Object.freeze({
58+
carrierId: "jsr:@oliphaunt/ts",
59+
lockDigest: "5ee675ab3066cca7df21dd425a5c80fd6c9b9c4b276757fc1aa84e2020761266",
60+
source: Object.freeze({
61+
commit: "9c398f4e5c05f494f9b752a8634e74e0bc11dd19",
62+
tree: "396cf3b10adb1a5b625e66c5ebacf8c3d364b543",
63+
}),
64+
version: "0.1.0",
65+
files: OLIPHAUNT_SLOPPY_IMPORT_PROOFS,
66+
}),
67+
Object.freeze({
68+
carrierId: "jsr:@oliphaunt/ts",
69+
lockDigest: "d1a9f799c1fd40582e7a824ccc6ec6650cba55b8a95592d3d2f626ba33cd6188",
70+
source: Object.freeze({
71+
commit: "ae3d29ba16245e9345a8d337cd17c53f9bf2e853",
72+
tree: "673e8f249d2f51d10997f0036a7e471bf35a388e",
73+
}),
74+
version: "0.1.1",
75+
files: OLIPHAUNT_SLOPPY_IMPORT_PROOFS,
76+
}),
77+
]);
78+
79+
function error(message) {
80+
return new Error(`${TOOL}: ${message}`);
81+
}
82+
83+
function stableJson(value) {
84+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
85+
if (value !== null && typeof value === "object") {
86+
return `{${Object.keys(value).sort(compareText)
87+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
88+
}
89+
return JSON.stringify(value);
90+
}
91+
92+
function fileProof(value, context) {
93+
if (
94+
value === null
95+
|| Array.isArray(value)
96+
|| typeof value !== "object"
97+
|| stableJson(Object.keys(value).sort(compareText)) !== stableJson(["checksum", "size"])
98+
|| !PROOF.test(value.checksum)
99+
|| !Number.isSafeInteger(value.size)
100+
|| value.size < 0
101+
) {
102+
throw error(`${context} must be an exact SHA-256 checksum and non-negative byte size`);
103+
}
104+
return { checksum: value.checksum, size: value.size };
105+
}
106+
107+
function safePublishPath(value, carrier) {
108+
if (
109+
typeof value !== "string"
110+
|| value.length === 0
111+
|| value.includes("\\")
112+
|| path.posix.isAbsolute(value)
113+
) {
114+
throw error(`${carrier.id} has unsafe JSR publish.include path ${JSON.stringify(value)}`);
115+
}
116+
const normalized = path.posix.normalize(value);
117+
if (normalized === ".." || normalized.startsWith("../") || /[*?\[\]{}]/u.test(value)) {
118+
throw error(
119+
`${carrier.id} JSR byte verification requires explicit, repository-relative `
120+
+ `publish.include files; got ${JSON.stringify(value)}`,
121+
);
122+
}
123+
return normalized.replace(/^\.\//u, "");
124+
}
125+
126+
function rawFileProof(file, carrier, relative) {
127+
let stat;
128+
try {
129+
stat = lstatSync(file);
130+
} catch {
131+
throw error(`${carrier.id} JSR publish.include file is unavailable: ${relative}`);
132+
}
133+
if (stat.isSymbolicLink() || !stat.isFile()) {
134+
throw error(`${carrier.id} JSR publish.include entry is not a regular file: ${relative}`);
135+
}
136+
return {
137+
checksum: `sha256-${createHash("sha256").update(readFileSync(file)).digest("hex")}`,
138+
size: stat.size,
139+
};
140+
}
141+
142+
function matchingNormalization(lock, carrier) {
143+
const matches = JSR_PUBLISH_NORMALIZATIONS.filter((entry) =>
144+
entry.lockDigest === lock?.lockDigest
145+
&& entry.source.commit === lock?.source?.commit
146+
&& entry.source.tree === lock?.source?.tree
147+
&& entry.carrierId === carrier?.id
148+
&& entry.version === carrier?.version);
149+
if (matches.length > 1) {
150+
throw error(`${carrier.id} has duplicate exact publish normalization records`);
151+
}
152+
return matches[0] ?? null;
153+
}
154+
155+
function sloppyImportCandidates(specifier) {
156+
const extension = path.posix.extname(specifier);
157+
const replacements = SLOPPY_IMPORT_TARGETS.get(extension) ?? [];
158+
const base = specifier.slice(0, specifier.length - extension.length);
159+
return replacements.map((replacement) => `${base}${replacement}`);
160+
}
161+
162+
function rewriteProneFiles(directory, rawFiles, carrier) {
163+
const included = new Set(Object.keys(rawFiles));
164+
const prone = [];
165+
for (const name of [...included].sort(compareText)) {
166+
const loader = SOURCE_LOADERS.get(path.posix.extname(name));
167+
if (loader === undefined) continue;
168+
const relative = name.slice(1);
169+
const text = readFileSync(path.join(directory, ...relative.split("/")), "utf8");
170+
let imports;
171+
try {
172+
imports = new Bun.Transpiler({ loader }).scanImports(text);
173+
} catch (cause) {
174+
throw error(
175+
`${carrier.id} cannot parse included JSR source ${relative}: `
176+
+ (cause instanceof Error ? cause.message : String(cause)),
177+
);
178+
}
179+
const resolvesToIncludedSource = imports.some(({ path: specifier }) => {
180+
if (!specifier.startsWith(".")) return false;
181+
const base = path.posix.dirname(relative);
182+
const literal = path.posix.normalize(path.posix.join(base, specifier));
183+
if (literal !== ".." && !literal.startsWith("../") && included.has(`/${literal}`)) {
184+
return false;
185+
}
186+
return sloppyImportCandidates(specifier).some((candidate) => {
187+
const resolved = path.posix.normalize(path.posix.join(base, candidate));
188+
return resolved !== ".." && !resolved.startsWith("../") && included.has(`/${resolved}`);
189+
});
190+
});
191+
if (resolvesToIncludedSource) prone.push(name);
192+
}
193+
return prone;
194+
}
195+
196+
function admittedNormalization(lock, carrier, rawFiles, proneFiles) {
197+
const normalization = matchingNormalization(lock, carrier);
198+
if (proneFiles.length === 0) {
199+
if (normalization !== null && Object.keys(normalization.files).length > 0) {
200+
throw error(`${carrier.id} exact publish normalization record no longer describes rewrite-prone source`);
201+
}
202+
return null;
203+
}
204+
if (normalization === null) {
205+
throw error(
206+
`${carrier.id}@${carrier.version} contains Deno/JSR rewrite-prone local JavaScript specifiers `
207+
+ "without an exact pre-recorded publish normalization for this lock/source/carrier/version",
208+
);
209+
}
210+
const recordedFiles = Object.keys(normalization.files).sort(compareText);
211+
if (stableJson(recordedFiles) !== stableJson(proneFiles)) {
212+
throw error(
213+
`${carrier.id} exact publish normalization must cover precisely the rewrite-prone files: `
214+
+ `expected=${JSON.stringify(proneFiles)}, recorded=${JSON.stringify(recordedFiles)}`,
215+
);
216+
}
217+
for (const name of recordedFiles) {
218+
const record = normalization.files[name];
219+
const expectedRaw = fileProof(record?.raw, `${carrier.id} normalization ${name} raw proof`);
220+
fileProof(record?.published, `${carrier.id} normalization ${name} published proof`);
221+
if (stableJson(rawFiles[name]) !== stableJson(expectedRaw)) {
222+
throw error(
223+
`${carrier.id} frozen JSR source no longer matches its exact publish-time normalization record for ${name}`,
224+
);
225+
}
226+
}
227+
return normalization;
228+
}
229+
230+
export function jsrPublishedFileProof(lock, carrier, name, raw) {
231+
const normalization = matchingNormalization(lock, carrier);
232+
const expected = normalization?.files[name];
233+
if (expected === undefined) return raw;
234+
const expectedRaw = fileProof(expected.raw, `${carrier.id} normalization ${name} raw proof`);
235+
if (stableJson(raw) !== stableJson(expectedRaw)) {
236+
throw error(
237+
`${carrier.id} frozen JSR source no longer matches its exact publish-time normalization record for ${name}`,
238+
);
239+
}
240+
return fileProof(expected.published, `${carrier.id} normalization ${name} published proof`);
241+
}
242+
243+
export function expectedJsrPublishedManifest({ carrier, directory, lock }) {
244+
let config;
245+
try {
246+
config = JSON.parse(readFileSync(path.join(directory, "jsr.json"), "utf8"));
247+
} catch (cause) {
248+
throw error(`${carrier.id} cannot read strict jsr.json: ${cause.message}`);
249+
}
250+
const include = config?.publish?.include;
251+
if (!Array.isArray(include) || include.length === 0) {
252+
throw error(`${carrier.id} jsr.json must declare a nonempty explicit publish.include list`);
253+
}
254+
if (config.name !== carrier.name || config.version !== carrier.version) {
255+
throw error(`${carrier.id} jsr.json identity does not match the frozen carrier`);
256+
}
257+
const rawFiles = {};
258+
for (const value of include) {
259+
const relative = safePublishPath(value, carrier);
260+
const name = `/${relative}`;
261+
if (Object.hasOwn(rawFiles, name)) {
262+
throw error(`${carrier.id} jsr.json publish.include repeats ${relative}`);
263+
}
264+
const file = path.join(directory, ...relative.split("/"));
265+
rawFiles[name] = rawFileProof(file, carrier, relative);
266+
}
267+
const canonicalRawFiles = Object.fromEntries(
268+
Object.entries(rawFiles).sort(([left], [right]) => compareText(left, right)),
269+
);
270+
const proneFiles = rewriteProneFiles(directory, canonicalRawFiles, carrier);
271+
const normalization = admittedNormalization(lock, carrier, canonicalRawFiles, proneFiles);
272+
return Object.fromEntries(Object.entries(canonicalRawFiles).map(([name, raw]) => [
273+
name,
274+
normalization?.files[name] === undefined
275+
? raw
276+
: jsrPublishedFileProof(lock, carrier, name, raw),
277+
]));
278+
}

0 commit comments

Comments
 (0)