oxedyne/daimond/verify/hand.mjs
6.4 KiB, 1 run
created by r2519314175:1023, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify/hand.mjs — seal the machine hand's published source. |
| 2 | // |
| 3 | // The served bundle is sealed by `verify/manifest.mjs`, and the claim it carries is strong: the |
| 4 | // wasm rebuilds byte for byte, so the code a browser runs can be shown to BE the public source. |
| 5 | // The hand cannot make that claim and this file does not pretend otherwise. It is a native binary |
| 6 | // built by whoever installs it, on their own toolchain, on their own machine; there is no |
| 7 | // published binary to compare against, and a Rust release build is not bit-identical across |
| 8 | // toolchain versions anyway. |
| 9 | // |
| 10 | // What can be claimed is narrower and still worth having: **this is exactly the source, and this |
| 11 | // is exactly how it is built.** So this writes `verify/hand.json` — a SHA-256 for every file of |
| 12 | // the published hand, one `source` hash over them all, the pinned toolchain, and the one command |
| 13 | // that turns the first into a binary. A reader who clones the mirror can confirm their copy is |
| 14 | // the sealed one (`node verify/check.mjs --hand`) before they build and install a program that |
| 15 | // runs commands on their computer. |
| 16 | // |
| 17 | // node verify/hand.mjs # seal ../daimond-oss/hand |
| 18 | // node verify/hand.mjs --root DIR # some other tree |
| 19 | // node verify/hand.mjs --no-lock # do not regenerate the lock first |
| 20 | // |
| 21 | // **It reads the PUBLIC tree, not this one**, for the same reason the bundle is built there: the |
| 22 | // two differ where it matters. This tree's `hand/Cargo.toml` depends on fe2o3 by path, into a |
| 23 | // working copy that exists on no machine but the author's; the mirror's pins it by git revision. |
| 24 | // Sealing the development copy would seal a manifest nobody outside can build, and every reader's |
| 25 | // check would fail against an honest tree. So the order at release time is: carve, seal the hand |
| 26 | // from what the carve produced, carve again to carry `hand.json` across. |
| 27 | // |
| 28 | // Regenerating `Cargo.lock` is part of sealing rather than part of carving. The lock is the |
| 29 | // mirror's own file — it follows from the mirror's git pin, not from this tree's paths — and it |
| 30 | // records the exact dependency versions this release resolved to, which is the difference between |
| 31 | // "the same source" and "the same build inputs". |
| 32 | |
| 33 | import { readFile, writeFile, stat } from 'node:fs/promises'; |
| 34 | import { join, normalize, resolve } from 'node:path'; |
| 35 | import { fileURLToPath } from 'node:url'; |
| 36 | import { spawnSync } from 'node:child_process'; |
| 37 | import { hashTree, bundleHash } from './lib.mjs'; |
| 38 | |
| 39 | const HERE = normalize(join(fileURLToPath(import.meta.url), '..')); |
| 40 | const DEV = normalize(join(HERE, '..')); |
| 41 | const MIRROR = process.env.MIRROR || normalize(join(DEV, '..', 'daimond-oss')); |
| 42 | const args = process.argv.slice(2); |
| 43 | const opt = (name, def = null) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; }; |
| 44 | const ROOT = resolve(opt('--root', join(MIRROR, 'hand'))); |
| 45 | const NO_LOCK = args.includes('--no-lock'); |
| 46 | |
| 47 | /// Everything under the hand except its build output. `target/` is its own cargo workspace's, so |
| 48 | /// it does not fall under the root `target/` the rest of the repository ignores. |
| 49 | const EXCLUDE_DIRS = ['target/']; |
| 50 | |
| 51 | /// The exact command that turns this source into the binary a browser starts. |
| 52 | /// |
| 53 | /// `--manifest-path`, never `-p`: the hand is its own cargo workspace, so `-p daimond-hand` from |
| 54 | /// the repository root fails with "package not found" — which reads like a missing crate rather |
| 55 | /// than like the workspace boundary it is. |
| 56 | const BUILD = 'cargo build --release --manifest-path hand/Cargo.toml'; |
| 57 | const BINARY = 'hand/target/release/daimond-hand'; |
| 58 | |
| 59 | /// The toolchain the repository pins, which is what `rustup` will select for the build above. |
| 60 | async function toolchain(dir) { |
| 61 | try { |
| 62 | const text = await readFile(join(dir, 'rust-toolchain.toml'), 'utf8'); |
| 63 | const m = /^\s*channel\s*=\s*"([^"]+)"/m.exec(text); |
| 64 | return m ? m[1] : ''; |
| 65 | } catch (e) { return ''; } |
| 66 | } |
| 67 | |
| 68 | if (!await stat(ROOT).then(s => s.isDirectory(), () => false)) { |
| 69 | console.error(`no hand to seal at ${ROOT}`); |
| 70 | console.error(`Carve first — \`node dev/publish.mjs\` — then seal what it produced.`); |
| 71 | process.exit(2); |
| 72 | } |
| 73 | |
| 74 | // The same refusal the carve makes, at the second place it matters. A `path` dependency here |
| 75 | // means the development tree got sealed by mistake, and the resulting hand.json would describe a |
| 76 | // build that resolves only inside one working copy. |
| 77 | { |
| 78 | const man = await readFile(join(ROOT, 'Cargo.toml'), 'utf8'); |
| 79 | const paths = man.split('\n').filter(l => /^\s*[A-Za-z0-9_-]+\s*=.*\bpath\s*=/.test(l)); |
| 80 | if (paths.length) { |
| 81 | console.error(`REFUSING TO SEAL ${ROOT}/Cargo.toml — it depends on a path:`); |
| 82 | for (const l of paths) { console.error(` ${l.trim()}`); } |
| 83 | console.error(`\nThat resolves only inside the tree it was written in, so a reader could not build it.`); |
| 84 | console.error(`Seal the PUBLIC tree: \`node dev/publish.mjs\` rewrites those into git pins.`); |
| 85 | process.exit(2); |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | if (!NO_LOCK) { |
| 90 | const r = spawnSync('cargo', ['generate-lockfile', '--manifest-path', join(ROOT, 'Cargo.toml')], |
| 91 | { encoding: 'utf8' }); |
| 92 | if (r.status !== 0) { |
| 93 | console.error(`could not resolve the hand's dependencies, so there is nothing honest to seal:`); |
| 94 | console.error((r.stderr || '').trim().split('\n').slice(-8).join('\n')); |
| 95 | console.error(`\n(--no-lock seals the lock already there, if that is what you meant.)`); |
| 96 | process.exit(1); |
| 97 | } |
| 98 | console.log(`Cargo.lock resolved against ${ROOT}/Cargo.toml`); |
| 99 | } |
| 100 | |
| 101 | const files = await hashTree(ROOT, { exclude: new Set(), excludeDirs: EXCLUDE_DIRS, excludeSuffixes: [] }); |
| 102 | const source = bundleHash(files); |
| 103 | const sealed = { |
| 104 | algo: 'sha-256', |
| 105 | source, |
| 106 | toolchain: await toolchain(MIRROR), |
| 107 | build: BUILD, |
| 108 | binary: BINARY, |
| 109 | files, |
| 110 | }; |
| 111 | |
| 112 | // Indented and key-sorted, unlike `www/manifest.json`, because this file is READ. A change to the |
| 113 | // hand shows up in a commit diff as the one line that moved, which is the whole point of sealing |
| 114 | // a component nobody can reproduce bit for bit: the record has to be legible to be useful. |
| 115 | await writeFile(join(HERE, 'hand.json'), JSON.stringify(sealed, null, '\t') + '\n'); |
| 116 | |
| 117 | console.log(`hand.json → ${Object.keys(files).length} files, source ${source.slice(0, 16)}…`); |
| 118 | console.log(` toolchain ${sealed.toolchain || '(none pinned)'}`); |
| 119 | console.log(` build ${BUILD}`); |
| 120 | console.log(`\nThis seals the SOURCE and the build recipe. It does not seal a binary, and no`); |
| 121 | console.log(`reproducibility is claimed for one — see "The machine hand" in verify/README.md.`); |
| 122 | console.log(`Carry it across with a second \`node dev/publish.mjs\`, then commit both trees.`); |