From 0adf56217715f1443a5988eda1914f750bc529c8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 30 Aug 2026 11:07:02 +0200 Subject: [PATCH] fix(release): align public Snap verifier with the shipped snap layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish-snap verifier had never run against a real release and encoded three stale expectations that the tag build's own validators do not share: - it required the app under usr/lib/iptvnator inside the snap, while Electron Builder's snap target ships the app at the snap root (/iptvnator.bin, /resources/**) — the layout the packaged smoke tests exercise; - it validated the source archive's runtime manifest with the raw source-build validator, but the archive carries the STAGED manifest (origin "vendored-lgpl" + sourceBuildOrigin) written by stage-runtime.mjs; the staged envelope is now checked explicitly and the remaining fields still go through the shared validator via an origin projection; - it deep-equaled the snap's bundled sourceRuntime against the archive manifest, but the snap bundles the builder view (no staging envelope); the binding now projects the envelope away first. Verified end-to-end in a Linux container against the real v0.23.0 release assets: release-snap-assets.cjs verify now passes and emits the sealed snapshot receipt. Regression tests cover the legacy usr/lib layout and staged-envelope mismatches. Co-Authored-By: Claude Fable 5 --- docs/architecture/embedded-mpv-native.md | 5 +- tools/embedded-mpv/README.md | 5 +- tools/packaging/release-snap-assets.test.mjs | 91 ++++++++++++++++++- .../packaging/release-snap-source-binding.cjs | 63 +++++++++---- .../validate-snap-release-boundary.mjs | 9 +- 5 files changed, 138 insertions(+), 35 deletions(-) diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 43d0a4e28..fd7615417 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -1058,8 +1058,9 @@ hashes, the exact VCS-free libplacebo tree inventory/digest, and byte-identical tooling from the released tag. Checkout and both artifact-transfer actions use full pinned commits, and checkout sets `persist-credentials: false`. The bounded SquashFS preflight and extraction then require the canonical -`/usr/lib/iptvnator` layout and reuse the static package validator for every -selected Snap. The public-release verifier separately reapplies the exact +snap-root layout (Electron app at `/`, so `/iptvnator.bin` and +`/resources/**` — the layout Electron Builder's Snap target produces) and +reuse the static package validator for every selected Snap. The public-release verifier separately reapplies the exact strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates the extracted `resources/app.asar`; any archived `electron-backend/native/**` entry fails before Store publication. The bounded diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 558485eb3..033b0c661 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -395,8 +395,9 @@ inventory/digest, released tooling, and runtime manifest. Checkout and both artifact-transfer actions use full pinned commits, and checkout does not persist its repository credential. The verifier bounds source members, the archive, SquashFS listing, extracted size, entry count, -command time, and job time; every Snap must use the canonical -`/usr/lib/iptvnator` layout and pass the existing static package validator. +command time, and job time; every Snap must use the canonical snap-root +layout (Electron app at `/`, so `/iptvnator.bin` and `/resources/**`) and +pass the existing static package validator. The public-release boundary also reapplies the exact strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates the extracted `resources/app.asar`, rejecting any archived diff --git a/tools/packaging/release-snap-assets.test.mjs b/tools/packaging/release-snap-assets.test.mjs index 6619a9687..2bd76ac2d 100644 --- a/tools/packaging/release-snap-assets.test.mjs +++ b/tools/packaging/release-snap-assets.test.mjs @@ -187,6 +187,9 @@ function createSourceBindingFixture() { const repositoryRevision = 'a'.repeat(40); const archiveSha256 = 'b'.repeat(64); const sourceRuntime = { + origin: 'vendored-lgpl', + sourceBuildOrigin: 'vendored-lgpl-source-build', + stagedAt: '2026-07-18T01:00:00.000Z', generatedAt: '2026-07-18T00:00:00.000Z', packages: { ffmpeg: { @@ -238,6 +241,14 @@ function createSourceBindingFixture() { archiveFiles: sourceIndex.archives, compliance: sourceCompliance, }; + // The snap bundles the from-source builder view of the staged manifest: + // origin restored, staging envelope (sourceBuildOrigin, stagedAt) absent. + const snapSourceRuntime = { + ...sourceRuntime, + origin: 'vendored-lgpl-source-build', + }; + delete snapSourceRuntime.sourceBuildOrigin; + delete snapSourceRuntime.stagedAt; const snapPayloads = { 'IPTVnator-amd64.snap': { assetName: 'IPTVnator-amd64.snap', @@ -255,7 +266,7 @@ function createSourceBindingFixture() { sha256: 'd'.repeat(64), repositoryRevision, }, - sourceRuntime, + sourceRuntime: snapSourceRuntime, }, }, 'IPTVnator-arm64.snap': { @@ -316,6 +327,37 @@ test('binds source metadata and checksums to every selected Snap before publicat inspectedSnaps, selection.snapAssets.map(({ name }) => name) ); + + for (const [field, value] of [ + ['origin', 'vendored-lgpl-source-build'], + ['sourceBuildOrigin', undefined], + ]) { + const stagedMismatch = structuredClone(fixture.sourceInspection); + if (value === undefined) { + delete stagedMismatch.sourceRuntime[field]; + } else { + stagedMismatch.sourceRuntime[field] = value; + } + assert.throws( + () => + helper.verifySnapReleaseCorrespondence( + selection, + temporaryRoot, + { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => stagedMismatch, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + } + ), + field === 'origin' + ? /origin must be "vendored-lgpl"/ + : /sourceBuildOrigin must be "vendored-lgpl-source-build"/ + ); + } }); test('publishes only a stable verified asset snapshot with an exact receipt', async (t) => { @@ -1180,7 +1222,7 @@ test('hashes the final source archive bytes and reads the exact packaged Snap bi fs.unlinkSync(oversizedMemberPath); const snapSourceRoot = path.join(temporaryRoot, 'snap-source'); - const appRoot = path.join(snapSourceRoot, 'usr', 'lib', 'iptvnator'); + const appRoot = snapSourceRoot; const nativeRoot = path.join( appRoot, 'resources', @@ -1193,10 +1235,16 @@ test('hashes the final source archive bytes and reads the exact packaged Snap bi electronHeader.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); electronHeader.writeUInt16LE(62, 18); fs.writeFileSync(path.join(appRoot, 'iptvnator.bin'), electronHeader); + const packagedSourceRuntime = { + ...sourceRuntime, + origin: 'vendored-lgpl-source-build', + }; + delete packagedSourceRuntime.sourceBuildOrigin; + delete packagedSourceRuntime.stagedAt; const packagedManifest = { ...fixture.snapPayloads['IPTVnator-amd64.snap'].manifest, sourceArchive, - sourceRuntime, + sourceRuntime: packagedSourceRuntime, }; fs.writeFileSync( path.join(nativeRoot, 'embedded-mpv-runtime.json'), @@ -1326,7 +1374,7 @@ test('hashes the final source archive bytes and reads the exact packaged Snap bi assert.equal(staticValidationCalls.length, 1); assert.ok( staticValidationCalls[0].resourceDirectory.endsWith( - ['payload', 'usr', 'lib', 'iptvnator', 'resources'].join(path.sep) + ['payload', 'resources'].join(path.sep) ) ); assert.deepEqual(staticValidationCalls[0].options, { @@ -1484,6 +1532,41 @@ test('hashes the final source archive bytes and reads the exact packaged Snap bi /canonical frame-copy manifest/i ); + const legacySourceRoot = path.join(temporaryRoot, 'legacy-snap-source'); + const legacyAppRoot = path.join( + legacySourceRoot, + 'usr', + 'lib', + 'iptvnator' + ); + fs.cpSync(appRoot, legacyAppRoot, { recursive: true }); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(legacySourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + throw new Error( + 'Static validation must not inspect a legacy usr/lib layout.' + ); + }, + } + ), + /canonical frame-copy manifest/i + ); + const manifestPath = path.join(nativeRoot, 'embedded-mpv-runtime.json'); fs.truncateSync(manifestPath, 16 * 1024 * 1024 + 1); let oversizedStaticValidationCalls = 0; diff --git a/tools/packaging/release-snap-source-binding.cjs b/tools/packaging/release-snap-source-binding.cjs index 0bf6f4c54..2c5e89f8b 100644 --- a/tools/packaging/release-snap-source-binding.cjs +++ b/tools/packaging/release-snap-source-binding.cjs @@ -41,6 +41,12 @@ const SHA256_PATTERN = /^[a-f0-9]{64}$/; const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; const FRAME_COPY_MANIFEST_NAME = 'embedded-mpv-runtime.json'; const FRAME_COPY_UNAVAILABLE_MARKER_NAME = 'embedded-mpv-unavailable.txt'; +// The archive carries the STAGED runtime manifest (`stage-runtime.mjs`): +// origin "vendored-lgpl", with the from-source builder's origin preserved in +// sourceBuildOrigin. The shared source-build validator still checks every +// other field through the projected origin below. +const STAGED_RUNTIME_ORIGIN = 'vendored-lgpl'; +const SOURCE_BUILD_RUNTIME_ORIGIN = 'vendored-lgpl-source-build'; const SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION = 1; const SNAP_RELEASE_BOUNDARY_HELPER_PATH = path.join( __dirname, @@ -1116,9 +1122,6 @@ function inspectSnapPayload( const payloads = collectSnapNativePayloads(extractionRoot); const canonicalNativeDirectory = path.join( extractionRoot, - 'usr', - 'lib', - 'iptvnator', 'resources', 'app.asar.unpacked', 'electron-backend', @@ -1148,20 +1151,8 @@ function inspectSnapPayload( ); } const payloadPath = canonicalPayloads[0]; - const resourcesDirectory = path.join( - extractionRoot, - 'usr', - 'lib', - 'iptvnator', - 'resources' - ); - const electronPath = path.join( - extractionRoot, - 'usr', - 'lib', - 'iptvnator', - 'iptvnator.bin' - ); + const resourcesDirectory = path.join(extractionRoot, 'resources'); + const electronPath = path.join(extractionRoot, 'iptvnator.bin'); const electronStat = fs.lstatSync(electronPath); if ( !electronStat.isFile() || @@ -1562,7 +1553,26 @@ function verifySnapReleaseSourceBinding( sourceIndex, sourceRuntime, } = sourceInspection; - const runtimeErrors = validateRuntimeManifest(sourceRuntime); + const stagedRuntimeErrors = []; + if (sourceRuntime?.origin !== STAGED_RUNTIME_ORIGIN) { + stagedRuntimeErrors.push( + `Linux runtime manifest origin must be "${STAGED_RUNTIME_ORIGIN}".` + ); + } + if (sourceRuntime?.sourceBuildOrigin !== SOURCE_BUILD_RUNTIME_ORIGIN) { + stagedRuntimeErrors.push( + `Linux runtime manifest sourceBuildOrigin must be "${SOURCE_BUILD_RUNTIME_ORIGIN}".` + ); + } + const validatorErrors = isObject(sourceRuntime) + ? validateRuntimeManifest({ + ...sourceRuntime, + origin: SOURCE_BUILD_RUNTIME_ORIGIN, + }) + : validateRuntimeManifest(sourceRuntime); + const runtimeErrors = Array.isArray(validatorErrors) + ? [...stagedRuntimeErrors, ...validatorErrors] + : validatorErrors; if (!Array.isArray(runtimeErrors) || runtimeErrors.length > 0) { throw new Error( `Source archive runtime manifest is invalid${ @@ -1742,7 +1752,22 @@ function verifySnapReleaseSourceBinding( `x64 Snap ${payload.assetName} has an invalid frame-copy source archive binding.` ); } - if (!isDeepStrictEqual(manifest.sourceRuntime, sourceRuntime)) { + // The snap bundles the from-source builder view of the runtime + // manifest, while the archive carries its staged envelope + // (origin renamed to "vendored-lgpl", sourceBuildOrigin and + // stagedAt added). Project the envelope away before binding. + const expectedSnapSourceRuntime = { + ...sourceRuntime, + origin: SOURCE_BUILD_RUNTIME_ORIGIN, + }; + delete expectedSnapSourceRuntime.sourceBuildOrigin; + delete expectedSnapSourceRuntime.stagedAt; + if ( + !isDeepStrictEqual( + manifest.sourceRuntime, + expectedSnapSourceRuntime + ) + ) { throw new Error( `Snap source runtime does not match source archive: ${payload.assetName}` ); diff --git a/tools/packaging/validate-snap-release-boundary.mjs b/tools/packaging/validate-snap-release-boundary.mjs index 2a98df1c8..c5d0c7697 100644 --- a/tools/packaging/validate-snap-release-boundary.mjs +++ b/tools/packaging/validate-snap-release-boundary.mjs @@ -18,14 +18,7 @@ export function validateExtractedSnapReleaseBoundary( { asarListPackage = listAsarPackageEntries } = {} ) { const errors = [...validateExtractedSnapMetadata(extractionRoot)]; - const asarPath = path.join( - extractionRoot, - 'usr', - 'lib', - 'iptvnator', - 'resources', - 'app.asar' - ); + const asarPath = path.join(extractionRoot, 'resources', 'app.asar'); let asarStat; try { asarStat = fs.lstatSync(asarPath);