From 041d32162666411eaa8a4d6f6f7d536630203691 Mon Sep 17 00:00:00 2001 From: McAtk <16798627+atk0309@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:14:03 -0700 Subject: [PATCH 1/3] Prepare verified portable release assets without unused native image optimization Identical tree to locally validated 4669f2a0feeb8381f5bdc76629113003b4e6726d. References #119. --- .gitattributes | 3 + .github/workflows/desktop-preview.yml | 51 ++- .prettierignore | 2 + docs/architecture.md | 13 + docs/release-candidate.md | 100 +++++ docs/solo-installation.md | 14 + next.config.mjs | 16 + scripts/desktop/acceptance-windows.ps1 | 7 + scripts/desktop/archive.ps1 | 15 +- scripts/desktop/build.mjs | 4 +- scripts/desktop/distribution-policy.mjs | 221 +++++++++ scripts/desktop/distribution.mjs | 203 +++++++++ scripts/desktop/inventory.mjs | 14 +- scripts/desktop/legal/README.md | 14 + scripts/desktop/legal/dotenv-16.3.1-LICENSE | 23 + .../legal/dotenv-expand-10.0.0-LICENSE | 24 + scripts/desktop/legal/provenance.json | 38 ++ scripts/desktop/licenses.mjs | 288 ++++++++++++ scripts/desktop/package.mjs | 84 +++- scripts/desktop/supplemental-licenses.mjs | 77 ++++ tests/desktop/acceptance.mjs | 31 ++ tests/desktop/archive-policy.test.mjs | 265 +++++++++++ tests/desktop/browser-checks.mjs | 5 + tests/desktop/distribution-acceptance.mjs | 419 ++++++++++++++++++ tests/desktop/distribution.test.mjs | 158 +++++++ tests/desktop/licenses.test.mjs | 257 +++++++++++ tests/desktop/portable-policy.test.mjs | 128 ++++++ tests/desktop/supplemental-licenses.test.mjs | 93 ++++ tests/desktop/windows-inventory.test.mjs | 49 ++ 29 files changed, 2589 insertions(+), 27 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/release-candidate.md create mode 100644 scripts/desktop/distribution-policy.mjs create mode 100644 scripts/desktop/distribution.mjs create mode 100644 scripts/desktop/legal/README.md create mode 100644 scripts/desktop/legal/dotenv-16.3.1-LICENSE create mode 100644 scripts/desktop/legal/dotenv-expand-10.0.0-LICENSE create mode 100644 scripts/desktop/legal/provenance.json create mode 100644 scripts/desktop/licenses.mjs create mode 100644 scripts/desktop/supplemental-licenses.mjs create mode 100644 tests/desktop/archive-policy.test.mjs create mode 100644 tests/desktop/distribution-acceptance.mjs create mode 100644 tests/desktop/distribution.test.mjs create mode 100644 tests/desktop/licenses.test.mjs create mode 100644 tests/desktop/portable-policy.test.mjs create mode 100644 tests/desktop/supplemental-licenses.test.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..11af9fd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Exact upstream license bytes are pinned in provenance.json. +scripts/desktop/legal/** -text diff --git a/.github/workflows/desktop-preview.yml b/.github/workflows/desktop-preview.yml index bb1f053..a5089ca 100644 --- a/.github/workflows/desktop-preview.yml +++ b/.github/workflows/desktop-preview.yml @@ -4,6 +4,11 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + release_version: + description: 'Optional intended stable numeric version; prepares an unpublished candidate' + required: false + type: string permissions: contents: read @@ -51,16 +56,29 @@ jobs: shell: powershell run: | ./scripts/desktop/acceptance-windows.ps1 -Repository $env:GITHUB_WORKSPACE -Node (Get-Command node.exe).Source -TestsOnly + - name: Validate requested version and packaging policies + env: + RC_VERSION: ${{ inputs.release_version }} + run: | + node -e "if(process.env.RC_VERSION && !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(process.env.RC_VERSION)) process.exit(1)" + node --test tests/desktop/distribution.test.mjs tests/desktop/licenses.test.mjs tests/desktop/supplemental-licenses.test.mjs tests/desktop/portable-policy.test.mjs tests/desktop/archive-policy.test.mjs - name: Build from clean committed source run: node scripts/desktop/build.mjs - name: Package native runtime with pinned Node archive verification - run: node scripts/desktop/package.mjs ci-${{ github.sha }} + env: + RC_VERSION: ${{ inputs.release_version }} + run: node -e "require('node:child_process').execFileSync(process.execPath,['scripts/desktop/package.mjs',process.env.RC_VERSION || 'ci-' + process.env.GITHUB_SHA],{stdio:'inherit'})" + - name: Independently inspect the actual archive and required notice bytes + run: node scripts/desktop/distribution-policy.mjs check build/desktop/${{ matrix.platform }} ${{ matrix.platform }} - name: Install acceptance browser on Linux if: runner.os == 'Linux' run: pnpm exec playwright install --with-deps chromium - name: Install acceptance browser on Windows if: runner.os == 'Windows' run: pnpm exec playwright install chromium + - name: Linux download-arrangement acceptance + if: runner.os == 'Linux' + run: node tests/desktop/distribution-acceptance.mjs build/desktop/linux-x64 - name: Linux installer and packaged app acceptance if: runner.os == 'Linux' run: node tests/desktop/acceptance.mjs build/desktop/linux-x64 @@ -78,4 +96,33 @@ jobs: name: examify-solo-${{ matrix.platform }}-${{ github.sha }} path: build/desktop/${{ matrix.platform }}/ if-no-files-found: error - retention-days: 7 + retention-days: ${{ inputs.release_version && 90 || 7 }} + + assemble-candidate: + name: Assemble unpublished matched candidate + if: github.event_name == 'workflow_dispatch' && inputs.release_version != '' + needs: package-and-accept + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22.22.2 + - uses: actions/download-artifact@v8 + with: + name: examify-solo-linux-x64-${{ github.sha }} + path: build/desktop/linux-x64 + - uses: actions/download-artifact@v8 + with: + name: examify-solo-win32-x64-${{ github.sha }} + path: build/desktop/win32-x64 + - name: Assemble exact tested platform assets (never publish or tag) + run: node scripts/desktop/distribution.mjs assemble build/desktop build/release-candidate + - uses: actions/upload-artifact@v7 + with: + name: examify-candidate-${{ inputs.release_version }}-${{ github.sha }} + path: build/release-candidate/ + if-no-files-found: error + retention-days: 90 diff --git a/.prettierignore b/.prettierignore index f9fc289..03f8ee4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ test-results pnpm-lock.yaml src/lib/db/migrations content/generated/**/*.json +# Preserve exact upstream license bytes; provenance hashes verify them. +scripts/desktop/legal/** diff --git a/docs/architecture.md b/docs/architecture.md index 276314d..fb087e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1381,3 +1381,16 @@ SHA-256 pins authenticate bytes relative to the trusted installer distribution; they are not a publisher-signature system. Windows directory durability cannot be established using these ordinary Node APIs; process-crash acceptance must not be reported as physical-power-loss proof. + +### Portable-only image optimization exclusion + +`scripts/desktop/build.mjs` selects `EXAMIFY_PORTABLE_BUILD=1` only for its build +subprocess. The default hosted configuration remains unchanged. Portable builds +serialize `images.unoptimized=true` and exclude Sharp/@img from route and +`next-server` traces. Packaging verifies actual serialized config, package paths, +module links and native binaries; a post-archive gate independently extracts and +rechecks them before CI can upload binaries. A metadata label alone is insufficient. +The current application uses neither Sharp nor Next Image. PDF/text upload storage +and provider/optional pdftoppm source processing are independent. Reintroducing an +image/native dependency requires explicit portable behavior and notice/source +review; do not weaken the gate to make a package build pass. diff --git a/docs/release-candidate.md b/docs/release-candidate.md new file mode 100644 index 0000000..4e4315c --- /dev/null +++ b/docs/release-candidate.md @@ -0,0 +1,100 @@ +# Windows/Linux release candidate + +This procedure prepares evidence; it does not approve tagging or publication. +Issue [#119](https://github.com/atk0309/project_Examify/issues/119) stays open until +its release gates are satisfied. Windows/Linux x64 only, with no macOS/ARM claim. + +## Portable image policy + +The desktop builder sets `EXAMIFY_PORTABLE_BUILD=1` for its build subprocess only. +This disables Next's unused image optimization endpoint and excludes Sharp/@img +from standalone server and route traces. The application has no direct Sharp or +Next Image dependency: uploads retain validated PDF/text bytes, optional PDF page +rasterization uses external `pdftoppm`, and AI source images are read as bytes. +Hosted/default builds retain their existing configuration and image optimizer. +Portable builds do not provide automatic image resizing, compression or format +conversion. Future use of Next Image needs an explicit portable fallback/review. + +Packaging must verify the serialized standalone server really has optimization +disabled, and inspect actual files, runtime links and package/native inventories. +The distribution gate independently extracts the completed archive and repeats +those checks, comparing internal identity/policy to its hashed sidecars. It also +checks exact supplemental notice bytes. CI cannot upload the app archive if this +gate or native acceptance fails. There is no environment/input bypass. + +The former libvips source/replacement gate is inapplicable only when the archive +inspection proves those unused native image packages are absent. Reintroducing +them or another unreviewed native library fails closed and requires a new notice, +source/replacement review. SQLite's native module and the private Node runtime +remain; their licenses/notices are retained. This is technical evidence, not a +complete SBOM, independent publisher authentication or legal certification. + +## Freeze and preserve one candidate + +1. Choose a reviewed clean source commit. Record its full SHA and successful full + CI/CodeQL run URLs for that exact commit, not a nearby PR merge-ref badge +2. Manually run **Solo desktop preview** with `release_version` set to the intended + stable numeric version, for example `0.1.0`. Candidate status belongs in the + evidence record: safe upgrades deliberately do not order `ci-*` or prerelease + labels. Never reuse a version previously used for a legacy preview +3. Both native package/browser/upgrade jobs and candidate assembly must pass. + The combined set contains Windows CMD/PowerShell/zip, Linux shell/tar.gz, + matching archive checksums, platform identities/runtime policies, complete + checksum lists and `release-set.json`. Source, lockfile, pinned Node and all + installer/archive bytes are recorded. Never replace tested bytes silently +4. Preserve the complete unmodified bundle in durable maintainer-controlled + storage before expiry. Actions retention is **90 days** for candidates, seven + for previews, and is not permanent hosting. Record run/artifact URLs, expiry + and durable storage privately; do not publish private paths or credentials +5. Verify `SHA256SUMS` after download (`sha256sum --check SHA256SUMS` on Linux; + compare `Get-FileHash -Algorithm SHA256` on Windows). Retain evidence separately + from the download location. Changed bytes require a new candidate/test record + +The local integrity tools never tag, publish or overwrite an existing candidate: + +```sh +node scripts/desktop/distribution-policy.mjs check build/desktop/linux-x64 linux-x64 +node scripts/desktop/distribution-policy.mjs check build/desktop/win32-x64 win32-x64 +node scripts/desktop/distribution.mjs assemble build/desktop build/release-candidate +``` + +Run each native archive policy check on its own platform. Assembly relies on both +already successful native jobs; it checks their shared version/source/lockfile. +It creates an unapproved candidate, not a publication authorization. + +## Remaining consumer, security and trust gates + +CI targets Ubuntu 22.04 and Windows Server 2022. These are automation environments, +not proven minimum consumer OS/browser requirements. Record actual OS edition, +version/build, architecture, filesystem, desktop and browser version for clean +ordinary-user consumer tests without development tools installed: + +- Actual downloaded installer/bootstrap paths, offline archive installation and + corrupt/wrong-version rejection. The automated local mirror changes only its + private installer copies' HTTPS origin; it does not prove GitHub TLS/CDN or + reputation behavior. Production installers retain fixed HTTPS-only URLs +- Real browser launch, Start-menu/Linux application shortcut and shortcut relaunch; + browser callbacks and `--no-shortcut` automation are not substitutes +- Deterministic sample/resume, repeated authoring using approved offline fixtures, + restart/crash recovery and upgrade/full-folder restore. Process interruption + does not establish physical-power-loss durability +- Final private-data/secret inventory, retained license notices and remaining + mapping/review notes in `THIRD-PARTY-LICENSES.json`. The exact missing dotenv BSD + notices are retained using hash-pinned upstream provenance; no source offer or + unrelated native-license obligation is asserted for excluded libraries +- Current locked production audit and native/embedded security triage. Record date, + tool/source identity and findings; a failed Dependabot update alone is not a + vulnerability, and package-manager audit is not complete binary analysis + +Artifacts remain unsigned. Checksums establish consistency with the chosen +installer; an attacker replacing both installer and archive can replace hashes. +Before publication, the maintainer must approve unsigned distribution or a +separate signing plan. Record real browser download, Mark-of-the-Web, SmartScreen +and PowerShell behavior. The CMD installer's existing process-local execution +policy option is not a signature or permission to circumvent organizational +policy. Stop at OS/browser security warnings; do not disable system protection. + +Publication requires separate approval after exact asset hashes, CI links, +consumer results and trust decision are recorded. Publish the exact tested asset +set under its pinned version, then verify remotely downloaded hashes/bootstrap. +Leave #119 open for any uncompleted manual or published-route checks. diff --git a/docs/solo-installation.md b/docs/solo-installation.md index b411d5e..ac62443 100644 --- a/docs/solo-installation.md +++ b/docs/solo-installation.md @@ -206,3 +206,17 @@ The `.examify-operations` databases are coordination files, outside study state. Do not remove them while an operation is active. Stale process markers after a crash do not require manual deletion. The household `examify:data` backup command does not capture this complete solo layout; use the full-folder backup above. + +## Portable image behavior and release evidence + +Portable Windows/Linux builds omit Next's unused automatic image optimizer and +its Sharp/libvips dependencies. Existing practice, raw PDF/text uploads and AI +source processing retain their separate paths; optional PDF rasterization still +uses external `pdftoppm`. Portable installs do not offer automatic image resizing, +compression or format conversion. Hosted/default builds are unchanged. + +Portable artifacts remain previews until the [release gate](release-candidate.md) +is completed. CI runner coverage does not establish minimum consumer OS/browser +support. Actual shortcut/browser opening and download-warning checks are still +required. Installers are unsigned; checksums do not independently prove publisher +identity. Never disable system security to run an installer. diff --git a/next.config.mjs b/next.config.mjs index 7af9bcc..d500c9c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,12 +1,28 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +const portable = process.env.EXAMIFY_PORTABLE_BUILD === '1'; const projectRoot = path.dirname(fileURLToPath(import.meta.url)); /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, output: 'standalone', + // The desktop builder alone selects this mode. Hosted/default builds keep + // Next's normal image optimizer and tracing behavior. + ...(portable + ? { + images: { unoptimized: true }, + outputFileTracingExcludes: { + '**/*': [ + 'node_modules/sharp/**/*', + 'node_modules/@img/**/*', + 'node_modules/.pnpm/sharp@*/**/*', + 'node_modules/.pnpm/@img+*/**/*', + ], + }, + } + : {}), outputFileTracingIncludes: { '/*': ['node_modules/better-sqlite3/**/*'], }, diff --git a/scripts/desktop/acceptance-windows.ps1 b/scripts/desktop/acceptance-windows.ps1 index ea4faf7..2b00031 100644 --- a/scripts/desktop/acceptance-windows.ps1 +++ b/scripts/desktop/acceptance-windows.ps1 @@ -36,6 +36,7 @@ try { $WorkerTests = Join-Path $Repository 'tests/desktop/worker-lock.test.mjs' $RelocatedTests = Join-Path $Repository 'tests/desktop/relocated-helpers.test.mjs' $UpgradeAcceptance = Join-Path $Repository 'tests/desktop/upgrade-acceptance.mjs' + $DistributionAcceptance = Join-Path $Repository 'tests/desktop/distribution-acceptance.mjs' $InventoryTests = Join-Path $Repository 'tests/desktop/windows-inventory.test.mjs' $UserTemp = Join-Path $Root 'UserTemp' $RunAcceptance = if ($TestsOnly) { '$false' } else { '$true' } @@ -59,6 +60,12 @@ try { & $(Quote $Node) --test $(Quote $UnitTests) $(Quote $InventoryTests) $(Quote $UpgradeTests) $(Quote $WorkerTests) $(Quote $RelocatedTests) *> $(Quote $Result) `$Code = `$LASTEXITCODE `$ErrorActionPreference = 'Stop' + if (`$Code -eq 0 -and $RunAcceptance) { + `$ErrorActionPreference = 'Continue' + & $(Quote $Node) $(Quote $DistributionAcceptance) $(Quote $Artifact) *>> $(Quote $Result) + `$Code = `$LASTEXITCODE + `$ErrorActionPreference = 'Stop' + } if (`$Code -eq 0 -and $RunAcceptance) { `$ErrorActionPreference = 'Continue' & $(Quote $Node) $(Quote $Accept) $(Quote $Artifact) *>> $(Quote $Result) diff --git a/scripts/desktop/archive.ps1 b/scripts/desktop/archive.ps1 index 747c7fe..0bb155a 100644 --- a/scripts/desktop/archive.ps1 +++ b/scripts/desktop/archive.ps1 @@ -1,9 +1,20 @@ param( - [Parameter(Mandatory = $true)][ValidateSet('extract', 'create')][string]$Operation, + [Parameter(Mandatory = $true)][ValidateSet('extract', 'create', 'verify-extract')][string]$Operation, [Parameter(Mandatory = $true)][string]$Source, [Parameter(Mandatory = $true)][string]$Destination ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem -if ($Operation -eq 'extract') { [IO.Compression.ZipFile]::ExtractToDirectory($Source, $Destination) } +if ($Operation -eq 'verify-extract') { + $Zip = [IO.Compression.ZipFile]::OpenRead($Source) + try { + foreach ($Entry in $Zip.Entries) { + $Name = $Entry.FullName.Replace('\', '/') + $Type = ($Entry.ExternalAttributes -shr 16) -band 0xF000 + if ($Name.StartsWith('/') -or $Name -match '[:\x00-\x1f]' -or $Name.Split('/') -contains '..' -or $Name -match '(^|/)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])([./]|$)' -or $Name -match '[ .](/|$)' -or $Type -notin @(0, 0x4000, 0x8000)) { throw 'Unsafe archive member.' } + } + } finally { $Zip.Dispose() } + [IO.Compression.ZipFile]::ExtractToDirectory($Source, $Destination) +} +elseif ($Operation -eq 'extract') { [IO.Compression.ZipFile]::ExtractToDirectory($Source, $Destination) } else { [IO.Compression.ZipFile]::CreateFromDirectory($Source, $Destination) } diff --git a/scripts/desktop/build.mjs b/scripts/desktop/build.mjs index 340cf54..36b6610 100644 --- a/scripts/desktop/build.mjs +++ b/scripts/desktop/build.mjs @@ -15,11 +15,11 @@ fs.rmSync(path.join(root, '.next'), { recursive: true, force: true }); execFileSync(process.execPath, [require.resolve('next/dist/bin/next'), 'build'], { cwd: root, stdio: 'inherit', - env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' }, + env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', EXAMIFY_PORTABLE_BUILD: '1' }, }); if (git('status', '--porcelain') || git('rev-parse', 'HEAD') !== commit) throw new Error('Source changed during the build.'); fs.writeFileSync( path.join(root, '.next/desktop-build.json'), - JSON.stringify({ commit, clean: true }), + JSON.stringify({ commit, clean: true, imageOptimization: 'disabled' }), ); diff --git a/scripts/desktop/distribution-policy.mjs b/scripts/desktop/distribution-policy.mjs new file mode 100644 index 0000000..c9721ee --- /dev/null +++ b/scripts/desktop/distribution-policy.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** Technical redistribution evidence; not a complete SBOM or legal certification. */ +import fs from 'node:fs'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { assertReleaseInventory } from './inventory.mjs'; +import { digest, policyName, verifyDistribution } from './distribution.mjs'; +import { loadSupplementalNotices } from './supplemental-licenses.mjs'; +const excludedName = (name) => name === 'sharp' || name.startsWith('@img/'); +const excludedPath = (name) => + /(?:^|\/)node_modules\/(?:sharp|@img)(?:\/|$)|(?:^|\/)\.pnpm\/(?:sharp@|@img\+)/.test(name); +export function standaloneConfig(app) { + const source = fs.readFileSync(path.join(app, 'server.js'), 'utf8'); + const matches = [...source.matchAll(/^const nextConfig = (\{.*\});?\s*$/gm)]; + if (matches.length !== 1) + throw new Error('Cannot verify the serialized standalone configuration.'); + return JSON.parse(matches[0][1]); +} +export function createRuntimePolicy(app, identity, licenses) { + if (standaloneConfig(app).images?.unoptimized !== true) + throw new Error( + 'Portable image optimization must be disabled in the actual standalone server.', + ); + const files = assertReleaseInventory(app); + if (files.some(excludedPath)) + throw new Error('Unused native image packages remain in the portable runtime.'); + const links = JSON.parse(fs.readFileSync(path.join(app, 'runtime-links.json'), 'utf8')); + if ( + !Array.isArray(links) || + links.some((link) => excludedPath(link.path) || excludedPath(link.target)) + ) + throw new Error('Portable runtime links still reference excluded image packages.'); + const packages = licenses.packages.map(({ name, version }) => ({ name, version })); + if (packages.some((pkg) => excludedName(pkg.name))) + throw new Error('Native image package remains in the license inventory.'); + const nativeBinaries = files.filter((file) => + /\.(?:node|so(?:\.[0-9]+)*|dll|dylib)$/i.test(file), + ); + if ( + !nativeBinaries.length || + nativeBinaries.some( + (file) => !file.endsWith('.node') || !/\/node_modules\/better-sqlite3\//.test(`/${file}`), + ) + ) + throw new Error('Unreviewed bundled native library; redistribution evidence must be updated.'); + const expectedNotices = loadSupplementalNotices(packages).documents; + for (const document of expectedNotices) + if (digest(path.join(app, document.path)) !== document.sha256) + throw new Error('Required supplemental notice is missing or changed.'); + for (const file of ['LICENSE', 'runtime/LICENSE']) + if (!fs.readFileSync(path.join(app, file), 'utf8').trim()) + throw new Error('Application/runtime license is missing.'); + const policy = { + schema: 1, + policy: 'portable-without-image-optimizer-v1', + version: identity.version, + platform: identity.platform, + sourceCommit: identity.sourceCommit, + imageOptimization: 'disabled', + standaloneServerSha256: digest(path.join(app, 'server.js')), + packages, + nativeBinaries, + supplementalDocuments: expectedNotices.map(({ path: noticePath, sha256 }) => ({ + path: noticePath, + sha256, + })), + licenseInventorySha256: digest(path.join(app, 'THIRD-PARTY-LICENSES.json')), + reviewWarnings: licenses.warnings, + scope: + 'Verifies the approved unused image-library exclusion and known notice fixes. Not publisher identity, a complete SBOM, or legal certification.', + }; + validateRuntimePolicy(policy, identity); + return policy; +} +export function validateRuntimePolicy(policy, identity) { + if ( + policy.schema !== 1 || + policy.policy !== 'portable-without-image-optimizer-v1' || + policy.imageOptimization !== 'disabled' || + ['version', 'platform', 'sourceCommit'].some((field) => policy[field] !== identity[field]) || + !/^[a-f0-9]{64}$/.test(policy.standaloneServerSha256) || + !/^[a-f0-9]{64}$/.test(policy.licenseInventorySha256) + ) + throw new Error('Missing or mismatched portable-runtime policy evidence.'); + if ( + !Array.isArray(policy.packages) || + policy.packages.some( + (pkg) => + typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || excludedName(pkg.name), + ) || + !policy.packages.some((pkg) => pkg.name === 'better-sqlite3' && pkg.version === '13.0.3') || + !policy.packages.some((pkg) => pkg.name === '@next/env' && pkg.version === '16.3.6') + ) + throw new Error('Unreviewed runtime package identities.'); + if ( + !Array.isArray(policy.nativeBinaries) || + !policy.nativeBinaries.length || + policy.nativeBinaries.some( + (file) => + typeof file !== 'string' || + !file.endsWith('.node') || + !/\/node_modules\/better-sqlite3\//.test(`/${file}`) || + file.split('/').includes('..'), + ) + ) + throw new Error('Unreviewed bundled native library.'); + const expected = loadSupplementalNotices(policy.packages).documents.map( + ({ path: noticePath, sha256 }) => ({ path: noticePath, sha256 }), + ); + if (JSON.stringify(policy.supplementalDocuments) !== JSON.stringify(expected)) + throw new Error('Exact required notice evidence is incomplete.'); +} +export function assertRedistributionReady(directory, platform) { + const distribution = verifyDistribution(directory, platform); + if (distribution.localPreview || distribution.sourceDirty) + throw new Error('Dirty/local preview artifacts are not eligible for distribution.'); + const policy = JSON.parse(fs.readFileSync(path.join(directory, policyName(platform)), 'utf8')); + validateRuntimePolicy(policy, distribution); + const archive = distribution.assets.find((asset) => /\.(zip|tar.gz)$/.test(asset.name)); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-archive-policy-')); + try { + const source = path.join(directory, archive.name); + if (platform === 'win32-x64') { + if (process.platform !== 'win32') + throw new Error('Verify Windows archive evidence on native Windows.'); + execFileSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-File', + path.join(import.meta.dirname, 'archive.ps1'), + '-Operation', + 'verify-extract', + '-Source', + source, + '-Destination', + path.join(temporary, 'app'), + ], + { stdio: 'pipe' }, + ); + } else { + if (process.platform !== 'linux') + throw new Error('Verify Linux archive evidence on native Linux.'); + const entries = execFileSync('tar', ['-tzf', source], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }) + .trim() + .split('\n'); + if ( + entries.some( + (name) => + name.startsWith('/') || /[\\\r\x00-\x1f]/.test(name) || name.split('/').includes('..'), + ) + ) + throw new Error('Unsafe archive member path.'); + const modes = execFileSync('tar', ['-tvzf', source], { + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }) + .trim() + .split('\n'); + if (modes.some((line) => !/^[-d]/.test(line))) + throw new Error('Archive may contain only regular files and directories.'); + fs.mkdirSync(path.join(temporary, 'app')); + execFileSync('tar', ['-xzf', source, '-C', path.join(temporary, 'app'), '--no-same-owner'], { + stdio: 'pipe', + }); + } + const app = path.join(temporary, 'app'); + const actualIdentity = JSON.parse( + fs.readFileSync(path.join(app, 'desktop-release.json'), 'utf8'), + ); + for (const field of [ + 'version', + 'platform', + 'sourceCommit', + 'lockfileSha256', + 'nodeVersion', + 'nodeArchiveSha256', + 'localPreview', + 'sourceDirty', + 'upgradeProtocol', + 'imageOptimization', + ]) + if (actualIdentity[field] !== distribution[field]) + throw new Error('Archive identity differs from distribution.'); + const retained = JSON.parse( + fs.readFileSync(path.join(app, 'desktop-runtime-policy.json'), 'utf8'), + ); + const licenses = JSON.parse( + fs.readFileSync(path.join(app, 'THIRD-PARTY-LICENSES.json'), 'utf8'), + ); + const actual = createRuntimePolicy(app, actualIdentity, licenses); + if ( + JSON.stringify(retained) !== JSON.stringify(policy) || + JSON.stringify(actual) !== JSON.stringify(policy) + ) + throw new Error('Archive runtime differs from the recorded policy.'); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + return policy; +} +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [command, directory, platform] = process.argv.slice(2); + if (command !== 'check' || !directory || !platform) + throw new Error('Usage: distribution-policy.mjs check DIRECTORY PLATFORM'); + try { + assertRedistributionReady(path.resolve(directory), platform); + console.log( + 'Portable exclusion and required notice evidence verified; publication remains separately approved.', + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/desktop/distribution.mjs b/scripts/desktop/distribution.mjs new file mode 100644 index 0000000..e3a85de --- /dev/null +++ b/scripts/desktop/distribution.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** Offline integrity checks and candidate assembly. Never tags or publishes. */ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +// Hash the committed blob, not Windows' potentially CRLF-normalized checkout. +export function committedLockfileSha256(repository) { + return createHash('sha256') + .update( + execFileSync('git', ['show', 'HEAD:pnpm-lock.yaml'], { + cwd: repository, + maxBuffer: 16 * 1024 * 1024, + }), + ) + .digest('hex'); +} +export const platforms = ['linux-x64', 'win32-x64']; +export const digest = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; +const manifestName = (platform) => `distribution-${platform}.json`; +export const policyName = (platform) => `runtime-policy-${platform}.json`; +export function assetNames(version, platform) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(version) || !platforms.includes(platform)) + throw new Error('Invalid distribution version or platform.'); + const archive = `examify-${version}-${platform}.${platform === 'win32-x64' ? 'zip' : 'tar.gz'}`; + return [ + archive, + `${archive}.sha256`, + policyName(platform), + ...(platform === 'win32-x64' ? ['install.cmd', 'install.ps1'] : ['install-solo.sh']), + ].sort(); +} +function regularFile(directory, name) { + const file = path.join(directory, name); + if (!fs.lstatSync(file).isFile()) throw new Error(`Asset must be a regular file: ${name}`); + return file; +} +export function writeDistribution(directory, identity) { + const assets = assetNames(identity.version, identity.platform).map((name) => ({ + name, + bytes: fs.statSync(regularFile(directory, name)).size, + sha256: digest(regularFile(directory, name)), + })); + fs.writeFileSync( + path.join(directory, manifestName(identity.platform)), + json({ schema: 1, ...identity, assets }), + ); + const names = [...assets.map((asset) => asset.name), manifestName(identity.platform)].sort(); + fs.writeFileSync( + path.join(directory, `SHA256SUMS-${identity.platform}`), + names.map((name) => `${digest(regularFile(directory, name))} ${name}\n`).join(''), + ); + return verifyDistribution(directory, identity.platform); +} +function hasAssignment(script, pattern, expected) { + const matches = script.split(/\r?\n/).filter((line) => pattern.test(line)); + return matches.length === 1 && matches[0] === expected; +} +export function verifyDistribution(directory, platform) { + if (!platforms.includes(platform)) throw new Error('Unsupported distribution platform.'); + const manifest = JSON.parse( + fs.readFileSync(regularFile(directory, manifestName(platform)), 'utf8'), + ); + if ( + manifest.schema !== 1 || + manifest.upgradeProtocol !== 1 || + manifest.imageOptimization !== 'disabled' || + manifest.platform !== platform || + !/^[0-9a-f]{40}$/.test(manifest.sourceCommit) || + !/^[0-9a-f]{64}$/.test(manifest.lockfileSha256) || + !/^[0-9a-f]{64}$/.test(manifest.nodeArchiveSha256) || + typeof manifest.nodeVersion !== 'string' || + !/^22\.[0-9]+\.[0-9]+$/.test(manifest.nodeVersion) || + typeof manifest.localPreview !== 'boolean' || + typeof manifest.sourceDirty !== 'boolean' + ) + throw new Error('Invalid distribution identity.'); + const names = assetNames(manifest.version, platform); + if ( + !Array.isArray(manifest.assets) || + JSON.stringify(manifest.assets.map((asset) => asset.name)) !== JSON.stringify(names) + ) + throw new Error('Incomplete or unexpected asset set.'); + const expected = [...names, manifestName(platform), `SHA256SUMS-${platform}`].sort(); + if (JSON.stringify(fs.readdirSync(directory).sort()) !== JSON.stringify(expected)) + throw new Error('Distribution directory contains missing or unexpected files.'); + for (const asset of manifest.assets) + if ( + fs.statSync(regularFile(directory, asset.name)).size !== asset.bytes || + digest(regularFile(directory, asset.name)) !== asset.sha256 + ) + throw new Error(`Distribution checksum mismatch: ${asset.name}`); + const sums = [...names, manifestName(platform)] + .sort() + .map((name) => `${digest(regularFile(directory, name))} ${name}\n`) + .join(''); + if (fs.readFileSync(regularFile(directory, `SHA256SUMS-${platform}`), 'utf8') !== sums) + throw new Error('Distribution checksum list mismatch.'); + const archive = names.find((name) => /\.(zip|tar.gz)$/.test(name)); + const hash = digest(path.join(directory, archive)); + if ( + fs.readFileSync(path.join(directory, `${archive}.sha256`), 'utf8') !== `${hash} ${archive}\n` + ) + throw new Error('Archive checksum sidecar mismatch.'); + const installer = fs.readFileSync( + path.join(directory, platform === 'win32-x64' ? 'install.ps1' : 'install-solo.sh'), + 'utf8', + ); + if ( + !(platform === 'win32-x64' + ? hasAssignment(installer, /^\s*\$Version\s*=/i, `$Version = '${manifest.version}'`) && + hasAssignment(installer, /^\s*\$Sha256\s*=/i, `$Sha256 = '${hash}'`) + : hasAssignment(installer, /^\s*VERSION=/, `VERSION='${manifest.version}'`) && + hasAssignment(installer, /^\s*SHA256=/, `SHA256='${hash}'`)) || + /__EXAMIFY_/.test(installer) + ) + throw new Error('Installer does not pin this version and archive.'); + if (platform === 'win32-x64') { + const cmd = fs.readFileSync(path.join(directory, 'install.cmd'), 'utf8'); + if ( + !hasAssignment( + cmd, + /^\s*set "?EXAMIFY_INSTALL_VERSION=/i, + `set "EXAMIFY_INSTALL_VERSION=${manifest.version}"`, + ) || + !hasAssignment( + cmd, + /^\s*set "?EXAMIFY_INSTALL_PS_SHA256=/i, + `set "EXAMIFY_INSTALL_PS_SHA256=${digest(path.join(directory, 'install.ps1'))}"`, + ) || + /__EXAMIFY_/.test(cmd) + ) + throw new Error('Command Prompt bootstrap does not pin this installer.'); + } + return manifest; +} +export function assembleReleaseSet(input, output) { + const manifests = platforms.map((platform) => + verifyDistribution(path.join(input, platform), platform), + ); + const first = manifests[0]; + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(first.version)) + throw new Error('An RC asset set requires the intended stable numeric release version.'); + for (const candidate of manifests) + if ( + candidate.localPreview || + candidate.sourceDirty || + ['version', 'sourceCommit', 'lockfileSha256', 'nodeVersion'].some( + (key) => candidate[key] !== first[key], + ) + ) + throw new Error('RC assets must share one clean source, version, lockfile and Node version.'); + if (fs.existsSync(output)) + throw new Error('Use a new empty release-set destination; never overwrite candidate evidence.'); + fs.mkdirSync(output, { recursive: true }); + const assets = []; + for (const platform of platforms) + for (const name of fs.readdirSync(path.join(input, platform)).sort()) { + fs.copyFileSync( + regularFile(path.join(input, platform), name), + path.join(output, name), + fs.constants.COPYFILE_EXCL, + ); + assets.push({ + name, + bytes: fs.statSync(path.join(output, name)).size, + sha256: digest(path.join(output, name)), + }); + } + assets.sort((a, b) => a.name.localeCompare(b.name)); + const record = { + schema: 1, + status: 'candidate-not-approved-for-publication', + version: first.version, + sourceCommit: first.sourceCommit, + lockfileSha256: first.lockfileSha256, + nodeVersion: first.nodeVersion, + platforms, + assets, + }; + fs.writeFileSync(path.join(output, 'release-set.json'), json(record)); + fs.writeFileSync( + path.join(output, 'SHA256SUMS'), + [...assets.map((asset) => asset.name), 'release-set.json'] + .sort() + .map((name) => `${digest(path.join(output, name))} ${name}\n`) + .join(''), + ); + return record; +} +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [command, input, output] = process.argv.slice(2); + if (command === 'verify' && input && output) + console.log(verifyDistribution(path.resolve(input), output)); + else if (command === 'assemble' && input && output) + console.log(assembleReleaseSet(path.resolve(input), path.resolve(output))); + else + throw new Error( + 'Usage: distribution.mjs verify DIRECTORY PLATFORM | assemble INPUT_ROOT NEW_OUTPUT', + ); +} diff --git a/scripts/desktop/inventory.mjs b/scripts/desktop/inventory.mjs index f1e946d..a2658ad 100644 --- a/scripts/desktop/inventory.mjs +++ b/scripts/desktop/inventory.mjs @@ -73,7 +73,11 @@ export function copyRuntimeTree( } /** Preserve pnpm's module graph without putting symlinks in the download archive. */ -export function copyStandaloneRuntime(standalone, destination, { sourceNodeModules } = {}) { +export function copyStandaloneRuntime( + standalone, + destination, + { sourceNodeModules, omitPortableImageLinks = false } = {}, +) { fs.mkdirSync(destination, { recursive: true }); const root = fs.realpathSync(standalone); const modules = sourceNodeModules ? fs.realpathSync(sourceNodeModules) : null; @@ -124,6 +128,14 @@ export function copyStandaloneRuntime(standalone, destination, { sourceNodeModul const relative = relativeRuntimePath(root, source); if (forbiddenEntry(relative)) throw new Error(`Forbidden release entry: ${relative}`); const stat = fs.lstatSync(source); + // Next retains dependency-graph links even when their optional package files + // were explicitly excluded from tracing. Drop only these declared links; + // real image package contents still fail closed instead of being concealed. + if (omitPortableImageLinks && /(?:^|\/)node_modules\/(?:sharp|@img\/[^/]+)$/.test(relative)) { + if (!stat.isSymbolicLink()) + throw new Error('Image package files remain despite portable tracing exclusions.'); + return; + } if (stat.isSymbolicLink()) { const real = fs.realpathSync(source); let resolved = relativeRuntimePath(root, real); diff --git a/scripts/desktop/legal/README.md b/scripts/desktop/legal/README.md new file mode 100644 index 0000000..15443f8 --- /dev/null +++ b/scripts/desktop/legal/README.md @@ -0,0 +1,14 @@ +# Retained upstream license evidence + +The two license files in this directory are original upstream bytes for +`dotenv@16.3.1` and `dotenv-expand@10.0.0`, embedded in `@next/env@16.3.6`. +`provenance.json` records their exact source URLs, package associations and SHA-256 +digests. The recovered files were checked against the previously verified hashes. + +Do not format these files or normalize their line endings. Packaging must verify +their hashes and associate them only with the exact shipped package version. + +This collection addresses identified embedded-dependency notices. It is not a +complete SBOM or a legal compliance certification. Native Sharp/libvips reference +materials are not part of this recovered set; the approved portable-only build +exclusion must be verified separately against each final platform archive. diff --git a/scripts/desktop/legal/dotenv-16.3.1-LICENSE b/scripts/desktop/legal/dotenv-16.3.1-LICENSE new file mode 100644 index 0000000..c430ad8 --- /dev/null +++ b/scripts/desktop/legal/dotenv-16.3.1-LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2015, Scott Motte +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/scripts/desktop/legal/dotenv-expand-10.0.0-LICENSE b/scripts/desktop/legal/dotenv-expand-10.0.0-LICENSE new file mode 100644 index 0000000..615b117 --- /dev/null +++ b/scripts/desktop/legal/dotenv-expand-10.0.0-LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2016, Scott Motte +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/scripts/desktop/legal/provenance.json b/scripts/desktop/legal/provenance.json new file mode 100644 index 0000000..f8b11ac --- /dev/null +++ b/scripts/desktop/legal/provenance.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "scope": "Exact-version upstream notice evidence for embedded dotenv dependencies. Original bytes are preserved. This is not a complete SBOM or legal compliance certification.", + "retrievedOn": "2026-10-02", + "documents": [ + { + "file": "dotenv-16.3.1-LICENSE", + "sourceUrl": "https://raw.githubusercontent.com/motdotla/dotenv/v16.3.1/LICENSE", + "sha256": "74b629b24865e1e83c5277ee84590b7937644d6fd959d0c7bdce758676cd2ced", + "size": 1294, + "associations": [ + { + "package": "@next/env", + "version": "16.3.6", + "component": "dotenv", + "componentVersion": "16.3.1" + } + ], + "description": "Original BSD-2-Clause license and copyright for dotenv 16.3.1 embedded in @next/env. Bytes match the previously verified upstream retrieval." + }, + { + "file": "dotenv-expand-10.0.0-LICENSE", + "sourceUrl": "https://raw.githubusercontent.com/motdotla/dotenv-expand/v10.0.0/LICENSE", + "sha256": "3726b9470c3a6b54e1ebcc1d802d37089b5a5fc97273b52b2cee578a4421ec45", + "size": 1295, + "associations": [ + { + "package": "@next/env", + "version": "16.3.6", + "component": "dotenv-expand", + "componentVersion": "10.0.0" + } + ], + "description": "Original BSD-2-Clause license and copyright for dotenv-expand 10.0.0 embedded in @next/env. Bytes match the previously verified upstream retrieval." + } + ], + "unresolved": [] +} diff --git a/scripts/desktop/licenses.mjs b/scripts/desktop/licenses.mjs new file mode 100644 index 0000000..bf76f64 --- /dev/null +++ b/scripts/desktop/licenses.mjs @@ -0,0 +1,288 @@ +/** Retain upstream evidence; this inventory is not a legal compliance certification. */ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { relativeRuntimePath } from './inventory.mjs'; +import { loadSupplementalNotices } from './supplemental-licenses.mjs'; + +const noticeName = + /^(?:licen[sc]e|copying|copyright|notice|third[-_. ]party(?:[-_. ]notices)?)(?:$|[._ -])/i; +const legalSuffix = /\.(?:LEGAL|LICENSE|NOTICE)\.txt$/i; +const packageName = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i; +const version = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const posix = (value) => value.split(path.sep).join('/'); +const moduleDirectory = (dir) => /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^@/.][^/]*$/.test(posix(dir)); + +function safeFile(root, file) { + const relative = relativeRuntimePath(root, file); + if (relative === null || !relative) + throw new Error('License evidence is outside its permitted root.'); + // Check every ancestor before reading: even an in-tree symlink is not evidence. + let cursor = root; + for (const part of relative.split('/')) { + cursor = path.join(cursor, part); + if (fs.lstatSync(cursor).isSymbolicLink()) + throw new Error(`Linked license evidence is forbidden: ${relative}`); + } + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > 8 * 1024 * 1024) + throw new Error(`License evidence must be a regular file under 8 MiB: ${relative}`); + return fs.readFileSync(file); +} + +function readManifest(root, file) { + try { + return JSON.parse(safeFile(root, file).toString('utf8')); + } catch (error) { + throw new Error(`Cannot read a safe runtime package manifest: ${error.message}`); + } +} + +/** + * Run against the fresh, link-free staging directory, after the helper bundles exist. + * sourceNodeModules must be the native frozen-lockfile installation used for the build. + * bundledInputs are absolute esbuild metafile input paths for shipped helpers only. + * No source code, README or arbitrary checkout files are read by the generic collector. + */ +export function retainRuntimeLicenses(app, { sourceNodeModules, bundledInputs = [] } = {}) { + const staged = fs.realpathSync(app); + const modules = fs.realpathSync(sourceNodeModules); + const requestedModules = path.resolve(sourceNodeModules); + const output = path.join(staged, 'licenses'); + const inventoryFile = path.join(staged, 'THIRD-PARTY-LICENSES.json'); + if (fs.existsSync(output) || fs.existsSync(inventoryFile)) + throw new Error('License retention requires a fresh staging destination.'); + const packages = new Map(); + const components = new Map(); + const pending = []; + const warnings = []; + + function pinnedPackage(candidate, expected) { + // Resolve only paths already inside the explicitly permitted installation. + if (relativeRuntimePath(modules, candidate) === null) + throw new Error('Runtime package is outside the permitted node_modules.'); + const real = fs.realpathSync(candidate); + const relative = relativeRuntimePath(modules, real); + const match = relative?.match(/^\.pnpm\/([^/]+)\/node_modules\/((?:@[^/]+\/)?[^/]+)$/); + if (!match) throw new Error('Runtime licenses require an exact in-tree pnpm package.'); + const manifest = readManifest(modules, path.join(real, 'package.json')); + const locator = `${manifest.name?.replace('/', '+')}@${manifest.version}`; + if ( + !packageName.test(manifest.name ?? '') || + !version.test(manifest.version ?? '') || + manifest.name !== match[2] || + (match[1] !== locator && !match[1].startsWith(`${locator}_`)) || + (expected && (expected.name !== manifest.name || expected.version !== manifest.version)) + ) + throw new Error('Runtime license package identity does not match its pinned source.'); + if (!packages.has(relative)) { + packages.set(relative, { + name: manifest.name, + version: manifest.version, + declaredLicense: typeof manifest.license === 'string' ? manifest.license : null, + sourcePackage: `node_modules/${relative}`, + shippedPaths: new Set(), + bundledInputs: new Set(), + root: real, + evidence: [], + }); + } + return packages.get(relative); + } + + function compiledComponent(pkg, relative) { + if (pkg.name !== 'next') return; + const match = relative.match(/^dist\/compiled\/((?:@[^/]+\/)?[^@/][^/]*)(?:\/|$)/); + if (!match) return; + const component = match[1]; + const key = `${pkg.sourcePackage}/dist/compiled/${component}`; + if (!components.has(key)) + components.set(key, { + name: component, + version: null, + declaredLicense: null, + sourcePackage: key, + parentPackage: pkg.sourcePackage, + root: path.join(pkg.root, 'dist/compiled', component), + evidence: [], + }); + } + + function walkStaged(dir, owner = null) { + const stat = fs.lstatSync(dir); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('License collection requires link-free staged runtime directories.'); + const relative = relativeRuntimePath(staged, dir); + if (moduleDirectory(dir)) { + const metadata = readManifest(staged, path.join(dir, 'package.json')); + const sourceRelative = relative.replace(/^(?:\.next\/)?node_modules\//, ''); + owner = pinnedPackage(path.join(modules, sourceRelative), metadata); + owner.shippedPaths.add(relative); + } + for (const entry of fs + .readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(dir, entry.name); + if (entry.isSymbolicLink()) + throw new Error('License collection requires a link-free staged runtime.'); + if (owner) { + const ownerPath = [...owner.shippedPaths].find( + (base) => relativeRuntimePath(path.join(staged, base), file) !== null, + ); + if (ownerPath) + compiledComponent(owner, relativeRuntimePath(path.join(staged, ownerPath), file)); + } + if (entry.isDirectory()) walkStaged(file, owner); + else if (!entry.isFile()) throw new Error('Staged runtime contains a non-regular file.'); + } + } + + for (const name of ['node_modules', '.next/node_modules']) { + const dir = path.join(staged, name); + if (fs.existsSync(dir)) walkStaged(dir); + } + + for (const input of bundledInputs) { + if (typeof input !== 'string' || !path.isAbsolute(input)) + throw new Error('Bundled license inputs must be absolute build metafile paths.'); + const relative = + relativeRuntimePath(requestedModules, input) ?? relativeRuntimePath(modules, input); + // Application/workspace source is covered by the application's own license. + if (relative === null) continue; + const real = fs.realpathSync(input); + if (relativeRuntimePath(modules, real) === null) + throw new Error('Bundled input escapes the permitted node_modules.'); + let dir = path.dirname(real); + while (dir !== modules && !moduleDirectory(dir)) dir = path.dirname(dir); + if (dir === modules) throw new Error('Bundled input has no pinned package root.'); + const pkg = pinnedPackage(dir); + const packageRelative = relativeRuntimePath(pkg.root, real); + pkg.bundledInputs.add(packageRelative); + compiledComponent(pkg, packageRelative); + } + + function retain(record, relative, kind) { + const file = path.join(record.root, relative); + const bytes = safeFile(modules, file); + // Some Next .LEGAL.txt files are intentionally empty placeholders. Preserve the + // upstream bytes, but never count them as actual license/attribution evidence. + if (kind === 'notice' && !bytes.toString('utf8').trim()) kind = 'empty-notice'; + const id = digest(record.sourcePackage).slice(0, 20); + const destination = `licenses/${id}/${posix(relative)}`; + pending.push({ destination, bytes }); + record.evidence.push({ + kind, + source: posix(relative), + path: destination, + sha256: digest(bytes), + }); + } + + function collectNotices(record, dir = record.root) { + // Validate directories before enumerating them, without following linked trees. + const relative = relativeRuntimePath(modules, dir); + if (relative === null || fs.lstatSync(dir).isSymbolicLink()) + throw new Error('License evidence directory escapes the permitted package.'); + let cursor = modules; + for (const part of relative.split('/')) { + cursor = path.join(cursor, part); + if (fs.lstatSync(cursor).isSymbolicLink()) + throw new Error('Linked license source directories are forbidden.'); + } + for (const entry of fs + .readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + const file = path.join(dir, entry.name); + const packageRelative = relativeRuntimePath(record.root, file); + // Compiled Next units are included only when actually present in the staged tree + // or in a shipped helper's bundler inputs, never all of Next's build tooling. + if (record.name === 'next' && packageRelative === 'dist/compiled') continue; + if (entry.isSymbolicLink()) throw new Error('Linked license source entries are forbidden.'); + if (entry.isDirectory()) collectNotices(record, file); + else if (noticeName.test(entry.name) || legalSuffix.test(entry.name)) + retain(record, packageRelative, 'notice'); + else if ( + record.parentPackage && + entry.name === 'package.json' && + packageRelative !== 'package.json' + ) + retain(record, packageRelative, 'manifest'); + } + } + + for (const record of [...packages.values(), ...components.values()]) { + if (fs.existsSync(path.join(record.root, 'package.json'))) { + const manifest = readManifest(modules, path.join(record.root, 'package.json')); + if (record.parentPackage) { + record.name = typeof manifest.name === 'string' ? manifest.name : record.name; + record.version = typeof manifest.version === 'string' ? manifest.version : null; + record.declaredLicense = typeof manifest.license === 'string' ? manifest.license : null; + } + retain(record, 'package.json', 'manifest'); + } + collectNotices(record); + // The native package ships its dependency attribution table and exact versions in + // these files, but does not supply the corresponding complete license texts. + if (/^@img\/sharp-libvips-/.test(record.name)) { + for (const file of ['README.md', 'versions.json']) + if (fs.existsSync(path.join(record.root, file))) retain(record, file, 'native-attribution'); + warnings.push({ + package: record.sourcePackage, + reason: + 'Native libvips dependencies require review of the retained attribution table, versions and corresponding source/license obligations.', + }); + } + if (record.name === 'better-sqlite3') { + const header = 'deps/sqlite3/sqlite3.h'; + if (fs.existsSync(path.join(record.root, header))) + retain(record, header, 'native-source-notice'); + else + warnings.push({ + package: record.sourcePackage, + reason: 'Bundled SQLite source header with its upstream notice is missing.', + }); + } + if (!record.evidence.some((file) => file.kind === 'notice')) + warnings.push({ + package: record.sourcePackage, + reason: + 'No standalone upstream license/notice file was found. A manifest license identifier is not a substitute for its text; review is required.', + }); + } + + const cleanRecord = ({ root: _root, shippedPaths, bundledInputs: inputs, ...record }) => ({ + ...record, + ...(shippedPaths + ? { shippedPaths: [...shippedPaths].sort(), bundledInputs: [...inputs].sort() } + : {}), + evidence: record.evidence.sort((a, b) => a.source.localeCompare(b.source)), + }); + const supplemental = loadSupplementalNotices([...packages.values()]); + pending.push(...supplemental.files); + const inventory = { + supplementalDocuments: supplemental.documents, + schemaVersion: 1, + scope: + 'Upstream manifests and notice files for staged pnpm runtime packages, shipped Next compiled components, and supplied helper-bundle inputs. Not a complete SBOM or a legal compliance certification; application/framework bundles can contain additional embedded code.', + reviewRequired: warnings.length > 0, + packages: [...packages.values()] + .map(cleanRecord) + .sort((a, b) => a.sourcePackage.localeCompare(b.sourcePackage)), + compiledComponents: [...components.values()] + .map(cleanRecord) + .sort((a, b) => a.sourcePackage.localeCompare(b.sourcePackage)), + warnings: warnings.sort( + (a, b) => a.package.localeCompare(b.package) || a.reason.localeCompare(b.reason), + ), + }; + // Validate and read all evidence first. A source error leaves no partial notice set. + for (const { destination, bytes } of pending) { + const file = path.join(staged, destination); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, bytes, { flag: 'wx' }); + } + fs.writeFileSync(inventoryFile, `${JSON.stringify(inventory, null, 2)}\n`, { flag: 'wx' }); + return inventory; +} diff --git a/scripts/desktop/package.mjs b/scripts/desktop/package.mjs index 47cd8f2..34cbb0e 100644 --- a/scripts/desktop/package.mjs +++ b/scripts/desktop/package.mjs @@ -7,6 +7,9 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertReleaseInventory, copyStandaloneRuntime, copyRuntimeTree } from './inventory.mjs'; +import { writeDistribution, policyName, committedLockfileSha256 } from './distribution.mjs'; +import { retainRuntimeLicenses } from './licenses.mjs'; +import { createRuntimePolicy, standaloneConfig } from './distribution-policy.mjs'; const require = createRequire(import.meta.url); const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -27,7 +30,11 @@ if (!localPreview) { const provenance = JSON.parse( fs.readFileSync(path.join(repo, '.next/desktop-build.json'), 'utf8'), ); - if (provenance.commit !== sourceCommit || provenance.clean !== true) + if ( + provenance.commit !== sourceCommit || + provenance.clean !== true || + provenance.imageOptimization !== 'disabled' + ) throw new Error('Run scripts/desktop/build.mjs for this exact source commit first.'); } if (!pin) throw new Error(`No portable release is supported for ${platform}.`); @@ -36,6 +43,10 @@ if (process.versions.node !== pins.version) if (!fs.existsSync(path.join(repo, '.next/standalone/server.js'))) throw new Error('Run the native production build first.'); const destination = path.join(repo, 'build', 'desktop', platform); +if (fs.existsSync(destination) && fs.readdirSync(destination).length) + throw new Error( + 'Package output already exists; preserve the previous candidate and use a clean output directory.', + ); fs.mkdirSync(destination, { recursive: true }); const stage = fs.mkdtempSync(path.join(destination, 'stage-')); const app = path.join(stage, 'app'); @@ -48,8 +59,11 @@ function command(binary, args) { execFileSync(binary, args, { cwd: repo, stdio: 'inherit' }); } try { + if (standaloneConfig(path.join(repo, '.next/standalone')).images?.unoptimized !== true) + throw new Error('Only verified portable builds may omit optional image links.'); copyStandaloneRuntime(path.join(repo, '.next/standalone'), app, { sourceNodeModules: path.join(repo, 'node_modules'), + omitPortableImageLinks: true, }); copy(path.join(repo, '.next/static'), path.join(app, '.next/static')); // Only committed sample content, never ignored study PDFs or family content. @@ -114,7 +128,14 @@ try { copy(path.join(repo, 'LICENSE'), path.join(app, 'LICENSE')); // tsx pins its esbuild dependency in pnpm-lock; avoid fetching a build-time tool. const esbuild = require(require.resolve('esbuild', { paths: [require.resolve('tsx')] })); - await esbuild.build({ + const bundledInputs = []; + const bundle = async (options) => { + const result = await esbuild.build({ ...options, metafile: true }); + bundledInputs.push( + ...Object.keys(result.metafile.inputs).map((name) => path.resolve(repo, name)), + ); + }; + await bundle({ entryPoints: [path.join(repo, 'src/lib/db/migrate.ts')], outfile: path.join(app, 'scripts/migrate.mjs'), bundle: true, @@ -134,7 +155,7 @@ try { }, ], }); - await esbuild.build({ + await bundle({ entryPoints: [path.join(repo, 'src/lib/solo-preflight.ts')], outfile: path.join(app, 'scripts/solo-preflight.mjs'), bundle: true, @@ -144,7 +165,7 @@ try { external: ['better-sqlite3'], }); // Exercise the real runtime prompt loader without invoking any provider. - await esbuild.build({ + await bundle({ stdin: { contents: "import { loadGeneratePrompt } from './tools/examify-ingest/src/prompt.ts'; const prompt = loadGeneratePrompt(process.cwd()); if (!prompt.text.trim() || !prompt.hash) throw new Error('Invalid generation prompt asset.');", @@ -187,24 +208,34 @@ try { copy(path.join(nodeRoot, runtimeExe), path.join(app, 'runtime', runtimeExe)); copy(path.join(nodeRoot, 'LICENSE'), path.join(app, 'runtime/LICENSE')); if (process.platform !== 'win32') fs.chmodSync(path.join(app, 'runtime', runtimeExe), 0o755); + const identity = { + version, + platform, + upgradeProtocol: 1, + nodeVersion: pins.version, + nodeArchiveSha256: pin.sha256, + sourceCommit, + localPreview, + imageOptimization: 'disabled', + lockfileSha256: committedLockfileSha256(repo), + sourceDirty: Boolean( + execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf8' }).trim(), + ), + }; fs.writeFileSync( path.join(app, 'desktop-release.json'), - `${JSON.stringify( - { - version, - platform, - nodeVersion: pins.version, - upgradeProtocol: 1, - nodeArchiveSha256: pin.sha256, - sourceCommit, - localPreview, - sourceDirty: Boolean( - execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf8' }).trim(), - ), - }, - null, - 2, - )}\n`, + `${JSON.stringify(identity, null, 2)}\n`, + ); + const licenses = retainRuntimeLicenses(app, { + sourceNodeModules: path.join(repo, 'node_modules'), + bundledInputs, + }); + for (const warning of licenses.warnings) + console.warn(`License review note: ${warning.package}: ${warning.reason}`); + const policy = createRuntimePolicy(app, identity, licenses); + fs.writeFileSync( + path.join(app, 'desktop-runtime-policy.json'), + `${JSON.stringify(policy, null, 2)}\n`, ); const inventory = assertReleaseInventory(app); fs.writeFileSync( @@ -243,6 +274,19 @@ try { .replaceAll('__EXAMIFY_INSTALL_PS_SHA256__', sha256(path.join(destination, installer))); fs.writeFileSync(path.join(destination, 'install.cmd'), cmd); } + fs.rmSync(stage, { recursive: true, force: true }); + if ( + !localPreview && + (execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf8' }).trim() || + execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim() !== + sourceCommit) + ) + throw new Error('Source changed during packaging.'); + fs.writeFileSync( + path.join(destination, policyName(platform)), + `${JSON.stringify(policy, null, 2)}\n`, + ); + writeDistribution(destination, identity); console.log(`Packaged ${file}; installer pins SHA-256 ${digest}`); } finally { fs.rmSync(stage, { recursive: true, force: true }); diff --git a/scripts/desktop/supplemental-licenses.mjs b/scripts/desktop/supplemental-licenses.mjs new file mode 100644 index 0000000..8a0c526 --- /dev/null +++ b/scripts/desktop/supplemental-licenses.mjs @@ -0,0 +1,77 @@ +/** Exact upstream notices missing from dependency tarballs, with pinned provenance. */ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +export function loadSupplementalNotices( + packages, + { root = path.join(import.meta.dirname, 'legal') } = {}, +) { + if (fs.lstatSync(root).isSymbolicLink()) + throw new Error('Supplemental notice root must not be linked.'); + const read = (name) => { + if ( + typeof name !== 'string' || + name.split('/').some((part) => !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part) || part === '..') + ) + throw new Error('Invalid supplemental notice filename.'); + let file = root; + for (const part of name.split('/')) { + file = path.join(file, part); + if (fs.lstatSync(file).isSymbolicLink()) + throw new Error('Supplemental notices must be regular files without linked ancestors.'); + } + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 1024 * 1024) + throw new Error('Supplemental notices must be regular files under 1 MiB.'); + return fs.readFileSync(file); + }; + const provenance = JSON.parse(read('provenance.json')); + if (provenance.schemaVersion !== 1 || !Array.isArray(provenance.documents)) + throw new Error('Invalid supplemental notice provenance.'); + const documents = []; + const files = []; + const seen = new Set(); + for (const document of provenance.documents) { + const bytes = read(document.file); + if ( + seen.has(document.file) || + !bytes.length || + sha256(bytes) !== document.sha256 || + !/^https:\/\//.test(document.sourceUrl) || + !Array.isArray(document.associations) || + !document.associations.length + ) + throw new Error('Supplemental notice provenance or bytes do not match.'); + seen.add(document.file); + for (const association of document.associations) + if (typeof association.package !== 'string' || typeof association.version !== 'string') + throw new Error('Supplemental notices require exact package-version associations.'); + const associations = document.associations.filter((association) => + packages.some( + (pkg) => pkg.name === association.package && pkg.version === association.version, + ), + ); + if (!associations.length) continue; + const destination = `licenses/upstream/${document.file}`; + documents.push({ ...document, associations, path: destination }); + files.push({ destination, bytes }); + } + for (const pkg of packages.filter((item) => item.name === '@next/env')) + for (const component of ['dotenv', 'dotenv-expand']) + if ( + !documents.some((doc) => + doc.associations.some( + (a) => + a.package === pkg.name && + a.version === pkg.version && + a.component === component && + a.componentVersion === { dotenv: '16.3.1', 'dotenv-expand': '10.0.0' }[component], + ), + ) + ) + throw new Error( + `Missing exact-version supplemental notice for ${pkg.name} ${pkg.version}: ${component}.`, + ); + return { documents, files }; +} diff --git a/tests/desktop/acceptance.mjs b/tests/desktop/acceptance.mjs index 9c831d6..7bbced9 100644 --- a/tests/desktop/acceptance.mjs +++ b/tests/desktop/acceptance.mjs @@ -9,11 +9,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnvironment } from '../../scripts/launcher.mjs'; import { observeLauncher } from './launcher-progress.mjs'; +import { verifyDistribution, policyName, digest } from '../../scripts/desktop/distribution.mjs'; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const platform = `${process.platform}-${process.arch}`; const artifacts = path.resolve(process.argv[2] || path.join(repo, 'build/desktop', platform)); const httpOnly = process.argv.includes('--http-only'); +const distribution = verifyDistribution(artifacts, platform); const asset = fs .readdirSync(artifacts) .find((name) => name.endsWith(platform === 'win32-x64' ? '.zip' : '.tar.gz')); @@ -74,6 +76,26 @@ try { const marker = JSON.parse(fs.readFileSync(path.join(root, 'installation.json'), 'utf8')); appDir = path.join(root, marker.release ?? `releases/${marker.version}`); node = path.join(appDir, process.platform === 'win32' ? 'runtime/node.exe' : 'runtime/bin/node'); + const installed = JSON.parse(fs.readFileSync(path.join(appDir, 'desktop-release.json'), 'utf8')); + for (const field of [ + 'version', + 'platform', + 'sourceCommit', + 'lockfileSha256', + 'nodeVersion', + 'nodeArchiveSha256', + 'upgradeProtocol', + 'sourceDirty', + 'localPreview', + 'imageOptimization', + ]) + assert.equal(installed[field], distribution[field], `Installed ${field} matches distribution`); + const policy = JSON.parse(fs.readFileSync(path.join(artifacts, policyName(platform)), 'utf8')); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(appDir, 'desktop-runtime-policy.json'), 'utf8')), + policy, + ); + assert.equal(digest(path.join(appDir, 'server.js')), policy.standaloneServerSha256); } catch (error) { fs.rmSync(fixture, { recursive: true, force: true }); throw error; @@ -224,6 +246,15 @@ try { }; if (httpOnly) { const cookie = await bootstrap(url); + assert.equal( + ( + await fetch(`${ready.origin}/_next/image?url=%2Ffavicon.ico&w=64&q=75`, { + headers: { cookie }, + }) + ).status, + 404, + 'Portable optimizer is disabled, not a missing-module server error', + ); const home = await fetch(ready.origin, { headers: { cookie } }); assert.equal(home.status, 200, 'Solo Home renders successfully'); const html = await home.text(); diff --git a/tests/desktop/archive-policy.test.mjs b/tests/desktop/archive-policy.test.mjs new file mode 100644 index 0000000..7cc9f94 --- /dev/null +++ b/tests/desktop/archive-policy.test.mjs @@ -0,0 +1,265 @@ +/** Real archive parsing/extraction, with inert fixtures. Never executes archive code. */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { digest, policyName, writeDistribution } from '../../scripts/desktop/distribution.mjs'; +import { + assertRedistributionReady, + createRuntimePolicy, +} from '../../scripts/desktop/distribution-policy.mjs'; +import { loadSupplementalNotices } from '../../scripts/desktop/supplemental-licenses.mjs'; + +const repo = path.resolve(import.meta.dirname, '../..'); +const platform = `${process.platform}-${process.arch}`; +const supported = ['linux-x64', 'win32-x64'].includes(platform); +const windows = process.platform === 'win32'; +const quote = (value) => `'${value.replaceAll("'", "''")}'`; +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; + +function archiveTool(operation, source, destination) { + return execFileSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + path.join(repo, 'scripts/desktop/archive.ps1'), + '-Operation', + operation, + '-Source', + source, + '-Destination', + destination, + ], + { stdio: 'pipe' }, + ); +} + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-archive-regression-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const app = path.join(root, 'app'); + const out = path.join(root, 'distribution'); + fs.mkdirSync(app); + fs.mkdirSync(out); + const put = (name, bytes) => { + const file = path.join(app, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, bytes); + }; + const identity = { + version: '0.1.0', + platform, + sourceCommit: 'a'.repeat(40), + nodeVersion: '22.22.2', + nodeArchiveSha256: 'b'.repeat(64), + lockfileSha256: 'c'.repeat(64), + upgradeProtocol: 1, + localPreview: false, + sourceDirty: false, + imageOptimization: 'disabled', + }; + const licenses = { + packages: [ + { name: 'better-sqlite3', version: '13.0.3' }, + { name: '@next/env', version: '16.3.6' }, + ], + warnings: [], + }; + put('server.js', 'const nextConfig = {"images":{"unoptimized":true}}\n'); + put('runtime-links.json', '[]'); + put('LICENSE', 'Application fixture license'); + put('runtime/LICENSE', 'Runtime fixture license'); + put('THIRD-PARTY-LICENSES.json', json(licenses)); + put('desktop-release.json', json(identity)); + put( + 'node_modules/.pnpm/better-sqlite3@13.0.3/node_modules/better-sqlite3/build/Release/better_sqlite3.node', + 'Inert native-library fixture, never loaded or executed.', + ); + for (const notice of loadSupplementalNotices(licenses.packages).files) + put(notice.destination, notice.bytes); + const policy = createRuntimePolicy(app, identity, licenses); + put('desktop-runtime-policy.json', json(policy)); + fs.writeFileSync(path.join(out, policyName(platform)), json(policy)); + const asset = `examify-${identity.version}-${platform}.${windows ? 'zip' : 'tar.gz'}`; + const archive = path.join(out, asset); + + function refreshDistribution() { + // Recompute all outer checksums so each failure must inspect the archive, + // rather than merely noticing a stale download checksum or installer pin. + const hash = digest(archive); + fs.writeFileSync(`${archive}.sha256`, `${hash} ${asset}\n`); + const installer = windows ? 'install.ps1' : 'install-solo.sh'; + fs.writeFileSync( + path.join(out, installer), + fs + .readFileSync(path.join(repo, installer), 'utf8') + .replaceAll('__EXAMIFY_VERSION__', identity.version) + .replaceAll('__EXAMIFY_SHA256__', hash), + ); + if (windows) { + fs.writeFileSync( + path.join(out, 'install.cmd'), + fs + .readFileSync(path.join(repo, 'install.cmd'), 'utf8') + .replaceAll('__EXAMIFY_VERSION__', identity.version) + .replaceAll('__EXAMIFY_INSTALL_PS_SHA256__', digest(path.join(out, installer))), + ); + } + writeDistribution(out, identity); + } + + function pack(extra = []) { + fs.rmSync(archive, { force: true }); + if (windows) archiveTool('create', app, archive); + else execFileSync('tar', ['-czf', archive, ...extra, '-C', app, '.'], { stdio: 'pipe' }); + refreshDistribution(); + } + + function appendZip(name, attributes = 0) { + // .NET emits the real central-directory attributes checked by verify-extract. + // Even the deliberately escaping paths lead only to this disposable root. + execFileSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + + `$zip=[IO.Compression.ZipFile]::Open(${quote(archive)},[IO.Compression.ZipArchiveMode]::Update); ` + + `try { $entry=$zip.CreateEntry(${quote(name)}); $entry.ExternalAttributes=[int]${attributes}; ` + + `$writer=New-Object IO.StreamWriter($entry.Open()); try { $writer.Write('inert fixture'); } ` + + `finally { $writer.Dispose(); } } finally { $zip.Dispose(); }`, + ], + { stdio: 'pipe' }, + ); + refreshDistribution(); + } + return { root, app, out, archive, identity, policy, put, pack, appendZip, refreshDistribution }; +} + +test( + 'native archive gate re-extracts and verifies a consistent inert fixture', + { skip: !supported }, + (t) => { + const f = fixture(t); + f.pack(); + assert.deepEqual(assertRedistributionReady(f.out, platform), f.policy); + }, +); + +test( + 'valid outer pins cannot hide an enabled optimizer inside the archive', + { skip: !supported }, + (t) => { + const f = fixture(t); + f.put('server.js', 'const nextConfig = {"images":{"unoptimized":false}}\n'); + f.pack(); + assert.throws(() => assertRedistributionReady(f.out, platform), /actual standalone/); + }, +); + +test( + 'valid outer pins cannot hide mismatched archive source identity', + { skip: !supported }, + (t) => { + const f = fixture(t); + f.put('desktop-release.json', json({ ...f.identity, sourceCommit: 'd'.repeat(40) })); + f.pack(); + assert.throws(() => assertRedistributionReady(f.out, platform), /Archive identity differs/); + }, +); + +test( + 'actual notice bytes must match even when every outer checksum is recomputed', + { skip: !supported }, + (t) => { + const f = fixture(t); + f.put(f.policy.supplementalDocuments[0].path, 'altered fixture notice'); + f.pack(); + assert.throws(() => assertRedistributionReady(f.out, platform), /notice is missing or changed/); + }, +); + +test( + 'sidecar runtime policy cannot differ from the independently inspected archive', + { skip: !supported }, + (t) => { + const f = fixture(t); + f.pack(); + fs.writeFileSync( + path.join(f.out, policyName(platform)), + json({ + ...f.policy, + standaloneServerSha256: 'e'.repeat(64), + }), + ); + f.refreshDistribution(); + assert.throws(() => assertRedistributionReady(f.out, platform), /Archive runtime differs/); + }, +); + +test( + 'Linux archive traversal is rejected before extraction', + { skip: platform !== 'linux-x64' }, + (t) => { + const f = fixture(t); + f.pack(['--transform=s,^,../,']); + assert.throws(() => assertRedistributionReady(f.out, platform), /Unsafe archive member path/); + }, +); + +for (const kind of ['symlink', 'hardlink']) { + test( + `Linux archive ${kind} members are rejected before extraction`, + { skip: platform !== 'linux-x64' }, + (t) => { + const f = fixture(t); + const link = path.join(f.app, `unsafe-${kind}`); + if (kind === 'symlink') fs.symlinkSync('../escaped', link); + else fs.linkSync(path.join(f.app, 'LICENSE'), link); + f.pack(); + assert.throws( + () => assertRedistributionReady(f.out, platform), + /only regular files and directories/, + ); + }, + ); +} + +for (const [label, member, attributes] of [ + ['forward-slash traversal', '../escaped.txt', 0], + ['backslash traversal', '..\\escaped.txt', 0], + ['alternate data stream', 'payload.txt:stream', 0], + ['DOS device name', 'CON.txt', 0], + ['trailing-dot alias', 'directory./payload.txt', 0], + ['Unix symlink attributes', 'unsafe-link', 0o120777 << 16], + ['absolute local path', null, 0], +]) { + test( + `Windows ZIP rejects ${label} before extracting any entries`, + { skip: platform !== 'win32-x64' }, + (t) => { + const f = fixture(t); + f.pack(); + f.appendZip(member ?? path.join(f.root, 'escaped.txt'), attributes); + const destination = path.join(f.root, 'rejected-extraction'); + assert.throws( + () => archiveTool('verify-extract', f.archive, destination), + /Unsafe archive member/, + ); + assert.equal( + fs.existsSync(destination), + false, + 'The complete ZIP is preflighted before extraction', + ); + assert.equal(fs.existsSync(path.join(f.root, 'escaped.txt')), false); + assert.throws(() => assertRedistributionReady(f.out, platform), /Unsafe archive member/); + }, + ); +} diff --git a/tests/desktop/browser-checks.mjs b/tests/desktop/browser-checks.mjs index 555f116..f818622 100644 --- a/tests/desktop/browser-checks.mjs +++ b/tests/desktop/browser-checks.mjs @@ -123,6 +123,11 @@ export async function verifyBrowser({ origin, browserUrl, reopen }) { await expect(page).toHaveURL(`${origin}/`); await expect(page.getByRole('heading', { name: 'Pick a subject to practise.' })).toBeVisible(); assert.equal(new URL(page.url()).hash, ''); + assert.equal( + (await context.request.get(`${origin}/_next/image?url=%2Ffavicon.ico&w=64&q=75`)).status(), + 404, + 'Authenticated portable image optimization is disabled', + ); await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); await expect(page.getByRole('link', { name: 'Create your question bank' })).toBeVisible(); diff --git a/tests/desktop/distribution-acceptance.mjs b/tests/desktop/distribution-acceptance.mjs new file mode 100644 index 0000000..7dff938 --- /dev/null +++ b/tests/desktop/distribution-acceptance.mjs @@ -0,0 +1,419 @@ +#!/usr/bin/env node +/** + * Native download-path acceptance using a disposable, loopback HTTP release mirror. + * Usage: node tests/desktop/distribution-acceptance.mjs ARTIFACT_DIRECTORY_OR_ARCHIVE + * + * The distribution and archive are verified before any copies are made. Only the + * copied installers' literal origin/HTTPS requirements change for this fixture; + * the copied Windows CMD must additionally pin the adapted PS bytes. Production + * scripts, archive pins and published assets are never changed. This proves the + * download layout, bootstrap and integrity/activation behavior, NOT real GitHub + * HTTPS, redirects, certificates, download reputation or SmartScreen behavior. + * Real login/browser coverage remains in acceptance.mjs. + */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnvironment } from '../../scripts/launcher.mjs'; +import { assetNames, digest, verifyDistribution } from '../../scripts/desktop/distribution.mjs'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const platform = `${process.platform}-${process.arch}`; +assert.ok(['linux-x64', 'win32-x64'].includes(platform), 'Run on a supported native platform'); +assert.ok(process.argv.length <= 3, 'Pass one artifact directory or native archive path'); +const input = path.resolve(process.argv[2] || path.join(repo, 'build/desktop', platform)); +const artifacts = fs.statSync(input).isDirectory() ? input : path.dirname(input); +const distribution = verifyDistribution(artifacts, platform); +const asset = assetNames(distribution.version, platform).find((name) => + /\.(zip|tar.gz)$/.test(name), +); +const archive = path.join(artifacts, asset); +if (input !== artifacts) assert.equal(input, archive, 'Argument names the verified native archive'); +const archiveHash = digest(archive); +const windows = process.platform === 'win32'; +const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'Examify distribution acceptance ')); +const children = new Set(); +const httpErrors = []; +let server; +let scenario; + +function replaceOnce(source, before, after) { + assert.equal(source.split(before).length, 2, `Expected one fixture adaptation: ${before}`); + return source.replace(before, after); +} + +function systemTool(name) { + const candidate = (process.env.PATH || '').split(path.delimiter).find((directory) => { + try { + fs.accessSync(path.join(directory, name), fs.constants.X_OK); + return fs.statSync(path.join(directory, name)).isFile(); + } catch { + return false; + } + }); + assert.ok(candidate, `Required standard system tool: ${name}`); + return fs.realpathSync(path.join(candidate, name)); +} + +function environment() { + const env = childEnvironment(); + // Avoid Windows' case-insensitive duplicate PATH keys, and never inherit user + // curl/wget profiles, proxy settings, PowerShell profiles or app/provider keys. + for (const key of Object.keys(env)) { + if (/^(PATH|HOME|USERPROFILE|LOCALAPPDATA|APPDATA|TEMP|TMP|TMPDIR|XDG_.*)$/i.test(key)) { + delete env[key]; + } + } + const home = path.join(fixture, 'Home'); + const temp = path.join(fixture, 'Temp'); + fs.mkdirSync(home); + fs.mkdirSync(temp); + Object.assign(env, { + HOME: home, + USERPROFILE: home, + LOCALAPPDATA: home, + APPDATA: home, + TEMP: temp, + TMP: temp, + TMPDIR: temp, + XDG_CONFIG_HOME: home, + XDG_CACHE_HOME: home, + XDG_DATA_HOME: home, + }); + if (windows) { + const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT; + assert.ok(systemRoot, 'Windows system directory is available'); + env.PATH = [ + path.join(systemRoot, 'System32'), + path.join(systemRoot, 'System32/WindowsPowerShell/v1.0'), + ].join(path.delimiter); + } else { + assert.notEqual(process.getuid(), 0, 'Run the real Linux installer as an ordinary user'); + const bin = path.join(fixture, 'System tools'); + fs.mkdirSync(bin); + // Links to real installed OS binaries, not command shims. In particular no + // host Node, npm, pnpm or Git can satisfy a missing private-runtime dependency. + for (const name of [ + 'bash', + 'tar', + 'gzip', + 'sha256sum', + 'mktemp', + 'uname', + 'id', + 'dirname', + 'ls', + 'mkdir', + 'chmod', + 'cp', + 'rm', + 'curl', + ]) { + fs.symlinkSync(systemTool(name), path.join(bin, name)); + } + env.PATH = bin; + } + return env; +} + +async function stop(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + if (windows) { + const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT; + await new Promise((resolve) => { + const killer = spawn( + path.join(systemRoot, 'System32/taskkill.exe'), + ['/pid', String(child.pid), '/t', '/f'], + { stdio: 'ignore', windowsHide: true }, + ); + killer.once('error', resolve); + killer.once('close', resolve); + }); + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } +} + +// Do not use execFileSync for the installer: this process must keep serving the +// mirror while the real curl/PowerShell child downloads its pinned assets. +async function run(command, args, options = {}) { + const child = spawn(command, args, { + cwd: fixture, + stdio: ['ignore', 'pipe', 'pipe'], + detached: !windows, + windowsHide: true, + ...options, + }); + children.add(child); + let output = ''; + let timedOut = false; + for (const stream of [child.stdout, child.stderr]) { + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + output = (output + chunk).slice(-128 * 1024); + }); + } + const timer = setTimeout(() => { + timedOut = true; + void stop(child); + }, 300_000); + try { + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, output })); + }); + assert.equal(timedOut, false, `Native command exceeded five minutes:\n${output}`); + return result; + } finally { + clearTimeout(timer); + children.delete(child); + } +} + +function assertNoActivation(root) { + // Even releases/bootstrap/state directories would mean unverified archive code + // reached the coordinator. Failed download checks must leave only an empty root. + assert.deepEqual( + fs.existsSync(root) ? fs.readdirSync(root) : [], + [], + 'No unverified activation or state', + ); +} + +function assertRequests(expected) { + assert.ok(scenario.requests.length, `${scenario.name}: native downloader reached the mirror`); + assert.deepEqual(httpErrors, [], 'Mirror completed every response without an I/O error'); + for (const request of scenario.requests) { + assert.equal(request.method, 'GET', 'Installer uses a real GET download'); + assert.ok(expected.includes(request.url), `Unexpected download path: ${request.url}`); + } + // Consecutive retries are fine; no fallback to moving/latest or a local archive. + const observed = scenario.requests + .map(({ url }) => url) + .filter((url, index, urls) => !index || url !== urls[index - 1]); + assert.deepEqual(observed, expected, `${scenario.name}: exact pinned release download layout`); +} + +try { + const env = environment(); + const source = path.join(fixture, 'Downloaded installer'); + fs.mkdirSync(source); + const wrongFiles = path.join(fixture, 'Wrong release archive contents'); + fs.mkdirSync(wrongFiles); + fs.writeFileSync( + path.join(wrongFiles, 'desktop-release.json'), + JSON.stringify({ + ...distribution, + version: `${distribution.version}-different-release`, + }), + ); + // A structurally valid archive with a different release identity, rather than a + // second arbitrary corrupt byte string. Its untrusted metadata is never executed. + const wrongArchive = path.join(fixture, windows ? 'wrong-release.zip' : 'wrong-release.tar.gz'); + const quote = (value) => `'${value.replaceAll("'", "''")}'`; + const packed = windows + ? await run( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `Add-Type -AssemblyName System.IO.Compression.FileSystem; [IO.Compression.ZipFile]::CreateFromDirectory(${quote(wrongFiles)}, ${quote(wrongArchive)})`, + ], + { env }, + ) + : await run('tar', ['-czf', wrongArchive, '-C', wrongFiles, '.'], { env }); + assert.equal(packed.code, 0, `Create a valid wrong-release archive:\n${packed.output}`); + assert.notEqual(digest(wrongArchive), archiveHash); + + const downloadPrefix = `/atk0309/project_Examify/releases/download/${distribution.version}/`; + const archivePath = `${downloadPrefix}${asset}`; + const psPath = `${downloadPrefix}install.ps1`; + let powershell; + server = http.createServer((request, response) => { + if (!scenario) { + response.writeHead(503).end(); + return; + } + scenario.requests.push({ method: request.method, url: request.url }); + let body; + let file; + if (request.method === 'GET' && windows && request.url === psPath) { + body = + scenario.name === 'corrupt-powershell' + ? Buffer.from(`${powershell}\n# corrupt download fixture\n`) + : Buffer.from(powershell); + } else if ( + request.method === 'GET' && + request.url === archivePath && + scenario.name !== 'missing-archive' + ) { + if (scenario.name === 'corrupt-archive') + body = Buffer.from('deliberately corrupt archive bytes'); + else file = scenario.name === 'mismatched-archive' ? wrongArchive : archive; + } else { + response.writeHead(404).end('Release asset not found'); + return; + } + response.writeHead(200, { + 'content-type': 'application/octet-stream', + 'content-length': body ? body.length : fs.statSync(file).size, + 'cache-control': 'no-store', + }); + if (body) response.end(body); + else { + const stream = fs.createReadStream(file); + stream.once('error', (error) => { + httpErrors.push(error.message); + response.destroy(error); + }); + response.once('close', () => stream.destroy()); + stream.pipe(response); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const origin = `http://127.0.0.1:${server.address().port}`; + const installerName = windows ? 'install.cmd' : 'install-solo.sh'; + const installer = path.join(source, installerName); + if (windows) { + const originalPs = path.join(artifacts, 'install.ps1'); + powershell = replaceOnce(fs.readFileSync(originalPs, 'utf8'), 'https://github.com', origin); + const localPsHash = createHash('sha256').update(powershell).digest('hex'); + let cmd = replaceOnce( + fs.readFileSync(path.join(artifacts, installerName), 'utf8'), + 'https://github.com', + origin, + ); + cmd = replaceOnce( + cmd, + `EXAMIFY_INSTALL_PS_SHA256=${digest(originalPs)}`, + `EXAMIFY_INSTALL_PS_SHA256=${localPsHash}`, + ); + fs.writeFileSync(installer, cmd); + } else { + let bash = replaceOnce( + fs.readFileSync(path.join(artifacts, installerName), 'utf8'), + 'https://github.com', + origin, + ); + bash = replaceOnce( + bash, + "--proto '=https' --proto-redir '=https' --tlsv1.2", + "--proto '=http' --proto-redir '=http'", + ); + bash = replaceOnce(bash, 'wget --https-only ', 'wget '); + fs.writeFileSync(installer, bash); + } + assert.deepEqual( + fs.readdirSync(source), + [installerName], + 'Start with the lone downloaded entrypoint', + ); + const install = (root) => + windows + ? run( + process.env.ComSpec || process.env.COMSPEC || 'cmd.exe', + ['/d', '/s', '/c', `""${installer}" -InstallRoot "${root}" -NoLaunch -NoShortcut"`], + { env, windowsVerbatimArguments: true }, + ) + : run('bash', [installer, '--root', root, '--no-launch', '--no-shortcut'], { env }); + const expected = windows ? [psPath, archivePath] : [archivePath]; + for (const name of [ + 'missing-archive', + 'corrupt-archive', + 'mismatched-archive', + ...(windows ? ['corrupt-powershell'] : []), + ]) { + scenario = { name, requests: [] }; + const root = path.join(fixture, `Rejected ${name}`); + const result = await install(root); + assert.notEqual(result.code, 0, `${name} must fail`); + assert.equal(result.signal, null, `${name} exits rather than crashing`); + if (name !== 'missing-archive') { + assert.match( + result.output, + name === 'corrupt-powershell' + ? /Installer checksum mismatch/ + : /Download checksum mismatch/, + `${name}: rejected by the pin before execution/extraction`, + ); + } + assertRequests(name === 'corrupt-powershell' ? [psPath] : expected); + assertNoActivation(root); + console.log(`Native download acceptance: ${name} rejected before activation.`); + } + + scenario = { name: 'success', requests: [] }; + const root = path.join(fixture, 'Verified Examify'); + const result = await install(root); + assert.equal(result.code, 0, `Real downloaded archive installs successfully:\n${result.output}`); + assertRequests(expected); + const marker = JSON.parse(fs.readFileSync(path.join(root, 'installation.json'), 'utf8')); + assert.equal(marker.version, distribution.version); + assert.equal(marker.protocol, 1); + assert.ok(marker.release.startsWith(`releases/${distribution.version}-`)); + assert.equal( + path.dirname(marker.release), + 'releases', + 'Activation points inside its release directory', + ); + const app = path.join(root, marker.release); + const release = JSON.parse(fs.readFileSync(path.join(app, 'desktop-release.json'), 'utf8')); + for (const key of [ + 'version', + 'platform', + 'sourceCommit', + 'lockfileSha256', + 'nodeVersion', + 'nodeArchiveSha256', + 'localPreview', + 'sourceDirty', + ]) { + assert.equal( + release[key], + distribution[key], + `Installed ${key} matches the verified distribution`, + ); + } + const runtime = path.join(app, windows ? 'runtime/node.exe' : 'runtime/bin/node'); + const native = await run(runtime, ['--version'], { env }); + assert.equal(native.code, 0, 'Installed private Node runs without a host Node on PATH'); + assert.equal(native.output.trim(), `v${distribution.nodeVersion}`); + assert.ok(fs.statSync(path.join(root, windows ? 'Examify.cmd' : 'Examify')).isFile()); + assert.deepEqual( + fs.readdirSync(source), + [installerName], + 'Bootstrap did not rely on a sibling archive or PowerShell file', + ); + assert.deepEqual( + verifyDistribution(artifacts, platform), + distribution, + 'Original distribution is unchanged', + ); + console.log( + `Native ${platform} download acceptance passed: pinned release paths, ${windows ? 'lone CMD and verified downloaded PowerShell, ' : ''}real archive activation and private Node. No host Node/Git/pnpm on installer PATH.`, + ); + console.log( + 'Loopback HTTP mirror only; GitHub HTTPS/redirects, download reputation/SmartScreen, shortcuts and browser/login behavior are not covered by this test.', + ); +} finally { + await Promise.all([...children].map(stop)); + if (server?.listening) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + fs.rmSync(fixture, { recursive: true, force: true }); +} diff --git a/tests/desktop/distribution.test.mjs b/tests/desktop/distribution.test.mjs new file mode 100644 index 0000000..9450406 --- /dev/null +++ b/tests/desktop/distribution.test.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { execFileSync } from 'node:child_process'; +import { + assetNames, + committedLockfileSha256, + assembleReleaseSet, + digest, + platforms, + verifyDistribution, + writeDistribution, + policyName, +} from '../../scripts/desktop/distribution.mjs'; +const repo = path.resolve(import.meta.dirname, '../..'); +function fixture(t, version = '0.1.0') { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-assets-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const platform of platforms) { + const dir = path.join(root, platform); + fs.mkdirSync(dir); + const archive = assetNames(version, platform).find((name) => /\.(zip|tar.gz)$/.test(name)); + fs.writeFileSync(path.join(dir, archive), `fixture ${platform}`); + const sha = digest(path.join(dir, archive)); + fs.writeFileSync(path.join(dir, `${archive}.sha256`), `${sha} ${archive}\n`); + fs.writeFileSync(path.join(dir, policyName(platform)), '{}\n'); + const installer = platform === 'win32-x64' ? 'install.ps1' : 'install-solo.sh'; + fs.writeFileSync( + path.join(dir, installer), + fs + .readFileSync(path.join(repo, installer), 'utf8') + .replaceAll('__EXAMIFY_VERSION__', version) + .replaceAll('__EXAMIFY_SHA256__', sha), + ); + if (platform === 'win32-x64') + fs.writeFileSync( + path.join(dir, 'install.cmd'), + fs + .readFileSync(path.join(repo, 'install.cmd'), 'utf8') + .replaceAll('__EXAMIFY_VERSION__', version) + .replaceAll('__EXAMIFY_INSTALL_PS_SHA256__', digest(path.join(dir, installer))), + ); + writeDistribution(dir, { + version, + platform, + upgradeProtocol: 1, + imageOptimization: 'disabled', + sourceCommit: 'a'.repeat(40), + lockfileSha256: 'b'.repeat(64), + nodeArchiveSha256: 'c'.repeat(64), + nodeVersion: '22.22.2', + sourceDirty: false, + localPreview: false, + }); + } + return root; +} +test('complete native set preserves identities/assets and refuses overwriting evidence', (t) => { + const root = fixture(t); + const output = path.join(root, 'candidate'); + const result = assembleReleaseSet(root, output); + assert.equal(result.assets.length, 13); + assert.equal(result.status, 'candidate-not-approved-for-publication'); + for (const asset of result.assets) + assert.equal(digest(path.join(output, asset.name)), asset.sha256); + assert.throws(() => assembleReleaseSet(root, output), /never overwrite/); +}); +for (const platform of platforms) { + test(`${platform}: tampering, missing assets and private extras are refused`, (t) => { + const root = fixture(t), + dir = path.join(root, platform), + name = platform === 'win32-x64' ? 'install.cmd' : 'install-solo.sh'; + const original = fs.readFileSync(path.join(dir, name)); + fs.appendFileSync(path.join(dir, name), 'changed'); + assert.throws(() => verifyDistribution(dir, platform), /checksum mismatch/); + fs.writeFileSync(path.join(dir, name), original); + fs.writeFileSync(path.join(dir, 'secrets.json'), '{}'); + assert.throws(() => verifyDistribution(dir, platform), /unexpected files/); + fs.unlinkSync(path.join(dir, 'secrets.json')); + fs.unlinkSync(path.join(dir, name)); + assert.throws(() => verifyDistribution(dir, platform), /unexpected files/); + }); + test(`${platform}: matching comment text and duplicate assignments do not satisfy pins`, (t) => { + const root = fixture(t), + dir = path.join(root, platform), + identity = verifyDistribution(dir, platform), + file = path.join(dir, platform === 'win32-x64' ? 'install.ps1' : 'install-solo.sh'), + original = fs.readFileSync(file, 'utf8'); + fs.writeFileSync(file, original.replace("'0.1.0'", "'9.9.9'") + "\n# '0.1.0'\n"); + assert.throws(() => writeDistribution(dir, identity), /does not pin/); + fs.writeFileSync( + file, + original + (platform === 'win32-x64' ? "\n$Version = '0.1.0'\n" : "\nVERSION='0.1.0'\n"), + ); + assert.throws(() => writeDistribution(dir, identity), /does not pin/); + }); +} +test('mixed source/version/Node, dirty previews and unordered versions cannot be RC sets', (t) => { + const root = fixture(t), + dir = path.join(root, 'linux-x64'), + identity = verifyDistribution(dir, 'linux-x64'); + for (const patch of [ + { sourceCommit: 'd'.repeat(40) }, + { sourceDirty: true }, + { localPreview: true }, + { nodeVersion: '22.23.0' }, + ]) { + writeDistribution(dir, { ...identity, ...patch }); + assert.throws(() => assembleReleaseSet(root, path.join(root, 'out')), /one clean source/); + } + const preview = fixture(t, 'ci-preview'); + assert.throws(() => assembleReleaseSet(preview, path.join(preview, 'out')), /stable numeric/); +}); +test('wrong CMD pins, escaping names and linked assets fail closed', (t) => { + assert.throws(() => assetNames('../escape', 'linux-x64')); + const root = fixture(t), + dir = path.join(root, 'win32-x64'), + identity = verifyDistribution(dir, 'win32-x64'); + fs.writeFileSync(path.join(dir, 'install.cmd'), '@echo off\n'); + assert.throws(() => writeDistribution(dir, identity), /bootstrap does not pin/); + const linux = path.join(root, 'linux-x64'), + file = path.join(linux, 'install-solo.sh'); + fs.renameSync(file, path.join(root, 'script')); + try { + fs.symlinkSync(path.join(root, 'script'), file); + } catch (error) { + if (error.code === 'EPERM') return t.skip('Symlink creation unavailable'); + throw error; + } + assert.throws(() => verifyDistribution(linux, 'linux-x64'), /regular file/); +}); + +test('lockfile source identity is stable across Windows checkout line endings', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-lock-identity-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + git('init', '--quiet'); + git('config', 'core.autocrlf', 'false'); + const file = path.join(root, 'pnpm-lock.yaml'); + fs.writeFileSync(file, 'lockfileVersion: 9\nsettings: {}\n'); + const expected = digest(file); + git('add', 'pnpm-lock.yaml'); + git( + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ); + fs.writeFileSync(file, 'lockfileVersion: 9\r\nsettings: {}\r\n'); + assert.notEqual(digest(file), expected); + assert.equal(committedLockfileSha256(root), expected); +}); diff --git a/tests/desktop/licenses.test.mjs b/tests/desktop/licenses.test.mjs new file mode 100644 index 0000000..20260a7 --- /dev/null +++ b/tests/desktop/licenses.test.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { retainRuntimeLicenses } from '../../scripts/desktop/licenses.mjs'; + +function write(file, value = '') { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, value); +} + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-runtime-licenses-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sourceNodeModules = path.join(root, 'repository/node_modules'); + const app = path.join(root, 'stage'); + fs.mkdirSync(sourceNodeModules, { recursive: true }); + fs.mkdirSync(app); + function pkg(name, { version = '1.2.3', shipped = true, suffix = '', license = 'MIT' } = {}) { + const relative = `.pnpm/${name.replace('/', '+')}@${version}${suffix}/node_modules/${name}`; + const source = path.join(sourceNodeModules, relative); + const staged = path.join(app, 'node_modules', relative); + const metadata = JSON.stringify({ name, version, license }); + write(path.join(source, 'package.json'), metadata); + write(path.join(source, 'index.js'), 'export const value = 1;'); + if (shipped) { + write(path.join(staged, 'package.json'), metadata); + write(path.join(staged, 'index.js'), 'export const value = 1;'); + } + return { source, staged, relative }; + } + const collect = (bundledInputs = []) => + retainRuntimeLicenses(app, { sourceNodeModules, bundledInputs }); + return { root, sourceNodeModules, app, pkg, collect }; +} + +test('retains exact manifests and omitted notices only for shipped packages', (t) => { + const f = fixture(t); + const a = f.pkg('@fixture/runtime', { suffix: '_peer@2.0.0' }); + const unused = f.pkg('build-only', { shipped: false }); + write(path.join(a.source, 'LICENSE.md'), 'Copyright fixture\nMIT license fixture\n'); + write(path.join(a.source, 'legal/NOTICE'), 'Third-party attribution\n'); + write(path.join(a.source, 'COPYING.LESSER'), 'License supplement\n'); + write(path.join(a.source, 'private-untraced.txt'), 'Never retained'); + write(path.join(a.source, '.env'), 'Never retained'); + write(path.join(unused.source, 'LICENSE'), 'Unshipped'); + const inventory = f.collect(); + assert.equal(inventory.packages.length, 1); + assert.equal(inventory.reviewRequired, false); + const record = inventory.packages[0]; + assert.equal(record.name, '@fixture/runtime'); + assert.equal(record.version, '1.2.3'); + assert.equal(record.declaredLicense, 'MIT'); + assert.deepEqual( + record.evidence.map((file) => file.source).sort(), + ['COPYING.LESSER', 'LICENSE.md', 'legal/NOTICE', 'package.json'].sort(), + ); + for (const file of record.evidence) { + const source = fs.readFileSync(path.join(a.source, file.source)); + assert.deepEqual(fs.readFileSync(path.join(f.app, file.path)), source); + assert.equal(file.sha256, createHash('sha256').update(source).digest('hex')); + } + const serialized = fs.readFileSync(path.join(f.app, 'THIRD-PARTY-LICENSES.json'), 'utf8'); + assert.equal(serialized.includes(f.root), false); + assert.equal(serialized.includes('build-only'), false); + assert.deepEqual(JSON.parse(serialized), inventory); + assert.throws(f.collect, /fresh staging destination/); +}); + +test('bundler inputs retain embedded dependencies without pulling in unused dev tools', (t) => { + const f = fixture(t); + const embedded = f.pkg('embedded', { shipped: false }); + f.pkg('unused-development-tool', { shipped: false }); + write(path.join(embedded.source, 'LICENSE'), 'Embedded upstream license'); + const appSource = path.join(f.root, 'repository/src/migrate.ts'); + write(appSource, 'application source'); + const inventory = f.collect([path.join(embedded.source, 'index.js'), appSource]); + assert.equal(inventory.packages.length, 1); + assert.deepEqual(inventory.packages[0].shippedPaths, []); + assert.deepEqual(inventory.packages[0].bundledInputs, ['index.js']); +}); + +test('Next compiled units retain upstream LEGAL files without inventing versions', (t) => { + const f = fixture(t); + const next = f.pkg('next'); + write(path.join(next.source, 'license.md'), 'Next license'); + const shipped = 'dist/compiled/@fixture/embedded'; + write( + path.join(next.source, shipped, 'package.json'), + JSON.stringify({ name: '@fixture/embedded', license: 'MIT' }), + ); + write(path.join(next.source, shipped, 'index.js.LEGAL.txt'), 'Bundled component legal text'); + write(path.join(next.staged, shipped, 'index.js'), 'bundled component code'); + write( + path.join(next.source, shipped, 'subcomponent/package.json'), + JSON.stringify({ name: 'nested-component', license: 'ISC' }), + ); + write(path.join(next.source, 'dist/compiled/unused/LICENSE'), 'Unused compiler license'); + const inventory = f.collect(); + assert.equal(inventory.compiledComponents.length, 1); + const record = inventory.compiledComponents[0]; + assert.equal(record.name, '@fixture/embedded'); + assert.equal(record.version, null); + assert.equal(record.parentPackage, inventory.packages[0].sourcePackage); + assert.deepEqual( + record.evidence.map((file) => file.source), + ['index.js.LEGAL.txt', 'package.json', 'subcomponent/package.json'], + ); + assert.equal(inventory.reviewRequired, false); + assert.equal(JSON.stringify(inventory).includes('unused'), false); +}); + +test('missing notices are explicitly unresolved, never fabricated from SPDX metadata', (t) => { + const f = fixture(t); + f.pkg('missing-notice', { license: 'Apache-2.0' }); + const inventory = f.collect(); + assert.equal(inventory.reviewRequired, true); + assert.equal(inventory.warnings.length, 1); + assert.match(inventory.warnings[0].reason, /not a substitute/); + assert.deepEqual( + inventory.packages[0].evidence.map((file) => file.kind), + ['manifest'], + ); + assert.match(inventory.scope, /Not a complete SBOM or a legal compliance certification/); +}); + +test('native library evidence retains libvips attribution and SQLite source notice', (t) => { + const f = fixture(t); + const vips = f.pkg('@img/sharp-libvips-linux-x64', { license: 'LGPL-3.0-or-later' }); + write(path.join(vips.source, 'README.md'), 'Upstream native licensing table'); + write(path.join(vips.source, 'versions.json'), JSON.stringify({ vips: '8.18.6' })); + const sqlite = f.pkg('better-sqlite3'); + write(path.join(sqlite.source, 'LICENSE'), 'MIT upstream notice'); + write(path.join(sqlite.source, 'deps/sqlite3/sqlite3.h'), '/* SQLite public domain header */\n'); + const inventory = f.collect(); + const sqliteRecord = inventory.packages.find((pkg) => pkg.name === 'better-sqlite3'); + assert.equal( + sqliteRecord.evidence.some((file) => file.kind === 'native-source-notice'), + true, + ); + const vipsRecord = inventory.packages.find((pkg) => pkg.name.startsWith('@img/')); + assert.deepEqual( + vipsRecord.evidence + .filter((file) => file.kind === 'native-attribution') + .map((file) => file.source), + ['README.md', 'versions.json'], + ); + assert.equal(inventory.reviewRequired, true); + assert.equal( + inventory.warnings.some((warning) => + /corresponding source\/license obligations/.test(warning.reason), + ), + true, + ); +}); + +test('mismatched source identity fails before emitting a partial notice set', (t) => { + const f = fixture(t); + const dep = f.pkg('dependency'); + write( + path.join(dep.source, 'package.json'), + JSON.stringify({ name: 'dependency', version: '9.9.9' }), + ); + assert.throws(f.collect, /identity does not match/); + assert.equal(fs.existsSync(path.join(f.app, 'licenses')), false); + assert.equal(fs.existsSync(path.join(f.app, 'THIRD-PARTY-LICENSES.json')), false); +}); + +test('empty notice placeholders do not satisfy license evidence', (t) => { + const f = fixture(t); + const dep = f.pkg('empty-notice'); + write(path.join(dep.source, 'LICENSE'), '\n \n'); + const inventory = f.collect(); + assert.equal(inventory.reviewRequired, true); + assert.deepEqual(inventory.packages[0].evidence.map((file) => file.kind).sort(), [ + 'empty-notice', + 'manifest', + ]); + assert.match(inventory.warnings[0].reason, /No standalone upstream license/); +}); + +test('a bundled input cannot escape through an external file link', (t) => { + const f = fixture(t); + const dep = f.pkg('embedded', { shipped: false }); + const outside = path.join(f.root, 'private-secret.js'); + write(outside, 'Must never read'); + const linked = path.join(dep.source, 'linked.js'); + try { + fs.symlinkSync(outside, linked, 'file'); + } catch (error) { + if (process.platform === 'win32' && error.code === 'EPERM') + return t.skip('File symlinks require Windows Developer Mode.'); + throw error; + } + assert.throws(() => f.collect([linked]), /Bundled input escapes/); + assert.equal(fs.existsSync(path.join(f.app, 'licenses')), false); +}); + +test('external package links are refused without exposing the external target', (t) => { + const f = fixture(t); + const dep = f.pkg('dependency'); + const outside = path.join(f.root, 'private-secrets'); + fs.renameSync(dep.source, outside); + fs.symlinkSync(outside, dep.source, process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(f.collect, (error) => { + assert.match(error.message, /exact in-tree pnpm package/); + assert.equal(error.message.includes('private-secrets'), false); + return true; + }); + assert.equal(fs.existsSync(path.join(f.app, 'licenses')), false); +}); + +test('linked notice files and directories cannot read outside the installation', (t) => { + for (const directory of [false, true]) { + const f = fixture(t); + const dep = f.pkg('dependency'); + const outside = path.join(f.root, 'private-secret'); + if (directory) { + write(path.join(outside, 'LICENSE'), 'Must never read'); + fs.symlinkSync( + outside, + path.join(dep.source, 'legal'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } else { + write(outside, 'Must never read'); + try { + fs.symlinkSync(outside, path.join(dep.source, 'LICENSE'), 'file'); + } catch (error) { + if (process.platform === 'win32' && error.code === 'EPERM') continue; + throw error; + } + } + assert.throws(f.collect, /Linked license/); + assert.equal(fs.existsSync(path.join(f.app, 'licenses')), false); + } +}); + +test('staging links and preexisting output are rejected before writing through them', (t) => { + const f = fixture(t); + const dep = f.pkg('dependency'); + fs.rmSync(dep.staged, { recursive: true }); + fs.symlinkSync(dep.source, dep.staged, process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(f.collect, /link-free staged runtime/); + const g = fixture(t); + const outside = path.join(g.root, 'external-output'); + fs.mkdirSync(outside); + fs.symlinkSync( + outside, + path.join(g.app, 'licenses'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + assert.throws(g.collect, /fresh staging destination/); + assert.deepEqual(fs.readdirSync(outside), []); +}); diff --git a/tests/desktop/portable-policy.test.mjs b/tests/desktop/portable-policy.test.mjs new file mode 100644 index 0000000..69972c7 --- /dev/null +++ b/tests/desktop/portable-policy.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import { + createRuntimePolicy, + validateRuntimePolicy, +} from '../../scripts/desktop/distribution-policy.mjs'; +import { loadSupplementalNotices } from '../../scripts/desktop/supplemental-licenses.mjs'; +const repo = path.resolve(import.meta.dirname, '../..'); +const identity = { version: '0.1.0', platform: 'linux-x64', sourceCommit: 'a'.repeat(40) }; +function fixture(t) { + const app = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-portable-policy-')); + t.after(() => fs.rmSync(app, { recursive: true, force: true })); + const put = (name, content) => { + const file = path.join(app, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + }; + const licenses = { + packages: [ + { name: 'better-sqlite3', version: '13.0.3' }, + { name: '@next/env', version: '16.3.6' }, + ], + warnings: [], + }; + put('server.js', 'const nextConfig = {"images":{"unoptimized":true}}\n'); + put('runtime-links.json', '[]'); + put('LICENSE', 'application fixture'); + put('runtime/LICENSE', 'runtime fixture'); + put('THIRD-PARTY-LICENSES.json', JSON.stringify(licenses)); + put( + 'node_modules/.pnpm/better-sqlite3@13.0.3/node_modules/better-sqlite3/build/Release/better_sqlite3.node', + 'native fixture', + ); + for (const file of loadSupplementalNotices(licenses.packages).files) + put(file.destination, file.bytes); + return { app, put, licenses }; +} +test('portable policy verifies actual serialized mode, native inventory and exact notice bytes', (t) => { + const { app, licenses } = fixture(t); + const policy = createRuntimePolicy(app, identity, licenses); + assert.equal(policy.imageOptimization, 'disabled'); + assert.equal(policy.supplementalDocuments.length, 2); + assert.doesNotThrow(() => validateRuntimePolicy(policy, identity)); + for (const patch of [ + { imageOptimization: 'enabled' }, + { sourceCommit: 'b'.repeat(40) }, + { packages: [...policy.packages, { name: 'sharp', version: '0.35.4' }] }, + { nativeBinaries: ['node_modules/libvips.so'] }, + { supplementalDocuments: [] }, + ]) + assert.throws(() => validateRuntimePolicy({ ...policy, ...patch }, identity)); +}); +test('metadata labels cannot hide a real optimizer or retained native package', (t) => { + const { app, put, licenses } = fixture(t); + put('server.js', 'const nextConfig = {"images":{"unoptimized":false}}\n'); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /actual standalone/); + put('server.js', 'const nextConfig = {"images":{"unoptimized":true}}\n'); + put('node_modules/@img/sharp-linux-x64/package.json', '{}'); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /image packages remain/); +}); +test('stale module links, unreviewed native libraries and altered notices are blocked', (t) => { + const { app, put, licenses } = fixture(t); + put( + 'runtime-links.json', + JSON.stringify([ + { path: 'node_modules/sharp', target: 'node_modules/.pnpm/sharp@0.35.4/node_modules/sharp' }, + ]), + ); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /links still reference/); + put('runtime-links.json', '[]'); + put('runtime/unreviewed.so', 'fixture'); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /Unreviewed bundled/); + fs.unlinkSync(path.join(app, 'runtime/unreviewed.so')); + const notice = loadSupplementalNotices(licenses.packages).documents[0]; + put(notice.path, 'changed'); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /notice is missing or changed/); +}); +test('default and hosted configuration are unchanged; portable tracing covers server and routes', () => { + const config = (value) => { + const env = { ...process.env }; + delete env.EXAMIFY_PORTABLE_BUILD; + if (value !== undefined) env.EXAMIFY_PORTABLE_BUILD = value; + return JSON.parse( + execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + "import config from './next.config.mjs'; console.log(JSON.stringify(config))", + ], + { cwd: repo, env, encoding: 'utf8' }, + ), + ); + }; + const original = config(), + disabled = config('0'), + portable = config('1'); + assert.deepEqual(original, disabled); + assert.equal(original.images, undefined); + assert.equal(original.outputFileTracingExcludes, undefined); + assert.equal(portable.images.unoptimized, true); + const { images: _images, outputFileTracingExcludes: exclusions, ...rest } = portable; + assert.deepEqual(rest, original); + const require = createRequire(import.meta.url), + picomatch = require('next/dist/compiled/picomatch'); + for (const entry of ['next-server', '/api/health', '/']) + assert.ok( + Object.keys(exclusions).some((pattern) => picomatch(pattern)(entry)), + `Exclusion applies to ${entry}`, + ); + for (const file of [ + 'node_modules/sharp/lib/index.js', + 'node_modules/@img/sharp-linux-x64/lib/sharp.node', + 'node_modules/.pnpm/sharp@0.35.4/node_modules/sharp/package.json', + 'node_modules/.pnpm/@img+sharp-libvips-linux-x64@1.3.3/node_modules/@img/sharp-libvips-linux-x64/lib/libvips.so', + ]) + assert.ok( + Object.values(exclusions) + .flat() + .some((pattern) => picomatch(pattern, { dot: true })(file)), + file, + ); +}); diff --git a/tests/desktop/supplemental-licenses.test.mjs b/tests/desktop/supplemental-licenses.test.mjs new file mode 100644 index 0000000..184c735 --- /dev/null +++ b/tests/desktop/supplemental-licenses.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { loadSupplementalNotices } from '../../scripts/desktop/supplemental-licenses.mjs'; +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-notices-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const documents = ['dotenv', 'dotenv-expand'].map((component) => { + const bytes = Buffer.from(`Fixture ${component}`), + file = `${component}-LICENSE`; + fs.writeFileSync(path.join(root, file), bytes); + return { + file, + sha256: createHash('sha256').update(bytes).digest('hex'), + sourceUrl: `https://example.invalid/${component}`, + associations: [ + { + package: '@next/env', + version: '16.3.6', + component, + componentVersion: component === 'dotenv' ? '16.3.1' : '10.0.0', + }, + ], + }; + }); + const write = () => + fs.writeFileSync( + path.join(root, 'provenance.json'), + JSON.stringify({ schemaVersion: 1, documents }), + ); + write(); + return { root, documents, write }; +} +test('exact shipped package and embedded component versions are required', (t) => { + const { root, documents, write } = fixture(t); + assert.equal( + loadSupplementalNotices([{ name: '@next/env', version: '16.3.6' }], { root }).files.length, + 2, + ); + assert.equal(loadSupplementalNotices([], { root }).files.length, 0); + assert.throws( + () => loadSupplementalNotices([{ name: '@next/env', version: '16.4.0' }], { root }), + /Missing exact-version/, + ); + documents[0].associations[0].componentVersion = '99.0.0'; + write(); + assert.throws( + () => loadSupplementalNotices([{ name: '@next/env', version: '16.3.6' }], { root }), + /Missing exact-version/, + ); +}); +test('tampering, missing coverage and escaping paths fail closed', (t) => { + const { root, documents, write } = fixture(t); + fs.appendFileSync(path.join(root, documents[0].file), 'changed'); + assert.throws(() => loadSupplementalNotices([], { root }), /bytes do not match/); + documents.shift(); + write(); + assert.throws( + () => loadSupplementalNotices([{ name: '@next/env', version: '16.3.6' }], { root }), + /Missing exact-version/, + ); + documents[0].file = '../outside'; + write(); + assert.throws(() => loadSupplementalNotices([], { root }), /Invalid supplemental/); +}); +test('nested evidence works but linked ancestors are rejected', (t) => { + const { root, documents, write } = fixture(t); + fs.mkdirSync(path.join(root, 'nested')); + const file = documents[0].file; + fs.renameSync(path.join(root, file), path.join(root, 'nested', file)); + documents[0].file = `nested/${file}`; + write(); + assert.equal( + loadSupplementalNotices([{ name: '@next/env', version: '16.3.6' }], { root }).files.length, + 2, + ); + fs.renameSync(path.join(root, 'nested'), path.join(root, 'other')); + try { + fs.symlinkSync(path.join(root, 'other'), path.join(root, 'nested'), 'dir'); + } catch (error) { + if (error.code === 'EPERM') return t.skip('Symlink unavailable'); + throw error; + } + assert.throws(() => loadSupplementalNotices([], { root }), /linked ancestors/); +}); +test('checked-in original notices match verified hashes and retain attribution', () => { + const result = loadSupplementalNotices([{ name: '@next/env', version: '16.3.6' }]); + assert.equal(result.documents.length, 2); + for (const file of result.files) assert.match(file.bytes.toString(), /Scott Motte/); +}); diff --git a/tests/desktop/windows-inventory.test.mjs b/tests/desktop/windows-inventory.test.mjs index d55d72e..94007c2 100644 --- a/tests/desktop/windows-inventory.test.mjs +++ b/tests/desktop/windows-inventory.test.mjs @@ -206,3 +206,52 @@ test('forbidden traced entries fail before their bytes are copied', (t) => { ); } }); + +test('portable copying omits only declared image links left after trace exclusion', (t) => { + const f = fixture(t); + for (const name of ['sharp', '@img/sharp-linux-x64']) + directoryLink( + path.join(f.sourceNodeModules, 'missing', name), + path.join(f.standalone, 'node_modules', name), + ); + assert.throws(() => f.copy(), /ENOENT/); + copyStandaloneRuntime(f.standalone, f.destination, { + sourceNodeModules: f.sourceNodeModules, + omitPortableImageLinks: true, + }); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(f.destination, 'runtime-links.json'), 'utf8')), + [], + ); + assert.equal( + assertReleaseInventory(f.destination).some((file) => file.includes('sharp')), + false, + ); +}); +test('portable link cleanup never conceals actual image package contents', (t) => { + const f = fixture(t); + f.pkg('sharp'); + assert.throws( + () => + copyStandaloneRuntime(f.standalone, f.destination, { + sourceNodeModules: f.sourceNodeModules, + omitPortableImageLinks: true, + }), + /Image package files remain/, + ); +}); +test('portable link cleanup does not relax unrelated module-link checks', (t) => { + const f = fixture(t); + directoryLink( + path.join(f.sourceNodeModules, 'missing'), + path.join(f.standalone, 'node_modules/unrelated'), + ); + assert.throws( + () => + copyStandaloneRuntime(f.standalone, f.destination, { + sourceNodeModules: f.sourceNodeModules, + omitPortableImageLinks: true, + }), + /ENOENT/, + ); +}); From 5e4ced04c513a183c2e0fbfd493463f8735e40fa Mon Sep 17 00:00:00 2001 From: McAtk <16798627+atk0309@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:23:10 -0700 Subject: [PATCH 2/3] Load compression types explicitly in Windows ZIP regression fixtures --- tests/desktop/archive-policy.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/desktop/archive-policy.test.mjs b/tests/desktop/archive-policy.test.mjs index 7cc9f94..bf55077 100644 --- a/tests/desktop/archive-policy.test.mjs +++ b/tests/desktop/archive-policy.test.mjs @@ -130,7 +130,8 @@ function fixture(t) { '-NoProfile', '-NonInteractive', '-Command', - `$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + + `$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.IO.Compression; ` + + `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + `$zip=[IO.Compression.ZipFile]::Open(${quote(archive)},[IO.Compression.ZipArchiveMode]::Update); ` + `try { $entry=$zip.CreateEntry(${quote(name)}); $entry.ExternalAttributes=[int]${attributes}; ` + `$writer=New-Object IO.StreamWriter($entry.Open()); try { $writer.Write('inert fixture'); } ` + From 2545f1b10e8c06eb4b991d4b8fe2238bcb43f06d Mon Sep 17 00:00:00 2001 From: McAtk <16798627+atk0309@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:08:31 -0700 Subject: [PATCH 3/3] Verify retained notice bytes and shipped identities; simplify portable license collection --- .github/workflows/desktop-preview.yml | 4 +- docs/release-candidate.md | 8 ++- scripts/desktop/distribution-policy.mjs | 54 ++++++++++++++++ scripts/desktop/licenses.mjs | 11 ---- scripts/desktop/package.mjs | 2 +- tests/desktop/archive-policy.test.mjs | 68 +++++++++++++++++++- tests/desktop/licenses.test.mjs | 20 +----- tests/desktop/portable-policy.test.mjs | 84 +++++++++++++++++++++++++ 8 files changed, 216 insertions(+), 35 deletions(-) diff --git a/.github/workflows/desktop-preview.yml b/.github/workflows/desktop-preview.yml index a5089ca..1396fdc 100644 --- a/.github/workflows/desktop-preview.yml +++ b/.github/workflows/desktop-preview.yml @@ -64,10 +64,10 @@ jobs: node --test tests/desktop/distribution.test.mjs tests/desktop/licenses.test.mjs tests/desktop/supplemental-licenses.test.mjs tests/desktop/portable-policy.test.mjs tests/desktop/archive-policy.test.mjs - name: Build from clean committed source run: node scripts/desktop/build.mjs - - name: Package native runtime with pinned Node archive verification + - name: Package native runtime from outside the repository (pinned Node verification) env: RC_VERSION: ${{ inputs.release_version }} - run: node -e "require('node:child_process').execFileSync(process.execPath,['scripts/desktop/package.mjs',process.env.RC_VERSION || 'ci-' + process.env.GITHUB_SHA],{stdio:'inherit'})" + run: node -e "require('node:child_process').execFileSync(process.execPath,[require('node:path').join(process.env.GITHUB_WORKSPACE,'scripts/desktop/package.mjs'),process.env.RC_VERSION || 'ci-' + process.env.GITHUB_SHA],{cwd:require('node:os').tmpdir(),stdio:'inherit'})" - name: Independently inspect the actual archive and required notice bytes run: node scripts/desktop/distribution-policy.mjs check build/desktop/${{ matrix.platform }} ${{ matrix.platform }} - name: Install acceptance browser on Linux diff --git a/docs/release-candidate.md b/docs/release-candidate.md index 4e4315c..59e5f7a 100644 --- a/docs/release-candidate.md +++ b/docs/release-candidate.md @@ -19,8 +19,12 @@ Packaging must verify the serialized standalone server really has optimization disabled, and inspect actual files, runtime links and package/native inventories. The distribution gate independently extracts the completed archive and repeats those checks, comparing internal identity/policy to its hashed sidecars. It also -checks exact supplemental notice bytes. CI cannot upload the app archive if this -gate or native acceptance fails. There is no environment/input bypass. +checks every retained evidence file against its recorded hash, compares shipped +package manifests with the inventory, and checks exact supplemental notice bytes. +Embedded-only helper dependencies are identified by their retained source manifest, +not an invented runtime directory. CI packages from outside the repository to test +bundle-input path resolution. CI cannot upload the app archive if this gate or native +acceptance fails. There is no environment/input bypass. The former libvips source/replacement gate is inapplicable only when the archive inspection proves those unused native image packages are absent. Reintroducing diff --git a/scripts/desktop/distribution-policy.mjs b/scripts/desktop/distribution-policy.mjs index c9721ee..54d2ad8 100644 --- a/scripts/desktop/distribution-policy.mjs +++ b/scripts/desktop/distribution-policy.mjs @@ -18,6 +18,59 @@ export function standaloneConfig(app) { throw new Error('Cannot verify the serialized standalone configuration.'); return JSON.parse(matches[0][1]); } +function verifyLicenseInventory(app, licenses, files) { + // The inventory walk has already rejected links and non-regular files. Membership + // binds every recorded path to a contained file, without a second filesystem walker. + const regularFiles = new Set(files); + const manifests = new Set(); + if (!Array.isArray(licenses.packages) || !Array.isArray(licenses.compiledComponents)) + throw new Error('Missing runtime license inventory records.'); + for (const record of [...licenses.packages, ...licenses.compiledComponents]) { + if (!Array.isArray(record.evidence)) throw new Error('Missing retained license evidence.'); + for (const evidence of record.evidence) { + if ( + !regularFiles.has(evidence.path) || + !/^[a-f0-9]{64}$/.test(evidence.sha256) || + digest(path.join(app, evidence.path)) !== evidence.sha256 + ) + throw new Error('Retained license evidence is missing or changed.'); + } + } + for (const record of licenses.packages) { + if ( + !Array.isArray(record.shippedPaths) || + !Array.isArray(record.bundledInputs) || + (!record.shippedPaths.length && !record.bundledInputs.length) + ) + throw new Error('Runtime package has no shipped or bundled provenance.'); + // Embedded helper dependencies have no runtime package directory. Their retained + // upstream manifest still identifies the source; do not invent a shipped path. + const retained = record.evidence.find( + (entry) => entry.kind === 'manifest' && entry.source === 'package.json', + ); + if (!retained) throw new Error('Runtime package lacks its retained manifest.'); + for (const file of [ + retained.path, + ...record.shippedPaths.map((base) => `${base}/package.json`), + ]) { + if (!regularFiles.has(file)) throw new Error('Shipped runtime package manifest is missing.'); + const manifest = JSON.parse(fs.readFileSync(path.join(app, file), 'utf8')); + if (manifest.name !== record.name || manifest.version !== record.version) + throw new Error('Actual runtime package identity differs from license inventory.'); + } + for (const base of record.shippedPaths) { + const file = `${base}/package.json`; + if (manifests.has(file)) throw new Error('Duplicate shipped runtime package manifest.'); + manifests.add(file); + } + } + for (const file of files) + if ( + /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^@/.][^/]*\/package\.json$/.test(file) && + !manifests.has(file) + ) + throw new Error('Shipped runtime package is missing from license inventory.'); +} export function createRuntimePolicy(app, identity, licenses) { if (standaloneConfig(app).images?.unoptimized !== true) throw new Error( @@ -32,6 +85,7 @@ export function createRuntimePolicy(app, identity, licenses) { links.some((link) => excludedPath(link.path) || excludedPath(link.target)) ) throw new Error('Portable runtime links still reference excluded image packages.'); + verifyLicenseInventory(app, licenses, files); const packages = licenses.packages.map(({ name, version }) => ({ name, version })); if (packages.some((pkg) => excludedName(pkg.name))) throw new Error('Native image package remains in the license inventory.'); diff --git a/scripts/desktop/licenses.mjs b/scripts/desktop/licenses.mjs index bf76f64..baba51a 100644 --- a/scripts/desktop/licenses.mjs +++ b/scripts/desktop/licenses.mjs @@ -223,17 +223,6 @@ export function retainRuntimeLicenses(app, { sourceNodeModules, bundledInputs = retain(record, 'package.json', 'manifest'); } collectNotices(record); - // The native package ships its dependency attribution table and exact versions in - // these files, but does not supply the corresponding complete license texts. - if (/^@img\/sharp-libvips-/.test(record.name)) { - for (const file of ['README.md', 'versions.json']) - if (fs.existsSync(path.join(record.root, file))) retain(record, file, 'native-attribution'); - warnings.push({ - package: record.sourcePackage, - reason: - 'Native libvips dependencies require review of the retained attribution table, versions and corresponding source/license obligations.', - }); - } if (record.name === 'better-sqlite3') { const header = 'deps/sqlite3/sqlite3.h'; if (fs.existsSync(path.join(record.root, header))) diff --git a/scripts/desktop/package.mjs b/scripts/desktop/package.mjs index 34cbb0e..97a89c4 100644 --- a/scripts/desktop/package.mjs +++ b/scripts/desktop/package.mjs @@ -130,7 +130,7 @@ try { const esbuild = require(require.resolve('esbuild', { paths: [require.resolve('tsx')] })); const bundledInputs = []; const bundle = async (options) => { - const result = await esbuild.build({ ...options, metafile: true }); + const result = await esbuild.build({ ...options, absWorkingDir: repo, metafile: true }); bundledInputs.push( ...Object.keys(result.metafile.inputs).map((name) => path.resolve(repo, name)), ); diff --git a/tests/desktop/archive-policy.test.mjs b/tests/desktop/archive-policy.test.mjs index bf55077..16c9068 100644 --- a/tests/desktop/archive-policy.test.mjs +++ b/tests/desktop/archive-policy.test.mjs @@ -68,8 +68,34 @@ function fixture(t) { { name: 'better-sqlite3', version: '13.0.3' }, { name: '@next/env', version: '16.3.6' }, ], + compiledComponents: [], warnings: [], }; + for (const record of licenses.packages) { + const base = `node_modules/.pnpm/${record.name.replace('/', '+')}@${record.version}/node_modules/${record.name}`; + const manifest = JSON.stringify({ name: record.name, version: record.version }); + const retained = `licenses/${record.name.replace('/', '-')}/package.json`; + put(`${base}/package.json`, manifest); + put(retained, manifest); + record.shippedPaths = [base]; + record.bundledInputs = []; + record.evidence = [ + { + kind: 'manifest', + source: 'package.json', + path: retained, + sha256: digest(path.join(app, retained)), + }, + ]; + const notice = `licenses/${record.name.replace('/', '-')}/LICENSE`; + put(notice, 'retained license fixture'); + record.evidence.push({ + kind: 'notice', + source: 'LICENSE', + path: notice, + sha256: digest(path.join(app, notice)), + }); + } put('server.js', 'const nextConfig = {"images":{"unoptimized":true}}\n'); put('runtime-links.json', '[]'); put('LICENSE', 'Application fixture license'); @@ -141,7 +167,19 @@ function fixture(t) { ); refreshDistribution(); } - return { root, app, out, archive, identity, policy, put, pack, appendZip, refreshDistribution }; + return { + root, + app, + out, + archive, + identity, + policy, + licenses, + put, + pack, + appendZip, + refreshDistribution, + }; } test( @@ -264,3 +302,31 @@ for (const [label, member, attributes] of [ }, ); } + +for (const change of [ + 'delete notice', + 'alter notice', + 'alter manifest name', + 'alter manifest version', +]) { + test(`refreshed outer pins cannot hide ${change}`, { skip: !supported }, (t) => { + const f = fixture(t); + const record = f.licenses.packages[0]; + const notice = record.evidence.find((entry) => entry.kind === 'notice').path; + if (change === 'delete notice') fs.unlinkSync(path.join(f.app, notice)); + else if (change === 'alter notice') f.put(notice, 'changed notice'); + else + f.put( + `${record.shippedPaths[0]}/package.json`, + json({ + name: change.endsWith('name') ? 'different' : record.name, + version: change.endsWith('version') ? '99.0.0' : record.version, + }), + ); + f.pack(); + assert.throws( + () => assertRedistributionReady(f.out, platform), + /license evidence is missing or changed|Actual runtime package identity/, + ); + }); +} diff --git a/tests/desktop/licenses.test.mjs b/tests/desktop/licenses.test.mjs index 20260a7..496a8ba 100644 --- a/tests/desktop/licenses.test.mjs +++ b/tests/desktop/licenses.test.mjs @@ -126,11 +126,8 @@ test('missing notices are explicitly unresolved, never fabricated from SPDX meta assert.match(inventory.scope, /Not a complete SBOM or a legal compliance certification/); }); -test('native library evidence retains libvips attribution and SQLite source notice', (t) => { +test('native library evidence retains the SQLite source notice', (t) => { const f = fixture(t); - const vips = f.pkg('@img/sharp-libvips-linux-x64', { license: 'LGPL-3.0-or-later' }); - write(path.join(vips.source, 'README.md'), 'Upstream native licensing table'); - write(path.join(vips.source, 'versions.json'), JSON.stringify({ vips: '8.18.6' })); const sqlite = f.pkg('better-sqlite3'); write(path.join(sqlite.source, 'LICENSE'), 'MIT upstream notice'); write(path.join(sqlite.source, 'deps/sqlite3/sqlite3.h'), '/* SQLite public domain header */\n'); @@ -140,20 +137,7 @@ test('native library evidence retains libvips attribution and SQLite source noti sqliteRecord.evidence.some((file) => file.kind === 'native-source-notice'), true, ); - const vipsRecord = inventory.packages.find((pkg) => pkg.name.startsWith('@img/')); - assert.deepEqual( - vipsRecord.evidence - .filter((file) => file.kind === 'native-attribution') - .map((file) => file.source), - ['README.md', 'versions.json'], - ); - assert.equal(inventory.reviewRequired, true); - assert.equal( - inventory.warnings.some((warning) => - /corresponding source\/license obligations/.test(warning.reason), - ), - true, - ); + assert.equal(inventory.reviewRequired, false); }); test('mismatched source identity fails before emitting a partial notice set', (t) => { diff --git a/tests/desktop/portable-policy.test.mjs b/tests/desktop/portable-policy.test.mjs index 69972c7..3392d74 100644 --- a/tests/desktop/portable-policy.test.mjs +++ b/tests/desktop/portable-policy.test.mjs @@ -9,6 +9,7 @@ import { createRuntimePolicy, validateRuntimePolicy, } from '../../scripts/desktop/distribution-policy.mjs'; +import { digest } from '../../scripts/desktop/distribution.mjs'; import { loadSupplementalNotices } from '../../scripts/desktop/supplemental-licenses.mjs'; const repo = path.resolve(import.meta.dirname, '../..'); const identity = { version: '0.1.0', platform: 'linux-x64', sourceCommit: 'a'.repeat(40) }; @@ -25,8 +26,34 @@ function fixture(t) { { name: 'better-sqlite3', version: '13.0.3' }, { name: '@next/env', version: '16.3.6' }, ], + compiledComponents: [], warnings: [], }; + for (const record of licenses.packages) { + const base = `node_modules/.pnpm/${record.name.replace('/', '+')}@${record.version}/node_modules/${record.name}`; + const manifest = JSON.stringify({ name: record.name, version: record.version }); + const retained = `licenses/${record.name.replace('/', '-')}/package.json`; + put(`${base}/package.json`, manifest); + put(retained, manifest); + record.shippedPaths = [base]; + record.bundledInputs = []; + record.evidence = [ + { + kind: 'manifest', + source: 'package.json', + path: retained, + sha256: digest(path.join(app, retained)), + }, + ]; + const notice = `licenses/${record.name.replace('/', '-')}/LICENSE`; + put(notice, 'retained license fixture'); + record.evidence.push({ + kind: 'notice', + source: 'LICENSE', + path: notice, + sha256: digest(path.join(app, notice)), + }); + } put('server.js', 'const nextConfig = {"images":{"unoptimized":true}}\n'); put('runtime-links.json', '[]'); put('LICENSE', 'application fixture'); @@ -126,3 +153,60 @@ test('default and hosted configuration are unchanged; portable tracing covers se file, ); }); + +test('embedded helper packages retain verified identity without invented runtime paths', (t) => { + const { app, put, licenses } = fixture(t); + const record = { + name: 'embedded-helper', + version: '1.0.0', + shippedPaths: [], + bundledInputs: ['index.js'], + evidence: [], + }; + const retained = 'licenses/embedded-helper/package.json'; + put(retained, JSON.stringify({ name: record.name, version: record.version })); + record.evidence.push({ + kind: 'manifest', + source: 'package.json', + path: retained, + sha256: digest(path.join(app, retained)), + }); + licenses.packages.push(record); + assert.doesNotThrow(() => createRuntimePolicy(app, identity, licenses)); + record.bundledInputs = []; + assert.throws( + () => createRuntimePolicy(app, identity, licenses), + /no shipped or bundled provenance/, + ); +}); + +test('compiled evidence paths must identify contained regular files with matching hashes', (t) => { + const { app, put, licenses } = fixture(t); + const file = 'licenses/compiled/LICENSE'; + put(file, 'compiled notice'); + const evidence = { path: file, sha256: digest(path.join(app, file)) }; + licenses.compiledComponents.push({ evidence: [evidence] }); + assert.doesNotThrow(() => createRuntimePolicy(app, identity, licenses)); + for (const invalid of ['../LICENSE', '/LICENSE', 'licenses/compiled', 'licenses/missing']) { + evidence.path = invalid; + assert.throws(() => createRuntimePolicy(app, identity, licenses), /license evidence/); + } + evidence.path = file; + put(file, 'altered compiled notice'); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /license evidence/); +}); + +test('actual shipped manifests cannot be omitted from the inventory or duplicated', (t) => { + const { app, put, licenses } = fixture(t); + put( + 'node_modules/undeclared/package.json', + JSON.stringify({ name: 'undeclared', version: '1.0.0' }), + ); + assert.throws( + () => createRuntimePolicy(app, identity, licenses), + /missing from license inventory/, + ); + fs.rmSync(path.join(app, 'node_modules/undeclared'), { recursive: true }); + licenses.packages.push(licenses.packages[0]); + assert.throws(() => createRuntimePolicy(app, identity, licenses), /Duplicate shipped/); +});