Native packaging verifies published digests
Amends ADR 0233: the source SHA256 of an asset is the digest the package published, not one computed from the installed file. Invariants I8, I13, I14.
Context
createNativePackaging returned a sha256 per asset that it computed from the file currently in
node_modules. The package published no digest to compare it with, so the value described
whatever was installed: an addon substituted after installation received its own digest, and a
consumer that compared the bytes it copied with that digest compared the file with itself. The
asset also exposed sourcePath, so a consumer read the addon a second time to copy it, outside
any check, and a build that called createNativePackaging from several places read and hashed
both addons on every call. The only real integrity was the tarball hash in the consumer’s
lockfile.
Decision
- The package publishes the digest. The tracked source layout is
native-layout.json(loader and addon paths, build input only).buildgenerates the publishednative-assets.jsonfrom it atformatVersion: 2, with{ path, size, sha256 }for every Darwin addon present at build time. A release is packed after both addons are in place and refuses a manifest without both digests. An architecture without an entry is not published. - The reader refuses any other format.
formatVersionis a literal2; an older or newer manifest throws before any asset is read, like other damaged metadata. - One read, verified, and a refusal that can be read. Each selected addon is read once, and
the size and SHA256 of the bytes read are compared with the manifest. A difference is the typed
refusal
{ state: 'mismatch', code: 'NATIVE_ASSET_DIGEST_MISMATCH', architecture, expected, actual }, besideunsupportedandmissing:expectedis the published{ size, sha256 }andactualthe installed file’s, so a log tells a truncated copy from a substituted one without a second read. The bytes are hashed even when the size already differs; the read has been paid, and the hash is what identifies the file that was found. A universal request refuses as a whole, at the first differing addon. - The platform is a closed set.
platformisz.enum(['darwin']), the platforms the package publishes addons for. Another name is a type error and a schema refusal that throws; it is not anunsupportedresult, which names only an architecture without an addon. A free string promised platforms that do not exist; adding one is a deliberate widening of the enum. - The verified bytes are the result. A ready asset is
{ outputPath, size, sha256, bytes }(plusarchitecturein the array form);sizeandsha256are the published values andbytesmatch them.sourcePathis not exposed. Embedded delivery gives Bun the same bytes through the plugin’sonLoad, so the compiled executable carries what was checked even if the file changes afterwards. - One call per build. Every call reads and hashes the selected addons; the result (assets and plugin) is the value to reuse. There is no hidden cache: a second call is a second check.
Consequences
A consumer writes asset.bytes and records asset.sha256 in its own archive manifest; it no
longer re-hashes the installed addon. The digest binds the addon to the package Stitchkit built.
It does not authenticate the package: the tarball’s integrity in the consumer’s lockfile and any
signature policy stay with the application. The shape change is a recorded break of an evolving
entrypoint.
Verification: packages/core/tests/native-packaging.test.ts builds the packaging module into a
fixture package with synthetic addons and a published manifest; one changed byte and one extra
byte both refuse with the published and the installed size and SHA256, a platform other than
darwin throws (and is a @ts-expect-error), an unlisted architecture is missing, a formatVersion: 1 manifest throws, and
an embedded build after the file was replaced still carries the verified bytes. On packed
packages, scripts/universal-native-build.ts refuses a manifest without both digests and proves
the tamper refusal with the real addons; the Darwin consumer lane
(darwin-artifacts.mjs) does the same before executing the artifacts.