diff --git a/.github/workflows/desktop-preview.yml b/.github/workflows/desktop-preview.yml index 2501347..bb1f053 100644 --- a/.github/workflows/desktop-preview.yml +++ b/.github/workflows/desktop-preview.yml @@ -26,7 +26,7 @@ jobs: platform: win32-x64 browsers: 'C:\ExamifyBrowserFixture' runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 45 env: NEXT_TELEMETRY_DISABLED: '1' PLAYWRIGHT_BROWSERS_PATH: ${{ matrix.browsers }} @@ -45,7 +45,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Linux launcher security and packaging tests if: runner.os == 'Linux' - run: node --test tests/desktop/launcher.test.mjs tests/desktop/windows-inventory.test.mjs tests/desktop/fixture-http-provider.test.mjs + run: node --test tests/desktop/launcher.test.mjs tests/desktop/windows-inventory.test.mjs tests/desktop/fixture-http-provider.test.mjs tests/desktop/upgrades.test.mjs tests/desktop/worker-lock.test.mjs tests/desktop/relocated-helpers.test.mjs - name: Windows launcher security and packaging tests under a standard user if: runner.os == 'Windows' shell: powershell @@ -64,6 +64,9 @@ jobs: - name: Linux installer and packaged app acceptance if: runner.os == 'Linux' run: node tests/desktop/acceptance.mjs build/desktop/linux-x64 + - name: Linux native cross-version upgrade and crash acceptance + if: runner.os == 'Linux' + run: node tests/desktop/upgrade-acceptance.mjs build/desktop/linux-x64 - name: Windows Command Prompt installer and packaged app under a standard user if: runner.os == 'Windows' shell: powershell diff --git a/AGENTS.md b/AGENTS.md index 6d37e6e..cd053ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,9 @@ Applies to this repository. Examify is a small, mobile-first, self-hosted exam-practice app for personal study, with optional household sharing: Next.js, TypeScript and SQLite. Keep changes focused and portable. Windows/Linux solo packaging is a preview; -see docs/solo-installation.md before making installation claims. +see docs/solo-installation.md before making installation claims. Protocol-1 +portable upgrades use immutable app/state generations and one activation pointer; +never run inactive code against current study data or weaken worker lifetime locks. ## Start here diff --git a/docs/architecture.md b/docs/architecture.md index f26e3ea..276314d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1351,3 +1351,33 @@ A marker PID is diagnostic metadata, never proof of liveness: after a crash or PID reuse, an unheld instance lock permits safe marker recovery without a manual file deletion. These coordination files are outside learner data/config and must not be unlinked while an operation or instance is active. + +### Portable activation protocol 1 + +Protocol-1 installations select an immutable release slot and optional private +state generation through one `installation.json` pointer. Stable bootstrap v1 +selects the release runtime; the launcher revalidates the selected slot under the +operation lock before either reopening a live instance or reading its state. +Missing/corrupt selected state fails closed. Same-version repair creates another +immutable slot rather than renaming the active release away. + +The installer owns operation, instance and worker locks while copying. Each real +migration/server worker holds a separate OS-released lease and waits for its +parent’s READY/GO exchange before importing application code or reading settings. +An orphan worker prevents copying even if its parent has died. Migration timeouts +wait for worker exit before releasing launcher protection. + +Complete offline data/config copies retain SQLite sidecars. Copy manifests check +all bytes; links, special files and foreign-owned entries are refused. The old +state stays untouched; only the candidate is migrated and health-tested. After +all probe workers stop, candidate files are flushed and one pointer replacement +activates app and state together. No automatic post-commit fallback is allowed. +Old generations and failed candidates are retained rather than silently deleted. + +Only protocol-1 higher stable numeric versions can cross-upgrade. Legacy preview +repairs remain legacy; no household conversion, prerelease ordering, silent +downgrade or automatic old-code/new-schema combination is supported. Archive +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. diff --git a/docs/solo-installation.md b/docs/solo-installation.md index f289069..b411d5e 100644 --- a/docs/solo-installation.md +++ b/docs/solo-installation.md @@ -30,10 +30,12 @@ not run the unsubstituted installer templates directly from a source checkout. Keep the launcher terminal open while practicing. Ctrl+C stops the local service; closing the browser alone does not. Reopening the shortcut uses the existing running instance and opens a fresh browser session safely. There is no automatic -background service or silent updater in this preview. The installer supports a -fresh install and repair of the same pinned version, replacing app files only -from the newly verified archive. Stop the launcher before repairing. Automatic -cross-version upgrades are refused until their backup/rollback flow is validated. +background service or silent updater in this preview. The installer supports fresh installs and same-version repairs. Upgrade-capable +packages also support an explicit upgrade to a higher stable numeric version +(for example, `0.1.0` to `0.2.0`) using that version’s verified installer. Stop +the launcher first. The original preview format cannot be upgraded in place; +keep that installation and use a separate empty folder. A same-version preview +repair does not convert it into an upgrade-capable installation. Existing household upgrades still use `install.sh --upgrade`. ## What the launcher does @@ -137,21 +139,70 @@ On clean Windows and Linux machines, verify the actual distributed package: No paid AI calls or real credentials are needed for this acceptance checklist. -## Back up a solo installation - -Stop the launcher cleanly, then copy both the stable `data` and `config` folders -to a private backup location. Keep them together; `config` contains authentication -secrets and any saved provider keys. Do not post or share the backup. Copying a -live SQLite database without its journal is not a reliable backup. - -The small `.examify-operations` directory at the install root coordinates -launch/repair operations and tracks the running instance with OS-released locks; -it is outside learner data and configuration. Stale process-ID markers after a -crash do not require manual cleanup or prevent relaunch/repair. It is not -part of your study backup and must not be removed while an operation is running. - -The existing `examify:data` backup command was designed for household checkouts; -it does not yet include the solo launcher's separate `config/secrets.json` and -provider-settings directory. A data-only backup is not a full solo-installation -backup. Platform installers must retain these stable folders when replacing app -versions; do not uninstall/delete them if you want to keep your progress. +## Upgrade safely + +Run the new version-pinned installer against the same installation folder, with +Examify stopped. There is no automatic download or silent downgrade. CI preview +labels and prereleases are not ordered as upgrade versions. The first public +upgrade-capable package must have a version never used by a legacy preview. + +An upgrade or upgrade-capable same-version repair: + +1. Acquires OS-released installation, launcher and database-worker locks. PID + numbers alone are never evidence that an installation is stopped +2. Preserves the entire previous data/config generation, including SQLite WAL, + material, question banks, results, provider settings and authentication secrets +3. Makes a private, hash-checked candidate copy and requires free space for twice + the copied bytes plus a 128 MiB migration reserve, after package extraction +4. Migrates and starts the actual new application against the candidate, without + opening a browser. It stops all verification workers before activation +5. Replaces one installation pointer, selecting the complete matching app and + study-state generation together. The ordinary shortcut resolves that pointer + +Migration, startup or preactivation interruption leaves the previous pair +selected. Orphan candidate folders may remain; they are never automatically +adopted or deleted. Re-running the verified installer prepares a fresh candidate. +After activation, no automatic rollback occurs: newer work might already exist. +Directly running an inactive supported version refuses to open study data. + +Use a local filesystem owned by your ordinary OS account. Network shares, +cloud-synced installation folders, links/junctions, hard-linked study files and +shared/foreign-owned state are unsupported. Keep adequate disk space; the reserve +is a safety check, not a bound on every future migration’s requirements. If a +fresh install is interrupted before it creates its installation marker, use a +new empty folder. A damaged immutable bootstrap fails closed and needs recovery +into a separate folder; repair does not overwrite its running Node executable. + +The automated checks cover process interruption. They do not simulate physical +power failure or failing storage. In particular, Windows ordinary-user Node APIs +do not provide a proven durable directory-commit guarantee; keep an independent +backup before upgrading. This feature does not claim guaranteed recovery from +sudden power loss on Windows. + +## Back up or recover a solo installation + +Stop Examify and copy the **whole installation folder** to a private location. +This retains the installation pointer, matching application/runtime, all state +generations, configuration and recovery metadata. After an upgrade, `data` and +`config` at the root can be an older snapshot: copying only those folders can +miss current work. Keep backup copies private; they include authentication +secrets and saved provider keys. Never publish or share them. + +To recover, retain the damaged installation unchanged, restore the complete +known-good backup into a separate private folder, and launch its `Examify` or +`Examify.cmd` entrypoint. Do not overlay old app files onto a newer database or +manually change just the version field. Recovery to a pre-upgrade backup loses +changes made after that snapshot; preserve the newer installation for possible +recovery of that work. Never run two copies against the same data directory. + +The retained previous generation and its matching release provide a recovery +source, not a second automatically active installation. The previous pointer is +recorded in `installation.json`, and candidate `backup-source.json` records the +source snapshot’s hashes. Advanced selective recovery requires keeping that +matched pair together in a separate folder; there is no one-click rollback or +automatic old-generation cleanup in this release. + +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. diff --git a/install-solo.sh b/install-solo.sh index 1e47e1e..cd81a8e 100755 --- a/install-solo.sh +++ b/install-solo.sh @@ -46,7 +46,7 @@ if [ -d "$ROOT" ]; then fail 'The destination contains other files. Choose an empty dedicated Examify folder.' fi fi -for ENTRY in installation.json Examify releases config data "releases/$VERSION"; do +for ENTRY in installation.json Examify bootstrap releases config data "releases/$VERSION"; do [ ! -L "$ROOT/$ENTRY" ] || fail 'Existing installation paths must not be symbolic links.' done umask 077 @@ -76,13 +76,7 @@ NODE="$STAGE/app/runtime/bin/node" cp -- "$NODE" "$STAGE/install-node" chmod 700 "$STAGE/install-node" "$STAGE/install-node" "$STAGE/app/scripts/desktop/install-release.mjs" "$ROOT" "$STAGE/app" "$VERSION" -cat > "$ROOT/Examify" <<'SH' -#!/usr/bin/env bash -set -euo pipefail -ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -SH -printf 'exec "$ROOT/releases/%s/runtime/bin/node" "$ROOT/releases/%s/scripts/launcher.mjs" --root "$ROOT" "$@"\n' "$VERSION" "$VERSION" >> "$ROOT/Examify" -chmod 700 "$ROOT/Examify" +# The locked release coordinator installed the stable entrypoint before activation. if [ "$SHORTCUT" = 1 ]; then DESKTOP="${XDG_DATA_HOME:-$HOME/.local/share}/applications" mkdir -p "$DESKTOP" diff --git a/install.ps1 b/install.ps1 index cd98964..292e454 100644 --- a/install.ps1 +++ b/install.ps1 @@ -30,7 +30,7 @@ if (Test-Path -LiteralPath $InstallRoot) { throw 'The destination contains other files. Choose an empty dedicated Examify folder.' } } -foreach ($Relative in @('installation.json', 'Examify.cmd', 'releases', 'config', 'data', "releases/$Version")) { +foreach ($Relative in @('installation.json', 'Examify.cmd', 'bootstrap', 'releases', 'config', 'data', "releases/$Version")) { $Candidate = Join-Path $InstallRoot $Relative if ((Test-Path -LiteralPath $Candidate) -and ((Get-Item -LiteralPath $Candidate -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Existing installation paths must not be links or junctions.' @@ -87,12 +87,7 @@ try { & $InstallerNode (Join-Path $App 'scripts/desktop/install-release.mjs') $InstallRoot $App $Version if ($LASTEXITCODE -ne 0) { throw 'Could not safely install this release. Existing learner data was preserved.' } $Launcher = Join-Path $InstallRoot 'Examify.cmd' - @" -@echo off -setlocal -"%~dp0releases\$Version\runtime\node.exe" "%~dp0releases\$Version\scripts\launcher.mjs" --root "%~dp0." %* -if errorlevel 1 pause -"@ | Set-Content -Encoding ASCII -LiteralPath $Launcher + # The locked coordinator installed this stable entrypoint before activation. if (-not $NoShortcut) { $Shell = New-Object -ComObject WScript.Shell $Link = $Shell.CreateShortcut((Join-Path ([Environment]::GetFolderPath('Programs')) 'Examify.lnk')) diff --git a/scripts/desktop/acceptance-windows.ps1 b/scripts/desktop/acceptance-windows.ps1 index 7516a22..ea4faf7 100644 --- a/scripts/desktop/acceptance-windows.ps1 +++ b/scripts/desktop/acceptance-windows.ps1 @@ -32,6 +32,10 @@ try { $Result = Join-Path $Root 'result.txt' $Exit = Join-Path $Root 'exit.txt' $UnitTests = Join-Path $Repository 'tests/desktop/launcher.test.mjs' + $UpgradeTests = Join-Path $Repository 'tests/desktop/upgrades.test.mjs' + $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' $InventoryTests = Join-Path $Repository 'tests/desktop/windows-inventory.test.mjs' $UserTemp = Join-Path $Root 'UserTemp' $RunAcceptance = if ($TestsOnly) { '$false' } else { '$true' } @@ -52,7 +56,7 @@ try { `$env:TEMP = $(Quote $UserTemp) `$env:TMP = $(Quote $UserTemp) `$ErrorActionPreference = 'Continue' - & $(Quote $Node) --test $(Quote $UnitTests) $(Quote $InventoryTests) *> $(Quote $Result) + & $(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) { @@ -61,6 +65,12 @@ try { `$Code = `$LASTEXITCODE `$ErrorActionPreference = 'Stop' } + if (`$Code -eq 0 -and $RunAcceptance) { + `$ErrorActionPreference = 'Continue' + & $(Quote $Node) $(Quote $UpgradeAcceptance) $(Quote $Artifact) *>> $(Quote $Result) + `$Code = `$LASTEXITCODE + `$ErrorActionPreference = 'Stop' + } `$Code | Set-Content -LiteralPath $(Quote $Exit) exit `$Code } catch { diff --git a/scripts/desktop/dispatch.mjs b/scripts/desktop/dispatch.mjs new file mode 100644 index 0000000..71d5270 --- /dev/null +++ b/scripts/desktop/dispatch.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +/** Root-stable bootstrap. The v1 protocol and its copied runtime are immutable. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash, randomBytes } from 'node:crypto'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { acquireOperationLock } from './operation-lock.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const runtimeName = process.platform === 'win32' ? 'node.exe' : 'node'; +const bootstrapFiles = ['dispatch.mjs', 'operation-lock.mjs', 'private-path.ps1', runtimeName]; +const digest = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const releaseRuntime = process.platform === 'win32' ? 'runtime/node.exe' : 'runtime/bin/node'; +const validVersion = (value) => + typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(value); +function exists(file) { + try { + fs.lstatSync(file); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} +function checkAncestors(directory) { + let current = path.parse(path.resolve(directory)).root; + for (const part of path + .resolve(directory) + .slice(current.length) + .split(path.sep) + .filter(Boolean)) { + current = path.join(current, part); + if (!exists(current)) continue; + const stat = fs.lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Unsafe installation path.'); + } +} +function secureWindows(file) { + if (process.platform !== 'win32') return; + const result = spawnSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(here, 'private-path.ps1'), + '-Path', + file, + ], + { stdio: 'ignore', windowsHide: true, timeout: 15000 }, + ); + if (result.status !== 0) throw new Error('Could not secure private Examify bootstrap files.'); +} +function secureDirectory(directory) { + checkAncestors(directory); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(directory); + if (process.getuid && stat.uid !== process.getuid()) + throw new Error('Installation belongs to another user.'); + if (process.platform !== 'win32') fs.chmodSync(directory, 0o700); + else secureWindows(directory); + return directory; +} +function secureFile(file) { + checkAncestors(path.dirname(file)); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) + throw new Error('Unsafe bootstrap file.'); + if (process.getuid && stat.uid !== process.getuid()) + throw new Error('Installation belongs to another user.'); + if (process.platform !== 'win32' && stat.mode & 0o077) + throw new Error('Bootstrap files must be private.'); + secureWindows(file); +} + +function flushFile(file) { + const descriptor = fs.openSync(file, process.platform === 'win32' ? 'r+' : 'r'); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} +function flushDirectory(directory) { + if (process.platform !== 'win32') flushFile(directory); +} + +/** Caller holds the root operation lock. Must run BEFORE release activation. */ +export function installBootstrap({ + root, + staged, + secureDirectory: directory = secureDirectory, + secureFile: file = secureFile, +}) { + root = path.resolve(root); + staged = path.resolve(staged); + const parent = directory(path.join(root, 'bootstrap')); + const destination = path.join(parent, 'v1'); + checkAncestors(destination); + if (!exists(destination)) { + const temporary = fs.mkdtempSync(path.join(parent, '.v1-')); + try { + for (const name of ['dispatch.mjs', 'operation-lock.mjs', 'private-path.ps1']) { + const source = path.join(staged, 'scripts/desktop', name); + checkAncestors(path.dirname(source)); + const stat = fs.lstatSync(source); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('Incomplete bootstrap package.'); + fs.copyFileSync(source, path.join(temporary, name), fs.constants.COPYFILE_EXCL); + fs.chmodSync(path.join(temporary, name), 0o600); + flushFile(path.join(temporary, name)); + } + const sourceNode = path.join(staged, releaseRuntime); + checkAncestors(path.dirname(sourceNode)); + const stat = fs.lstatSync(sourceNode); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Invalid bootstrap runtime.'); + fs.copyFileSync(sourceNode, path.join(temporary, runtimeName), fs.constants.COPYFILE_EXCL); + fs.chmodSync(path.join(temporary, runtimeName), 0o700); + flushFile(path.join(temporary, runtimeName)); + fs.writeFileSync( + path.join(temporary, 'integrity.json'), + JSON.stringify({ + protocol: 1, + files: Object.fromEntries( + bootstrapFiles.map((name) => [name, digest(path.join(temporary, name))]), + ), + }) + '\n', + { mode: 0o600, flag: 'wx' }, + ); + flushFile(path.join(temporary, 'integrity.json')); + flushDirectory(temporary); + // An interrupted copy leaves only an unreferenced staging directory. + fs.renameSync(temporary, destination); + flushDirectory(parent); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + } + directory(destination); + // Never rewrite a running Windows executable, including during same-version repair. + for (const name of [...bootstrapFiles, 'integrity.json']) file(path.join(destination, name)); + const integrity = JSON.parse(fs.readFileSync(path.join(destination, 'integrity.json'), 'utf8')); + if ( + integrity?.protocol !== 1 || + !integrity.files || + bootstrapFiles.some((name) => integrity.files[name] !== digest(path.join(destination, name))) + ) + throw new Error( + 'The stable Examify bootstrap is damaged. Existing installation was not activated.', + ); + const entry = path.join(root, process.platform === 'win32' ? 'Examify.cmd' : 'Examify'); + if (exists(entry)) file(entry); + const contents = + process.platform === 'win32' + ? '@echo off\r\nsetlocal\r\n"%~dp0bootstrap\\v1\\node.exe" "%~dp0bootstrap\\v1\\dispatch.mjs" %*\r\nif errorlevel 1 pause\r\n' + : '#!/usr/bin/env bash\nset -euo pipefail\nROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"\nexec "$ROOT/bootstrap/v1/node" "$ROOT/bootstrap/v1/dispatch.mjs" "$@"\n'; + if (exists(entry) && fs.readFileSync(entry, 'utf8') === contents) return; + const temporaryEntry = path.join(root, `.entry-${randomBytes(8).toString('hex')}`); + try { + fs.writeFileSync(temporaryEntry, contents, { mode: 0o700, flag: 'wx' }); + file(temporaryEntry); + flushFile(temporaryEntry); + fs.renameSync(temporaryEntry, entry); + flushDirectory(root); + } finally { + fs.rmSync(temporaryEntry, { force: true }); + } +} + +/** Read the single commit record while serialized with installation operations. */ +export async function resolveLaunchTarget(root) { + root = path.resolve(root); + checkAncestors(root); + const unlock = await acquireOperationLock({ root, secureDirectory, secureFile }); + try { + const marker = path.join(root, 'installation.json'); + secureFile(marker); + let value; + try { + value = JSON.parse(fs.readFileSync(marker, 'utf8')); + } catch { + throw new Error('Invalid installation metadata. Reinstall the verified release.'); + } + if ( + !value || + value.app !== 'examify-solo' || + !validVersion(value.version) || + (value.state !== undefined && + (typeof value.state !== 'string' || !/^states\/[a-f0-9]{32}$/.test(value.state))) + ) + throw new Error('Invalid installation metadata. Reinstall the verified release.'); + if ( + value.previous !== undefined && + (!value.previous || + !validVersion(value.previous.version) || + (value.previous.state !== undefined && + (typeof value.previous.state !== 'string' || + !/^states\/[a-f0-9]{32}$/.test(value.previous.state)))) + ) + throw new Error('Invalid previous installation metadata.'); + if (value.protocol !== undefined && value.protocol !== 1) + throw new Error('Unsupported installation protocol.'); + const validRelease = (release, version) => + typeof release === 'string' && + release.startsWith(`releases/${version}-`) && + /^[a-f0-9]{32}$/.test(release.slice(`releases/${version}-`.length)); + if (value.protocol === 1 && !validRelease(value.release, value.version)) + throw new Error('Invalid active release path.'); + if (value.protocol === undefined && (value.release !== undefined || value.state !== undefined)) + throw new Error('Invalid legacy installation metadata.'); + if ( + value.previous?.release !== undefined && + !validRelease(value.previous.release, value.previous.version) + ) + throw new Error('Invalid previous release path.'); + const appRoot = path.join(root, value.release ?? `releases/${value.version}`); + checkAncestors(appRoot); + const runtime = path.join(appRoot, releaseRuntime); + const launcher = path.join(appRoot, 'scripts/launcher.mjs'); + // Release files need not be mode 0600; their root directory is private. + for (const target of [runtime, launcher]) { + checkAncestors(path.dirname(target)); + const stat = fs.lstatSync(target); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('Active release is incomplete. Reinstall the verified release.'); + } + return { version: value.version, appRoot, runtime, launcher }; + } finally { + unlock(); + } +} + +export async function dispatch({ root, args = [] }) { + if (args.length) throw new Error('Usage: Examify'); + const target = await resolveLaunchTarget(root); + // Release the operation lock first: launcher reacquires it and revalidates + // that its app directory is still selected by installation.json. + return await new Promise((resolve, reject) => { + const child = spawn(target.runtime, [target.launcher, '--root', path.resolve(root)], { + stdio: 'inherit', + windowsHide: false, + }); + const forward = (signal) => { + if (!child.killed) child.kill(signal); + }; + const onInterrupt = () => forward('SIGINT'); + const onTerminate = () => forward('SIGTERM'); + process.on('SIGINT', onInterrupt); + process.on('SIGTERM', onTerminate); + const cleanup = () => { + process.off('SIGINT', onInterrupt); + process.off('SIGTERM', onTerminate); + }; + child.once('error', (error) => { + cleanup(); + reject(error); + }); + child.once('exit', (code, signal) => { + cleanup(); + resolve(code ?? (signal === 'SIGINT' ? 130 : 1)); + }); + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + process.exitCode = await dispatch({ + root: path.resolve(here, '../..'), + args: process.argv.slice(2), + }); + } catch (error) { + console.error(error.message || 'Examify could not start.'); + process.exitCode = 1; + } +} diff --git a/scripts/desktop/inspect-state.ps1 b/scripts/desktop/inspect-state.ps1 new file mode 100644 index 0000000..f03a0a9 --- /dev/null +++ b/scripts/desktop/inspect-state.ps1 @@ -0,0 +1,49 @@ +# Read-only ownership/reparse validation of a private state snapshot source. +# Never normalize source ACLs: a failed upgrade must leave its source unchanged. +param([Parameter(Mandatory = $true)][string]$Path) +$ErrorActionPreference = 'Stop' +$PSModuleAutoLoadingPreference = 'None' +try { + $Sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $Sections = [Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner + function Inspect-Entry([string]$Entry, [bool]$RequireDirectory) { + $Attributes = [IO.File]::GetAttributes($Entry) + if (($Attributes -band [IO.FileAttributes]::ReparsePoint) -or ($Attributes -band [IO.FileAttributes]::Device)) { throw 'unsafe' } + $Directory = [bool]($Attributes -band [IO.FileAttributes]::Directory) + if ($RequireDirectory -and -not $Directory) { throw 'directory' } + $Item = if ($Directory) { [IO.DirectoryInfo]::new($Entry) } else { [IO.FileInfo]::new($Entry) } + $Acl = $Item.GetAccessControl($Sections) + if ($Acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $Sid.Value) { throw 'owner' } + return $Item + } + # Validate the exact root, then only data/config. Coordination files and app + # releases are intentionally outside this state-validation traversal. + $Root = Inspect-Entry ([IO.Path]::GetFullPath($Path)) $true + $Pending = [Collections.Generic.Stack[string]]::new() + foreach ($Name in @('data', 'config')) { + $Candidate = [IO.Path]::Combine($Root.FullName, $Name) + try { $null = [IO.File]::GetAttributes($Candidate) } + catch { + # Missing fresh-install folders are allowed; access/I/O failures + # must never be treated as absence by Directory.Exists/File.Exists. + $Cause = $_.Exception + while ($Cause.InnerException) { $Cause = $Cause.InnerException } + if (($Cause -is [IO.FileNotFoundException]) -or ($Cause -is [IO.DirectoryNotFoundException])) { continue } + throw + } + $null = Inspect-Entry $Candidate $true + $Pending.Push($Candidate) + } + while ($Pending.Count -gt 0) { + $Directory = Inspect-Entry ($Pending.Pop()) $true + foreach ($Child in $Directory.GetFileSystemInfos()) { + $Checked = Inspect-Entry $Child.FullName $false + if ($Checked -is [IO.DirectoryInfo]) { $Pending.Push($Checked.FullName) } + } + } + exit 0 +} catch { + # No learner filenames, saved settings or raw OS diagnostics in logs. + [Console]::Error.WriteLine('Examify cannot verify the private study source. Existing data was preserved.') + exit 1 +} diff --git a/scripts/desktop/install-release.mjs b/scripts/desktop/install-release.mjs index aa95fdd..144a363 100644 --- a/scripts/desktop/install-release.mjs +++ b/scripts/desktop/install-release.mjs @@ -4,8 +4,26 @@ import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { assertExistingAncestors, privateDirectory, privateFile } from '../launcher.mjs'; -import { acquireOperationLock, tryAcquireInstanceLock } from './operation-lock.mjs'; +import { + acquireOperationLock, + tryAcquireInstanceLock, + tryAcquireWorkerLock, +} from './operation-lock.mjs'; import { restoreRuntimeLinks } from './restore-links.mjs'; +import { installBootstrap } from './dispatch.mjs'; +import { probeRelease } from './upgrade-probe.mjs'; +import { + readInstallation, + assertStateComplete, + inventoryState, + copyState, + requireSpace, + assertUpgradeVersion, + durableJson, + syncTree, + syncRelease, + syncDirectory, +} from './state-store.mjs'; function exists(file) { try { @@ -25,7 +43,16 @@ function readMetadata(file) { } /** Called only by the checksum-verified installer, using its verified temporary Node. */ -export async function installRelease({ root, staged, version, prepare = restoreRuntimeLinks }) { +export async function installRelease({ + root, + staged, + version, + prepare = restoreRuntimeLinks, + probe = probeRelease, + bootstrap = installBootstrap, + onPhase = () => {}, + helperOptions = {}, +}) { root = path.resolve(root); staged = path.resolve(staged); if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(version)) throw new Error('Invalid release version.'); @@ -43,17 +70,31 @@ export async function installRelease({ root, staged, version, prepare = restoreR release.nodeVersion !== process.versions.node ) throw new Error('Wrong release/runtime.'); + // Imported modules keep their original URLs after staging is renamed. Keep + // helper lookup local to this transaction, and rebase it with the app tree. + // Tests importing checkout code may stage only metadata, so their helpers + // deliberately remain beside the imported installer. + let helperDirectory = path.dirname(fileURLToPath(import.meta.url)); + const helperRelative = path.relative(staged, helperDirectory); + const helpersAreStaged = + helperRelative !== '..' && + !helperRelative.startsWith(`..${path.sep}`) && + !path.isAbsolute(helperRelative); + const options = () => ({ ...helperOptions, helperDirectory }); + const secureDirectory = (dir) => privateDirectory(dir, options()); + const secureFile = (file) => privateFile(file, options()); const unlock = await acquireOperationLock({ root, - secureDirectory: privateDirectory, - secureFile: privateFile, + secureDirectory, + secureFile, }); let releaseInstance; + let releaseWorker; try { releaseInstance = await tryAcquireInstanceLock({ root, - secureDirectory: privateDirectory, - secureFile: privateFile, + secureDirectory, + secureFile, }); if (!releaseInstance) throw new Error( @@ -61,16 +102,111 @@ export async function installRelease({ root, staged, version, prepare = restoreR ); const runningPath = path.join(root, 'running.json'); if (exists(runningPath)) { - privateFile(runningPath); + secureFile(runningPath); // Marker PIDs are diagnostic only: they can be reused after a crash. } + releaseWorker = await tryAcquireWorkerLock({ + root, + secureDirectory, + secureFile, + }); + if (!releaseWorker) + throw new Error('Examify is running. A database worker is still stopping; retry shortly.'); const marker = path.join(root, 'installation.json'); - if (exists(marker)) { - privateFile(marker); - const previous = readMetadata(marker); - if (previous.app !== 'examify-solo' || previous.version !== version) - throw new Error('Different or unknown installation. Automatic upgrades are not enabled.'); + const previous = readInstallation(root, secureFile); + if (previous && previous.version !== version) { + if (previous.protocol !== 1 || release.upgradeProtocol !== 1) + throw new Error( + 'This preview installation cannot be upgraded in place. Keep it and install into a separate empty folder.', + ); + assertUpgradeVersion(previous.version, version); + } + if (!previous || previous.protocol === 1) { + if (release.upgradeProtocol !== 1) + throw new Error('The package does not support safe activation.'); + const releases = secureDirectory(path.join(root, 'releases')); + const releaseId = `${version}-${randomBytes(16).toString('hex')}`; + const destination = path.join(releases, releaseId); + await prepare(staged, { installedRoot: destination }); + await bootstrap({ root, staged, secureDirectory, secureFile }); + fs.renameSync(staged, destination); + if (helpersAreStaged) helperDirectory = path.join(destination, helperRelative); + // Application links are package-verified and may be junctions. fsync each + // regular file without following them; the package was staged privately. + syncRelease(destination); + syncDirectory(releases); + let state; + let candidate; + if (previous) { + const source = assertStateComplete(root, previous); + assertExistingAncestors(source); + const entries = inventoryState(source, options()); + requireSpace(root, entries); + const states = secureDirectory(path.join(root, 'states')); + state = `states/${randomBytes(16).toString('hex')}`; + candidate = path.join(root, state); + copyState(source, candidate, entries, secureDirectory, options()); + durableJson(path.join(candidate, 'backup-source.json'), { + installation: previous, + files: entries, + }); + onPhase('state-copied'); + await probe({ + root: candidate, + appDir: destination, + node: path.join( + destination, + 'runtime', + process.platform === 'win32' ? 'node.exe' : 'bin/node', + ), + }); + const lockOptions = { + root: candidate, + secureDirectory, + secureFile, + }; + const candidateInstance = await tryAcquireInstanceLock(lockOptions); + if (!candidateInstance) throw new Error('Upgrade verification has not stopped.'); + let candidateWorker; + try { + candidateWorker = await tryAcquireWorkerLock(lockOptions); + if (!candidateWorker) throw new Error('Upgrade database verification has not stopped.'); + assertStateComplete(candidate, { initialized: true }); + inventoryState(candidate, options()); // Revalidate migrated files and config. + syncTree(candidate); + syncDirectory(states); + } finally { + candidateWorker?.(); + candidateInstance(); + } + } + const next = { + app: 'examify-solo', + protocol: 1, + version, + release: `releases/${releaseId}`, + ...(state ? { state } : {}), + ...(previous + ? { + previous: { + app: previous.app, + protocol: previous.protocol, + version: previous.version, + initialized: previous.initialized, + release: previous.release, + ...(previous.state ? { state: previous.state } : {}), + }, + } + : {}), + }; + onPhase('before-activate'); + durableJson(marker, next); + // After this point never auto-rollback: a fresh launch may save new work. + onPhase('activated'); + return next; } + // Legacy same-version repair stays a legacy installation. It never gains + // an upgrade protocol or state pointer merely by replacing its app files. const releases = path.join(root, 'releases'); assertExistingAncestors(releases); fs.mkdirSync(releases, { recursive: true, mode: 0o700 }); @@ -82,6 +218,7 @@ export async function installRelease({ root, staged, version, prepare = restoreR await prepare(staged, { installedRoot: destination }); const temporaryMarker = path.join(root, `.installation-${randomBytes(8).toString('hex')}.json`); const needsMarker = !exists(marker); + await bootstrap({ root, staged, secureDirectory, secureFile }); if (needsMarker) fs.writeFileSync(temporaryMarker, `${JSON.stringify({ app: 'examify-solo', version })}\n`, { mode: 0o600, @@ -107,6 +244,7 @@ export async function installRelease({ root, staged, version, prepare = restoreR } if (movedOld) fs.rmSync(backup, { recursive: true, force: true }); } finally { + releaseWorker?.(); releaseInstance?.(); unlock(); } diff --git a/scripts/desktop/operation-lock.mjs b/scripts/desktop/operation-lock.mjs index 868d7fe..f0ab57c 100644 --- a/scripts/desktop/operation-lock.mjs +++ b/scripts/desktop/operation-lock.mjs @@ -71,3 +71,8 @@ export function acquireOperationLock(options) { export function tryAcquireInstanceLock(options) { return acquireLock(options, 'instance.sqlite', false); } + +/** Independent child lifetime lease, including migrations and orphaned servers. */ +export function tryAcquireWorkerLock(options) { + return acquireLock(options, 'worker.sqlite', false); +} diff --git a/scripts/desktop/package.mjs b/scripts/desktop/package.mjs index 13e57f8..47cd8f2 100644 --- a/scripts/desktop/package.mjs +++ b/scripts/desktop/package.mjs @@ -100,7 +100,16 @@ try { path.join(repo, 'scripts/desktop/restore-links.mjs'), path.join(app, 'scripts/desktop/restore-links.mjs'), ); - for (const name of ['operation-lock.mjs', 'install-release.mjs']) + for (const name of [ + 'operation-lock.mjs', + 'dispatch.mjs', + 'install-release.mjs', + 'state-store.mjs', + 'inspect-state.ps1', + 'upgrade-probe.mjs', + 'worker-guard.cjs', + 'worker-runner.mjs', + ]) copy(path.join(repo, 'scripts/desktop', name), path.join(app, 'scripts/desktop', name)); copy(path.join(repo, 'LICENSE'), path.join(app, 'LICENSE')); // tsx pins its esbuild dependency in pnpm-lock; avoid fetching a build-time tool. @@ -185,6 +194,7 @@ try { version, platform, nodeVersion: pins.version, + upgradeProtocol: 1, nodeArchiveSha256: pin.sha256, sourceCommit, localPreview, diff --git a/scripts/desktop/settings-loader.cjs b/scripts/desktop/settings-loader.cjs index 8610537..1e6938f 100644 --- a/scripts/desktop/settings-loader.cjs +++ b/scripts/desktop/settings-loader.cjs @@ -1,3 +1,5 @@ +// Fail closed before reading configuration, including when loaded independently. +require('./worker-guard.cjs'); // Load only user-selected AI settings after exec, so env-store can rotate them. // Never import the shell's provider credentials or runtime security settings. const fs = require('node:fs'); @@ -36,6 +38,3 @@ for (const name of ['.env', '.env.local']) { for (const [key, value] of Object.entries(saved)) { if (process.env[key] === undefined) process.env[key] = value; } - -// A closed/crashed launcher must not leave a detached database-owning server behind. -if (process.connected) process.once('disconnect', () => process.exit(0)); diff --git a/scripts/desktop/state-store.mjs b/scripts/desktop/state-store.mjs new file mode 100644 index 0000000..461c17c --- /dev/null +++ b/scripts/desktop/state-store.mjs @@ -0,0 +1,260 @@ +/** Private immutable state generations. The installation pointer is the only commit record. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash, randomBytes } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const VERSION = /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/; +export function validateInstallation(value) { + if ( + !value || + value.app !== 'examify-solo' || + typeof value.version !== 'string' || + !VERSION.test(value.version) + ) + throw new Error('Invalid installation metadata.'); + if (value.protocol !== undefined && value.protocol !== 1) + throw new Error('Unsupported installation protocol.'); + if (value.protocol === 1) { + if ( + value.release !== `releases/${value.version}-${String(value.release).split('-').at(-1)}` || + !/^[a-f0-9]{32}$/.test(String(value.release).split('-').at(-1)) + ) + throw new Error('Invalid release pointer.'); + if ( + value.state !== undefined && + (typeof value.state !== 'string' || !/^states\/[a-f0-9]{32}$/.test(value.state)) + ) + throw new Error('Invalid state pointer.'); + } else if (value.state !== undefined || value.release !== undefined) { + throw new Error('Legacy installations cannot select state generations.'); + } + if (value.initialized !== undefined && typeof value.initialized !== 'boolean') + throw new Error('Invalid initialization marker.'); + return value; +} +export function readInstallation(root, secureFile) { + const file = path.join(root, 'installation.json'); + try { + secureFile(file); + } catch (error) { + if (error.code === 'ENOENT') return undefined; + throw error; + } + return validateInstallation(JSON.parse(fs.readFileSync(file, 'utf8'))); +} +export const stateRoot = (root, marker) => (marker?.state ? path.join(root, marker.state) : root); +export const releaseRoot = (root, marker) => + path.join(root, marker.release ?? `releases/${marker.version}`); + +export function assertUpgradeVersion(previous, next) { + const parse = (value) => { + const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value); + if (!match) throw new Error('Cross-version upgrades require stable numeric release versions.'); + return match.slice(1).map(BigInt); + }; + const a = parse(previous); + const b = parse(next); + for (let i = 0; i < 3; i += 1) { + if (b[i] > a[i]) return; + if (b[i] < a[i]) break; + } + throw new Error( + 'Downgrades are not supported. Restore a matched application and private backup separately.', + ); +} + +export function syncDirectory(directory) { + // Windows does not expose a portable directory FlushFileBuffers via Node. + // Files are flushed and replace is atomic, but sudden storage/power loss is + // not claimed recoverable there; never emulate replace with delete + rename. + if (process.platform === 'win32') return; + const fd = fs.openSync(directory, 'r'); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} +export function durableJson(file, value) { + const temporary = `${file}.${randomBytes(16).toString('hex')}.tmp`; + const fd = fs.openSync(temporary, 'wx', 0o600); + try { + fs.writeFileSync(fd, `${JSON.stringify(value)}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(temporary, file); + syncDirectory(path.dirname(file)); +} + +function inspect(file, directory) { + const stat = fs.lstatSync(file); + if ( + stat.isSymbolicLink() || + (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) || + (process.getuid && stat.uid !== process.getuid()) + ) + throw new Error( + 'Unsafe file in private study state. Links and special files are not supported.', + ); + return stat; +} +export function inventoryState( + root, + { + helperDirectory = path.dirname(fileURLToPath(import.meta.url)), + platform = process.platform, + spawn = spawnSync, + } = {}, +) { + if (platform === 'win32') { + const result = spawn( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(helperDirectory, 'inspect-state.ps1'), + '-Path', + root, + ], + { stdio: 'ignore', windowsHide: true, timeout: 120000 }, + ); + if (result.status !== 0) + throw new Error( + 'Study state must be owned by this Windows user and contain no reparse points.', + ); + } + const entries = []; + function visit(directory, relative) { + inspect(directory, true); + for (const name of fs.readdirSync(directory).sort()) { + const file = path.join(directory, name); + const rel = `${relative}/${name}`; + const stat = fs.lstatSync(file); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + inspect(file, true); + entries.push({ path: rel, directory: true }); + visit(file, rel); + } else { + inspect(file, false); + entries.push({ + path: rel, + bytes: stat.size, + sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), + }); + } + } + } + for (const name of ['data', 'config']) { + const directory = path.join(root, name); + try { + fs.lstatSync(directory); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + entries.push({ path: name, directory: true }); + visit(directory, name); + } + return entries; +} +export function copyState(source, destination, entries, secureDirectory, helperOptions) { + secureDirectory(destination); + for (const item of entries) { + const from = path.join(source, item.path); + const to = path.join(destination, item.path); + if (item.directory) { + inspect(from, true); + secureDirectory(to); + continue; + } + inspect(from, false); + const input = fs.openSync(from, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + let output; + try { + const stat = fs.fstatSync(input); + if (!stat.isFile() || stat.nlink !== 1 || (process.getuid && stat.uid !== process.getuid())) + throw new Error('Unsafe source file.'); + output = fs.openSync(to, 'wx', 0o600); + const hash = createHash('sha256'); + const buffer = Buffer.alloc(65536); + let size = 0; + let count; + while ((count = fs.readSync(input, buffer, 0, buffer.length, null))) { + hash.update(buffer.subarray(0, count)); + size += count; + let offset = 0; + while (offset < count) offset += fs.writeSync(output, buffer, offset, count - offset); + } + if (size !== item.bytes || hash.digest('hex') !== item.sha256) + throw new Error('Study files changed during backup. Stop all Examify processes and retry.'); + fs.fsyncSync(output); + } finally { + fs.closeSync(input); + if (output !== undefined) fs.closeSync(output); + } + } + if (JSON.stringify(inventoryState(source, helperOptions)) !== JSON.stringify(entries)) + throw new Error('Study files changed during backup.'); + syncTree(destination); +} +export function syncTree(root) { + for (const name of fs.readdirSync(root)) { + const file = path.join(root, name); + const stat = fs.lstatSync(file); + if (stat.isDirectory() && !stat.isSymbolicLink()) syncTree(file); + else { + inspect(file, false); + const fd = fs.openSync(file, process.platform === 'win32' ? 'r+' : 'r'); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + } + } + syncDirectory(root); +} +export function requireSpace(root, entries) { + const needed = + entries.reduce((sum, item) => sum + BigInt(item.bytes ?? 0), 0n) * 2n + 128n * 1024n * 1024n; + const stat = fs.statfsSync(root, { bigint: true }); + if (stat.bavail * stat.bsize < needed) + throw new Error('Not enough free space for a private upgrade copy and migration reserve.'); +} + +export function syncRelease(root) { + for (const name of fs.readdirSync(root)) { + const file = path.join(root, name); + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink()) continue; // Only verified package runtime links; do not follow. + if (stat.isDirectory()) syncRelease(file); + else { + const fd = fs.openSync(file, process.platform === 'win32' ? 'r+' : 'r'); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + } + } + syncDirectory(root); +} + +export function assertStateComplete(root, marker) { + const selected = stateRoot(root, marker); + inspect(selected, true); + if (marker?.state || marker?.initialized) { + for (const name of ['data', 'config']) inspect(path.join(selected, name), true); + for (const name of ['data/app.db', 'config/secrets.json']) + inspect(path.join(selected, name), false); + } + return selected; +} diff --git a/scripts/desktop/upgrade-probe.mjs b/scripts/desktop/upgrade-probe.mjs new file mode 100644 index 0000000..4da89cf --- /dev/null +++ b/scripts/desktop/upgrade-probe.mjs @@ -0,0 +1,59 @@ +/** Test candidate migration and startup using the real packaged launcher, without opening a browser. */ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startLauncher, childEnvironment } from '../launcher.mjs'; +export async function probeRelease({ root, appDir, node }) { + await new Promise((resolve, reject) => { + const child = spawn( + node, + [path.join(appDir, 'scripts/desktop/upgrade-probe.mjs'), root, appDir], + { + cwd: appDir, + env: childEnvironment(), + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + windowsHide: true, + }, + ); + const timer = setTimeout(() => child.kill('SIGKILL'), 150000); + child.once('error', () => { + clearTimeout(timer); + reject(new Error('Upgrade startup verification could not run.')); + }); + child.once('exit', (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else + reject( + new Error( + 'Upgrade migration or startup verification failed. The previous installation is unchanged.', + ), + ); + }); + }); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + let runtime; + let disconnected = !process.connected; + const onDisconnect = () => { + disconnected = true; + if (runtime) void runtime.stop().then(() => process.exit(1)); + }; + process.on('disconnect', onDisconnect); + try { + if (disconnected) throw new Error('Installer disconnected.'); + runtime = await startLauncher({ + root: process.argv[2], + appDir: process.argv[3], + browser: () => {}, + timeout: 60000, + }); + await runtime.stop(); + if (disconnected) throw new Error('Installer disconnected.'); + process.off('disconnect', onDisconnect); + process.disconnect(); + } catch { + process.exitCode = 1; + if (process.connected) process.disconnect(); + } +} diff --git a/scripts/desktop/worker-guard.cjs b/scripts/desktop/worker-guard.cjs new file mode 100644 index 0000000..0824600 --- /dev/null +++ b/scripts/desktop/worker-guard.cjs @@ -0,0 +1,82 @@ +// Acquire before settings, application modules or learner databases are opened. +// The OS releases this transaction even when the supervisor/worker is killed. +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { DatabaseSync } = require('node:sqlite'); + +function requireSupervisor() { + const parent = Number(process.env.EXAMIFY_LAUNCHER_PID); + if (!process.connected || !Number.isSafeInteger(parent) || parent <= 0 || parent !== process.ppid) + throw new Error('The solo worker requires its connected launcher.'); +} + +function privatePath(file, directory) { + const stat = fs.lstatSync(file); + if ( + stat.isSymbolicLink() || + (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) + ) + throw new Error('Unsafe solo worker lock path.'); + if (process.platform === 'win32') { + const result = spawnSync( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(__dirname, 'private-path.ps1'), + '-Path', + file, + ], + { stdio: 'ignore', windowsHide: true, timeout: 15000 }, + ); + if (result.status !== 0) throw new Error('Could not secure private solo worker files.'); + } else if (stat.uid !== process.getuid() || stat.mode & 0o077) { + throw new Error('Solo worker lock paths must be private to their owner.'); + } +} + +requireSupervisor(); +process.once('disconnect', () => process.exit(0)); +const root = process.env.EXAMIFY_INSTALL_ROOT; +if (process.env.EXAMIFY_MODE !== 'solo' || !root || !path.isAbsolute(root)) + throw new Error('The solo worker requires its stable installation root.'); +// Inspect every ancestor before opening the lock; never follow a symlink into +// another installation, even when candidate data/config live in another root. +let current = path.parse(root).root; +for (const part of path.resolve(root).slice(current.length).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Unsafe solo worker installation path.'); +} +privatePath(root, true); +const directory = path.join(root, '.examify-operations'); +try { + fs.mkdirSync(directory, { mode: 0o700 }); +} catch (error) { + if (error.code !== 'EEXIST') throw error; +} +privatePath(directory, true); +const file = path.join(directory, 'worker.sqlite'); +try { + fs.closeSync(fs.openSync(file, 'wx', 0o600)); +} catch (error) { + if (error.code !== 'EEXIST') throw error; +} +privatePath(file, false); +const database = new DatabaseSync(file); +try { + database.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE'); + requireSupervisor(); +} catch { + database.close(); + throw new Error('Another solo worker is active or its launcher has disconnected.'); +} +// Retain the connection for the whole process lifetime. Never release on an +// ordinary event or unlink the lock file: either can admit a concurrent writer. +module.exports = { requireSupervisor, database }; diff --git a/scripts/desktop/worker-runner.mjs b/scripts/desktop/worker-runner.mjs new file mode 100644 index 0000000..a9b86ad --- /dev/null +++ b/scripts/desktop/worker-runner.mjs @@ -0,0 +1,35 @@ +// A fresh worker must prove its launcher still owns startup before opening data. +// A connected flag alone cannot detect a disconnect still queued by Node. +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); +const guard = require('./worker-guard.cjs'); +const target = process.argv[2]; +if ( + !target || + !path.isAbsolute(target) || + process.argv.slice(3).some((arg) => arg !== '--settings') +) + throw new Error('Invalid solo worker target.'); +await new Promise((resolve, reject) => { + const onDisconnect = () => reject(new Error('The solo launcher disconnected before startup.')); + const onMessage = (message) => { + if (message?.type !== 'examify-worker-go') return; + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + resolve(); + }; + process.on('message', onMessage); + process.once('disconnect', onDisconnect); + guard.requireSupervisor(); + process.send({ type: 'examify-worker-ready' }, (error) => { + if (error) reject(new Error('The solo launcher could not authorize startup.')); + }); +}); +guard.requireSupervisor(); +// Keep IPC cleanup active without preventing a finished migration from exiting. +process.channel?.unref(); +if (process.argv.includes('--settings')) require('./settings-loader.cjs'); +await import(pathToFileURL(target).href); diff --git a/scripts/launcher.mjs b/scripts/launcher.mjs index 395ef05..3b28b74 100644 --- a/scripts/launcher.mjs +++ b/scripts/launcher.mjs @@ -7,15 +7,28 @@ import http from 'node:http'; import net from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { acquireOperationLock, tryAcquireInstanceLock } from './desktop/operation-lock.mjs'; +import { + acquireOperationLock, + tryAcquireInstanceLock, + tryAcquireWorkerLock, +} from './desktop/operation-lock.mjs'; +import { + readInstallation, + assertStateComplete, + releaseRoot, + durableJson, +} from './desktop/state-store.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const secret = () => randomBytes(32).toString('hex'); const securedWindowsPaths = new Set(); -function secureWindowsPath(file) { - if (process.platform !== 'win32' || securedWindowsPaths.has(file)) return; - const result = spawnSync( +function secureWindowsPath( + file, + { helperDirectory = path.join(here, 'desktop'), spawn = spawnSync } = {}, +) { + if (securedWindowsPaths.has(file)) return; + const result = spawn( 'powershell.exe', [ '-NoLogo', @@ -24,7 +37,7 @@ function secureWindowsPath(file) { '-ExecutionPolicy', 'Bypass', '-File', - path.join(here, 'desktop', 'private-path.ps1'), + path.join(helperDirectory, 'private-path.ps1'), '-Path', file, '-Diagnostics', @@ -75,7 +88,8 @@ export function assertExistingAncestors(dir) { } } -export function privateDirectory(dir) { +export function privateDirectory(dir, options = {}) { + const platform = options.platform ?? process.platform; const absolute = path.resolve(dir); let current = path.parse(absolute).root; for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) { @@ -99,19 +113,20 @@ export function privateDirectory(dir) { const stat = fs.statSync(absolute); if (process.getuid && stat.uid !== process.getuid()) throw new Error('Installation belongs to another user.'); - if (process.platform !== 'win32') fs.chmodSync(absolute, 0o700); - else secureWindowsPath(absolute); + if (platform !== 'win32') fs.chmodSync(absolute, 0o700); + else secureWindowsPath(absolute, options); return absolute; } -export function privateFile(file) { +export function privateFile(file, options = {}) { + const platform = options.platform ?? process.platform; const stat = fs.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('Unsafe launcher state file.'); if (process.getuid && stat.uid !== process.getuid()) throw new Error('Launcher state belongs to another user.'); - if (process.platform === 'win32') secureWindowsPath(file); - if (process.platform !== 'win32' && stat.mode & 0o077) + if (platform === 'win32') secureWindowsPath(file, options); + if (platform !== 'win32' && stat.mode & 0o077) throw new Error('Launcher state must be private to its owner.'); } @@ -331,8 +346,8 @@ export async function startLauncher({ throw new Error('This preview supports Windows x64 and Linux x64 only.'); } root = path.resolve(root); - const configDir = path.join(root, 'config'); - const dataDir = path.join(root, 'data'); + let configDir = path.join(root, 'config'); + let dataDir = path.join(root, 'data'); const stateFile = path.join(root, 'running.json'); assertExistingAncestors(root); assertExistingAncestors(configDir); @@ -347,6 +362,30 @@ export async function startLauncher({ let releaseInstance; let startedInstance = false; try { + const installation = readInstallation(root, privateFile); + if (installation) { + if (path.resolve(appDir) !== path.resolve(releaseRoot(root, installation))) + throw new Error( + 'This application version is not active. Reopen Examify using its installed shortcut.', + ); + let selected; + try { + selected = assertStateComplete(root, installation); + } catch (error) { + if (error.code === 'ENOENT') + throw new Error( + 'The active study state is missing. Restore the complete private backup; no fallback was opened.', + ); + throw error; + } + assertExistingAncestors(selected); + configDir = path.join(selected, 'config'); + dataDir = path.join(selected, 'data'); + assertExistingAncestors(configDir); + assertExistingAncestors(dataDir); + } else if (path.dirname(path.resolve(appDir)) === path.join(root, 'releases')) { + throw new Error('Installation metadata is missing. No study data was opened.'); + } onPhase('existing'); async function reuse(running) { if (!running.origin || !(await healthy(running.origin, running.instanceId))) return false; @@ -392,6 +431,14 @@ export async function startLauncher({ ); await sleep(250); } + const workerLease = await tryAcquireWorkerLock({ + root, + secureDirectory: privateDirectory, + secureFile: privateFile, + }); + if (!workerLease) + throw new Error('An Examify database worker is still stopping. Wait briefly and relaunch.'); + workerLease(); // Only the lifetime lock proves a marker stale. A PID can name an unrelated // process after a crash; even a healthy saved endpoint is not liveness proof. let previous = false; @@ -489,6 +536,8 @@ export async function startLauncher({ NODE_ENV: 'production', NEXT_TELEMETRY_DISABLED: '1', EXAMIFY_MODE: 'solo', + EXAMIFY_INSTALL_ROOT: root, + EXAMIFY_LAUNCHER_PID: String(process.pid), EXAMIFY_CONFIG_DIR: configDir, EXAMIFY_DATA_DIR: dataDir, DATABASE_URL: `file:${path.join(dataDir, 'app.db')}`, @@ -505,16 +554,33 @@ export async function startLauncher({ TURNSTILE_ENABLED: '0', }; onPhase('migration'); - const migration = spawn(node, [path.join(appDir, 'scripts', 'migrate.mjs')], { - cwd: appDir, - env, - stdio: ['ignore', 'ignore', 'pipe'], - windowsHide: true, - }); + function spawnWorker(target, settings = false) { + const worker = spawn( + node, + [ + path.join(appDir, 'scripts', 'desktop', 'worker-runner.mjs'), + target, + ...(settings ? ['--settings'] : []), + ], + { + cwd: appDir, + env, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + windowsHide: true, + }, + ); + worker.on('message', (message) => { + if (message?.type === 'examify-worker-ready' && !stopped && worker.connected) + worker.send({ type: 'examify-worker-go' }, () => {}); + }); + return worker; + } + const migration = spawnWorker(path.join(appDir, 'scripts', 'migrate.mjs')); + child = migration; // Never print migration output: it can contain private paths or SQL data. migration.stderr.resume(); const migrationCode = await new Promise((resolve, reject) => { - const timer = setTimeout(() => migration.kill(), 60000); + const timer = setTimeout(() => migration.kill('SIGKILL'), 60000); migration.once('error', (error) => { clearTimeout(timer); reject(error); @@ -527,20 +593,8 @@ export async function startLauncher({ if (migrationCode !== 0) throw new Error('Examify could not prepare its data. Existing files have been preserved.'); onPhase('server'); - child = spawn( - node, - [ - '--require', - path.join(appDir, 'scripts', 'desktop', 'settings-loader.cjs'), - path.join(appDir, 'server.js'), - ], - { - cwd: appDir, - env, - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - windowsHide: true, - }, - ); + child = spawnWorker(path.join(appDir, 'server.js'), true); + child.stderr.resume(); child.once('error', () => { spawnError = true; }); @@ -557,6 +611,8 @@ export async function startLauncher({ ); await sleep(250); } + if (installation?.protocol === 1 && !installation.initialized) + durableJson(path.join(root, 'installation.json'), { ...installation, initialized: true }); privateFile(stateFile); writeJson(stateFile, { pid: process.pid, instanceId, origin }); onPhase('browser'); diff --git a/tests/desktop/acceptance.mjs b/tests/desktop/acceptance.mjs index 514a673..9c831d6 100644 --- a/tests/desktop/acceptance.mjs +++ b/tests/desktop/acceptance.mjs @@ -51,6 +51,15 @@ function install(targetRoot, sourceArchive, stdio = 'inherit') { } let appDir; let node; +function refreshTarget() { + 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'); +} +function activeState() { + const marker = JSON.parse(fs.readFileSync(path.join(root, 'installation.json'), 'utf8')); + return marker.state ? path.join(root, marker.state) : root; +} try { const corrupt = path.join(fixture, 'corrupt.archive'); const refusedRoot = path.join(fixture, 'Rejected download'); @@ -63,7 +72,7 @@ try { assert.equal(fs.existsSync(path.join(refusedRoot, 'installation.json')), false); install(root, archive); const marker = JSON.parse(fs.readFileSync(path.join(root, 'installation.json'), 'utf8')); - appDir = path.join(root, 'releases', marker.version); + appDir = path.join(root, marker.release ?? `releases/${marker.version}`); node = path.join(appDir, process.platform === 'win32' ? 'runtime/node.exe' : 'runtime/bin/node'); } catch (error) { fs.rmSync(fixture, { recursive: true, force: true }); @@ -294,18 +303,20 @@ try { 'tampered bytes with unchanged release manifest', ); install(root, archive); + refreshTarget(); assert.ok( fs.readFileSync(path.join(appDir, 'server.js')).equals(originalServer), 'Verified reinstall replaces modified code despite a stale marker naming an unrelated live PID', ); fs.unlinkSync(node); install(root, archive); + refreshTarget(); assert.ok( fs.existsSync(node), 'Verified reinstall repairs a partially missing release without executing its old runtime', ); assert.ok( - fs.readFileSync(path.join(root, 'config/secrets.json')).equals(secrets), + fs.readFileSync(path.join(activeState(), 'config/secrets.json')).equals(secrets), 'Repeat installation preserves private configuration', ); const again = launch(); @@ -314,10 +325,10 @@ try { assert.equal(restarted.reused, false); await bootstrap(await takeUrl()); assert.ok( - fs.readFileSync(path.join(root, 'config/secrets.json')).equals(secrets), + fs.readFileSync(path.join(activeState(), 'config/secrets.json')).equals(secrets), 'Relaunch retains private signing secrets', ); - assert.ok(fs.statSync(path.join(root, 'data/app.db')).size > 0); + assert.ok(fs.statSync(path.join(activeState(), 'data/app.db')).size > 0); console.log( 'Stop/restart preserves the solo profile and private data. Runtime PATH contains no host Node, Git or pnpm.', ); diff --git a/tests/desktop/fixtures/delayed-worker.mjs b/tests/desktop/fixtures/delayed-worker.mjs new file mode 100644 index 0000000..2a24a1f --- /dev/null +++ b/tests/desktop/fixtures/delayed-worker.mjs @@ -0,0 +1,15 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const [runner, target] = process.argv.slice(2); +const root = process.env.EXAMIFY_INSTALL_ROOT; +process.on('exit', (code) => fs.writeFileSync(path.join(root, 'child-exit'), String(code))); +const timer = setInterval(() => { + if (fs.existsSync(path.join(root, 'gate'))) { + clearInterval(timer); + process.argv = [process.execPath, runner, target]; + import('../../../scripts/desktop/worker-runner.mjs').catch(() => process.exit(1)); + } +}, 20); +// Confirm the delayed process has actually started before killing its launcher. +process.send({ type: 'waiting' }); diff --git a/tests/desktop/fixtures/worker-supervisor.cjs b/tests/desktop/fixtures/worker-supervisor.cjs new file mode 100644 index 0000000..ddec0e1 --- /dev/null +++ b/tests/desktop/fixtures/worker-supervisor.cjs @@ -0,0 +1,12 @@ +const { fork } = require('node:child_process'); + +const [runner, target, delay] = process.argv.slice(2); +const child = fork(delay || runner, delay ? [runner, target] : [target], { + env: { ...process.env, EXAMIFY_LAUNCHER_PID: String(process.pid) }, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], +}); +child.on('message', (message) => { + if (message.type === 'examify-worker-ready') child.send({ type: 'examify-worker-go' }); + if (message.type === 'blocked' || message.type === 'waiting') + process.send({ type: message.type, pid: child.pid }); +}); diff --git a/tests/desktop/fixtures/worker-target.cjs b/tests/desktop/fixtures/worker-target.cjs new file mode 100644 index 0000000..fd4ee37 --- /dev/null +++ b/tests/desktop/fixtures/worker-target.cjs @@ -0,0 +1,14 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +if (process.env.EXAMIFY_TEST_WORKER_MODE === 'blocked') { + process.send({ type: 'blocked' }); + // Model a synchronous data operation that cannot service IPC disconnect. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); +} else { + fs.writeFileSync(path.join(process.env.EXAMIFY_INSTALL_ROOT, 'opened'), 'yes'); + if (process.env.EXAMIFY_TEST_WORKER_MODE !== 'natural') { + process.send({ type: 'opened' }); + setInterval(() => {}, 1000); + } +} diff --git a/tests/desktop/launcher.test.mjs b/tests/desktop/launcher.test.mjs index 88c0002..a88d128 100644 --- a/tests/desktop/launcher.test.mjs +++ b/tests/desktop/launcher.test.mjs @@ -132,6 +132,7 @@ test('private paths can be secured repeatedly by fresh ordinary-user processes', for (const relative of [ 'launcher.mjs', 'desktop/operation-lock.mjs', + 'desktop/state-store.mjs', 'desktop/private-path.ps1', ]) { fs.copyFileSync( @@ -415,13 +416,14 @@ test('verified reinstall repairs tampered and partial releases, rolls back failu } try { fs.writeFileSync(path.join(destination, 'payload.txt'), 'tampered'); - await installRelease({ root, staged: staged(), version }); + await installRelease({ bootstrap: () => {}, root, staged: staged(), version }); assert.equal(fs.readFileSync(path.join(destination, 'payload.txt'), 'utf8'), 'verified'); fs.unlinkSync(path.join(destination, 'payload.txt')); - await installRelease({ root, staged: staged(), version }); + await installRelease({ bootstrap: () => {}, root, staged: staged(), version }); assert.equal(fs.readFileSync(path.join(destination, 'payload.txt'), 'utf8'), 'verified'); await assert.rejects( installRelease({ + bootstrap: () => {}, root, staged: staged(), version, @@ -441,7 +443,7 @@ test('verified reinstall repairs tampered and partial releases, rolls back failu return rename(from, to); }; await assert.rejects( - installRelease({ root, staged: failedStage, version }), + installRelease({ bootstrap: () => {}, root, staged: failedStage, version }), /synthetic publication failure/, ); } finally { @@ -455,7 +457,7 @@ test('verified reinstall repairs tampered and partial releases, rolls back failu const running = path.join(root, 'running.json'); fs.writeFileSync(running, JSON.stringify({ pid: process.pid }), { mode: 0o600 }); fs.writeFileSync(path.join(destination, 'payload.txt'), 'tampered'); - await installRelease({ root, staged: staged(), version }); + await installRelease({ bootstrap: () => {}, root, staged: staged(), version }); assert.equal( fs.readFileSync(path.join(destination, 'payload.txt'), 'utf8'), 'verified', @@ -465,12 +467,12 @@ test('verified reinstall repairs tampered and partial releases, rolls back failu assert.equal(typeof releaseInstance, 'function'); try { await assert.rejects( - installRelease({ root, staged: staged(), version }), + installRelease({ bootstrap: () => {}, root, staged: staged(), version }), /Examify is running/, ); fs.unlinkSync(running); await assert.rejects( - installRelease({ root, staged: staged(), version }), + installRelease({ bootstrap: () => {}, root, staged: staged(), version }), /Examify is running/, ); } finally { @@ -720,10 +722,16 @@ function launcherFixture() { fs.mkdirSync(path.join(appDir, 'scripts', 'desktop'), { recursive: true }); fs.writeFileSync(path.join(appDir, 'scripts', 'solo-preflight.mjs'), 'process.exit(0);'); fs.writeFileSync(path.join(appDir, 'scripts', 'migrate.mjs'), 'process.exit(0);'); - fs.copyFileSync( - new URL('../../scripts/desktop/settings-loader.cjs', import.meta.url), - path.join(appDir, 'scripts', 'desktop', 'settings-loader.cjs'), - ); + for (const name of [ + 'settings-loader.cjs', + 'worker-guard.cjs', + 'worker-runner.mjs', + 'private-path.ps1', + ]) + fs.copyFileSync( + new URL(`../../scripts/desktop/${name}`, import.meta.url), + path.join(appDir, 'scripts', 'desktop', name), + ); fs.writeFileSync( path.join(appDir, 'server.js'), ` diff --git a/tests/desktop/relocated-helpers.test.mjs b/tests/desktop/relocated-helpers.test.mjs new file mode 100644 index 0000000..7f732d6 --- /dev/null +++ b/tests/desktop/relocated-helpers.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; + +const scripts = fileURLToPath(new URL('../../scripts/', import.meta.url)); + +async function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-relocated-helpers-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const staged = path.join(root, '.install.fixture', 'app'); + fs.mkdirSync(staged, { recursive: true }); + fs.cpSync(scripts, path.join(staged, 'scripts'), { recursive: true }); + fs.writeFileSync( + path.join(staged, 'desktop-release.json'), + JSON.stringify({ + version: '0.2.0', + platform: `${process.platform}-${process.arch}`, + nodeVersion: process.versions.node, + upgradeProtocol: 1, + }), + ); + for (const name of ['data', 'config']) fs.mkdirSync(path.join(root, name), { mode: 0o700 }); + fs.writeFileSync(path.join(root, 'data/app.db'), 'preserved database', { mode: 0o600 }); + fs.writeFileSync(path.join(root, 'config/secrets.json'), '{"secret":"fixture"}', { mode: 0o600 }); + const previous = { + app: 'examify-solo', + protocol: 1, + version: '0.1.0', + initialized: true, + release: `releases/0.1.0-${'a'.repeat(32)}`, + }; + fs.writeFileSync(path.join(root, 'installation.json'), JSON.stringify(previous), { mode: 0o600 }); + const { installRelease } = await import( + pathToFileURL(path.join(staged, 'scripts/desktop/install-release.mjs')) + ); + return { root, staged, previous, installRelease }; +} + +for (const realWindows of [false, true]) { + test( + `relocated installer uses live helpers for copied state and candidate locks (${realWindows ? 'real Windows' : 'injected Windows'})`, + { + skip: realWindows && process.platform !== 'win32', + }, + async (t) => { + const f = await fixture(t); + const calls = []; + let finalApp; + const marker = await f.installRelease({ + root: f.root, + staged: f.staged, + version: '0.2.0', + prepare() {}, + bootstrap() {}, + probe({ appDir, root }) { + finalApp = appDir; + assert.equal(fs.existsSync(f.staged), false); + assert.equal( + fs.readFileSync(path.join(root, 'data/app.db'), 'utf8'), + 'preserved database', + ); + }, + helperOptions: { + platform: 'win32', + spawn(command, args, options) { + const helper = args[args.indexOf('-File') + 1]; + const target = args[args.indexOf('-Path') + 1]; + const moved = !fs.existsSync(f.staged); + assert.ok(fs.existsSync(helper), `Helper must still exist: ${helper}`); + if (moved) assert.ok(helper.startsWith(path.join(f.root, 'releases') + path.sep)); + else assert.ok(helper.startsWith(f.staged + path.sep)); + calls.push({ helper, target, moved }); + return realWindows ? spawnSync(command, args, options) : { status: 0 }; + }, + }, + }); + const candidate = path.join(f.root, marker.state); + const movedCalls = calls.filter(({ moved }) => moved); + assert.ok(movedCalls.length > 0); + assert.ok(movedCalls.every(({ helper }) => helper.startsWith(finalApp + path.sep))); + const inventoryCalls = movedCalls.filter( + ({ helper }) => path.basename(helper) === 'inspect-state.ps1', + ); + assert.deepEqual( + inventoryCalls.map(({ target }) => target), + [f.root, f.root, candidate], + 'Source inventory, copy revalidation and candidate validation all use the relocated helper', + ); + for (const name of ['instance.sqlite', 'worker.sqlite']) { + assert.ok( + movedCalls.some( + ({ target }) => target === path.join(candidate, '.examify-operations', name), + ), + `Candidate ${name} must receive its ACL through the relocated helper`, + ); + } + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'preserved database'); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(f.root, 'installation.json'), 'utf8')), + marker, + ); + }, + ); +} + +test('post-rename helper failure preserves the previous activation pointer', async (t) => { + const f = await fixture(t); + await assert.rejects( + f.installRelease({ + root: f.root, + staged: f.staged, + version: '0.2.0', + prepare() {}, + bootstrap() {}, + probe() {}, + helperOptions: { + platform: 'win32', + spawn(command, args) { + const helper = args[args.indexOf('-File') + 1]; + assert.ok(fs.existsSync(helper)); + return { status: path.basename(helper) === 'inspect-state.ps1' ? 1 : 0 }; + }, + }, + }), + /Study state must be owned/, + ); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(f.root, 'installation.json'), 'utf8')), + f.previous, + ); + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'preserved database'); +}); diff --git a/tests/desktop/upgrade-acceptance.mjs b/tests/desktop/upgrade-acceptance.mjs new file mode 100644 index 0000000..d5db968 --- /dev/null +++ b/tests/desktop/upgrade-acceptance.mjs @@ -0,0 +1,366 @@ +#!/usr/bin/env node +/** Native, disposable upgrade acceptance. Never modifies the distributed archive. */ +import assert from 'node:assert/strict'; +import { execFileSync, fork } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { childEnvironment } from '../../scripts/launcher.mjs'; +import { observeLauncher } from './launcher-progress.mjs'; + +const self = fileURLToPath(import.meta.url); +const repo = path.resolve(path.dirname(self), '../..'); +if (process.argv[2] === '--install-worker') { + const [root, staged, version, killPhase] = process.argv.slice(3); + const { installRelease } = await import( + pathToFileURL(path.join(staged, 'scripts/desktop/install-release.mjs')) + ); + await installRelease({ + root, + staged, + version, + onPhase(phase) { + if (phase === killPhase) { + fs.writeSync(1, `kill-boundary:${phase}\n`); + process.kill(process.pid, 'SIGKILL'); + } + }, + }); +} else { + await acceptance(); +} +async function acceptance() { + const platform = `${process.platform}-${process.arch}`; + const artifacts = path.resolve(process.argv[2] || path.join(repo, 'build/desktop', platform)); + const asset = fs + .readdirSync(artifacts) + .find((name) => name.endsWith(process.platform === 'win32' ? '.zip' : '.tar.gz')); + assert.ok(asset, 'Native packaged archive exists'); + const archive = path.join(artifacts, asset); + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'Examify upgrade acceptance ')); + const root = path.join(fixture, 'Private Examify'); + fs.mkdirSync(root, { mode: 0o700 }); + const env = { + ...childEnvironment(), + PATH: + process.platform === 'win32' + ? `${process.env.SystemRoot}\\system32;${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0` + : '', + }; + const children = new Set(); + const browserWaiters = []; + let sequence = 0; + let node; + function marker() { + return JSON.parse(fs.readFileSync(path.join(root, 'installation.json'), 'utf8')); + } + function stage(version, failure) { + const temp = fs.mkdtempSync(path.join(root, '.install.')); + const app = path.join(temp, 'app'); + fs.mkdirSync(app); + if (process.platform === 'win32') { + execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(repo, 'scripts/desktop/archive.ps1'), + '-Operation', + 'extract', + '-Source', + archive, + '-Destination', + app, + ], + { stdio: 'pipe' }, + ); + } else execFileSync('tar', ['-xzf', archive, '-C', app], { stdio: 'pipe' }); + const manifest = JSON.parse(fs.readFileSync(path.join(app, 'desktop-release.json'), 'utf8')); + // This is a disposable fixture derived from the actual native archive, not a + // release publication. Distinct SQL below proves a real migration occurred. + fs.writeFileSync( + path.join(app, 'desktop-release.json'), + JSON.stringify({ ...manifest, version, upgradeProtocol: 1 }), + ); + if (version !== '0.1.0') { + const migrations = path.join(app, 'src/lib/db/migrations'); + const journalPath = path.join(migrations, 'meta/_journal.json'); + const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')); + const last = journal.entries.at(-1); + const tag = `${String(last.idx + 1).padStart(4, '0')}_native_upgrade_fixture`; + journal.entries.push({ ...last, idx: last.idx + 1, when: last.when + 1000, tag }); + fs.writeFileSync(journalPath, JSON.stringify(journal)); + fs.writeFileSync( + path.join(migrations, `${tag}.sql`), + failure === 'migration' + ? 'THIS IS DELIBERATELY INVALID SQL;' + : "ALTER TABLE exam_attempts ADD COLUMN upgrade_fixture TEXT NOT NULL DEFAULT 'migrated-v2';", + ); + if (failure === 'startup') fs.writeFileSync(path.join(app, 'server.js'), 'process.exit(71);'); + } + return app; + } + function install(staged, version, killPhase = '') { + const bundledRuntime = path.join( + staged, + process.platform === 'win32' ? 'runtime/node.exe' : 'runtime/bin/node', + ); + // Like the real installer, run a fresh copy outside the app that gets moved + // and synced. Windows locks a running executable against those operations. + // Each stage has a unique parent; retain its runtime until fixture cleanup + // so interrupted workers never cause a later install to overwrite it. + const runtime = path.join( + path.dirname(staged), + process.platform === 'win32' ? 'install-node.exe' : 'install-node', + ); + fs.copyFileSync(bundledRuntime, runtime, fs.constants.COPYFILE_EXCL); + execFileSync(runtime, [self, '--install-worker', root, staged, version, killPhase], { + env, + stdio: 'pipe', + timeout: 180000, + }); + } + function snapshot(directory) { + const entries = {}; + function visit(dir, prefix = '') { + for (const name of fs.readdirSync(dir).sort()) { + const file = path.join(dir, name), + key = `${prefix}${name}`; + const stat = fs.lstatSync(file); + assert.ok(!stat.isSymbolicLink(), 'Private state contains no links'); + if (stat.isDirectory()) visit(file, `${key}/`); + else entries[key] = createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + } + } + visit(directory); + return entries; + } + function launch(appDir, targetRoot = root) { + const proc = fork(path.join(repo, 'tests/desktop/launcher-child.mjs'), [appDir, targetRoot], { + execPath: node, + env, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + children.add(proc); + proc.stdout.resume(); + proc.stderr.resume(); + proc.once('exit', () => children.delete(proc)); + // A reused launch asks the original launcher to open its browser, so its + // capability arrives over the original child's IPC, not the reused child's. + const browserUrl = new Promise((resolve, reject) => { + const waiter = { + finish(error, url) { + clearTimeout(timer); + const index = browserWaiters.indexOf(waiter); + if (index !== -1) browserWaiters.splice(index, 1); + if (error) reject(error); + else resolve(url); + }, + }; + const timer = setTimeout(() => waiter.finish(new Error('Browser callback timed out')), 90000); + timer.unref(); + browserWaiters.push(waiter); + }); + proc.on('message', (message) => { + if (message.kind === 'browser') browserWaiters[0]?.finish(undefined, message.url); + }); + void browserUrl.catch(() => {}); + const observer = observeLauncher(proc, { id: ++sequence }); + proc.once('exit', () => observer.dispose()); + return { proc, ready: observer.ready, browserUrl }; + } + async function stop(proc) { + if (proc.exitCode !== null || proc.signalCode !== null) return; + await new Promise((resolve) => { + const timer = setTimeout(() => proc.kill('SIGKILL'), 5000); + proc.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + if (proc.connected) proc.send('stop', () => {}); + else proc.kill(); + }); + } + function sql(appDir, dataRoot, code) { + return execFileSync( + node, + [ + '--input-type=module', + '-e', + `import { createRequire } from 'node:module'; import path from 'node:path'; const require = createRequire(path.join(process.argv[1], 'package.json')); const Database = require('better-sqlite3'); const db = new Database(process.argv[2]); ${code}`, + appDir, + path.join(dataRoot, 'data/app.db'), + ], + { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } + async function usable(appDir, targetRoot = root) { + const running = launch(appDir, targetRoot); + try { + const ready = await running.ready; + const url = new URL(await running.browserUrl); + const session = await fetch(`${ready.origin}/api/solo/session`, { + method: 'POST', + headers: { origin: ready.origin, 'content-type': 'application/json' }, + body: JSON.stringify({ token: url.hash.slice(1) }), + }); + assert.equal(session.status, 200); + const cookie = session.headers.get('set-cookie').split(';')[0]; + const home = await fetch(ready.origin, { headers: { cookie } }); + assert.equal(home.status, 200); + assert.match(await home.text(), /3 done/); + const reopened = launch(appDir, targetRoot); + assert.equal( + (await reopened.ready).reused, + true, + 'Reopen uses the selected state signing secrets', + ); + const fresh = new URL(await reopened.browserUrl); + const freshSession = await fetch(`${ready.origin}/api/solo/session`, { + method: 'POST', + headers: { origin: ready.origin, 'content-type': 'application/json' }, + body: JSON.stringify({ token: fresh.hash.slice(1) }), + }); + assert.equal( + freshSession.status, + 200, + 'Reopened upgraded app issues a usable fresh capability', + ); + } finally { + await stop(running.proc); + } + } + try { + install(stage('0.1.0'), '0.1.0'); + const old = marker(); + assert.equal(old.protocol, 1); + const oldApp = path.join(root, old.release); + node = path.join( + oldApp, + process.platform === 'win32' ? 'runtime/node.exe' : 'runtime/bin/node', + ); + const first = launch(oldApp); + const ready = await first.ready; + const { verifyBrowser } = await import('./browser-checks.mjs'); + await verifyBrowser({ + origin: ready.origin, + browserUrl: await first.browserUrl, + reopen: async () => { + const reopened = launch(oldApp); + await reopened.ready; + return reopened.browserUrl; + }, + }); + await stop(first.proc); + assert.equal( + JSON.parse( + sql( + oldApp, + root, + "console.log(JSON.stringify(db.prepare('SELECT count(*) n FROM exam_attempts').get())); db.close();", + ), + ).n, + 3, + ); + // Exit without db.close(): committed WAL frames must be copied, not dropped. + sql( + oldApp, + root, + "db.pragma('journal_mode=WAL'); db.pragma('wal_autocheckpoint=0'); db.prepare('UPDATE exam_attempts SET score_pct=37 WHERE id=(SELECT min(id) FROM exam_attempts)').run(); process.exit(0);", + ); + assert.ok(fs.statSync(path.join(root, 'data/app.db-wal')).size > 0); + const savedData = snapshot(path.join(root, 'data')); + const savedConfig = snapshot(path.join(root, 'config')); + assert.ok( + Object.keys(savedData).some((file) => file.includes('content/')), + 'Authored material and bank exist', + ); + assert.ok( + Object.keys(savedConfig).some((file) => file.startsWith('.env')), + 'Saved provider settings exist', + ); + const savedMarker = fs.readFileSync(path.join(root, 'installation.json')); + function preserved() { + assert.deepEqual(fs.readFileSync(path.join(root, 'installation.json')), savedMarker); + assert.deepEqual( + snapshot(path.join(root, 'data')), + savedData, + 'Original database, WAL, materials, banks and results remain byte-identical', + ); + assert.deepEqual( + snapshot(path.join(root, 'config')), + savedConfig, + 'Original secrets and provider settings remain byte-identical', + ); + assert.ok(fs.existsSync(path.join(oldApp, 'server.js'))); + } + assert.throws( + () => install(stage('0.1.0'), '0.1.0', 'before-activate'), + (error) => String(error.stdout).includes('kill-boundary:before-activate'), + ); + preserved(); + for (const failure of ['migration', 'startup']) { + assert.throws( + () => install(stage('0.2.0', failure), '0.2.0'), + (error) => /Upgrade migration or startup verification failed/.test(String(error.stderr)), + ); + preserved(); + } + for (const phase of ['state-copied', 'before-activate']) { + assert.throws( + () => install(stage('0.2.0'), '0.2.0', phase), + (error) => String(error.stdout).includes(`kill-boundary:${phase}`), + ); + preserved(); + } + install(stage('0.2.0'), '0.2.0'); + const upgraded = marker(); + assert.equal(upgraded.version, '0.2.0'); + assert.notEqual(upgraded.release, old.release); + assert.equal(upgraded.previous.release, old.release); + assert.match(upgraded.state, /^states\/[a-f0-9]{32}$/); + assert.deepEqual(snapshot(path.join(root, 'data')), savedData); + assert.deepEqual(snapshot(path.join(root, 'config')), savedConfig); + const newApp = path.join(root, upgraded.release); + const newState = path.join(root, upgraded.state); + const rows = JSON.parse( + sql( + newApp, + newState, + "console.log(JSON.stringify(db.prepare('SELECT score_pct, upgrade_fixture FROM exam_attempts ORDER BY id').all())); db.close();", + ), + ); + assert.equal(rows.length, 3); + assert.equal(rows[0].score_pct, 37, 'Committed WAL frame survives upgrade'); + assert.ok( + rows.every((row) => row.upgrade_fixture === 'migrated-v2'), + 'New packaged SQL migration actually ran', + ); + assert.deepEqual(snapshot(path.join(newState, 'config')), savedConfig); + const copiedData = snapshot(path.join(newState, 'data')); + for (const [file, hash] of Object.entries(savedData)) { + if (!/^app\.db(?:-|$)/.test(file)) + assert.equal(copiedData[file], hash, 'Every authored material and bank is preserved'); + } + await usable(newApp); + // Verify the rollback pair in a disposable independent root, without changing + // the committed pointer or ever launching old code against migrated state. + const rollback = path.join(fixture, 'Rollback verification'); + fs.mkdirSync(rollback, { mode: 0o700 }); + for (const name of ['data', 'config']) + fs.cpSync(path.join(root, name), path.join(rollback, name), { recursive: true }); + await usable(oldApp, rollback); + console.log( + 'Native upgrade acceptance passed: authored study state, real schema migration, committed WAL, migration/startup failure, hard-kill recovery, immutable rollback pair and authenticated relaunch.', + ); + } finally { + for (const waiter of [...browserWaiters]) waiter.finish(new Error('Acceptance stopped')); + await Promise.all([...children].map(stop)); + fs.rmSync(fixture, { recursive: true, force: true }); + } +} diff --git a/tests/desktop/upgrades.test.mjs b/tests/desktop/upgrades.test.mjs new file mode 100644 index 0000000..4851c4e --- /dev/null +++ b/tests/desktop/upgrades.test.mjs @@ -0,0 +1,341 @@ +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 { installRelease } from '../../scripts/desktop/install-release.mjs'; +import { privateDirectory, privateFile, startLauncher } from '../../scripts/launcher.mjs'; +import { + tryAcquireInstanceLock, + tryAcquireWorkerLock, +} from '../../scripts/desktop/operation-lock.mjs'; +import { assertUpgradeVersion, validateInstallation } from '../../scripts/desktop/state-store.mjs'; + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-upgrade-unit-')); + privateDirectory(root); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + function staged(version = '0.1.0', protocol = 1) { + const app = path.join(fs.mkdtempSync(path.join(root, '.install.')), 'app'); + fs.mkdirSync(app); + fs.writeFileSync( + path.join(app, 'desktop-release.json'), + JSON.stringify({ + version, + platform: `${process.platform}-${process.arch}`, + nodeVersion: process.versions.node, + upgradeProtocol: protocol, + }), + ); + fs.writeFileSync(path.join(app, 'payload'), `code-${version}`); + return app; + } + const read = () => JSON.parse(fs.readFileSync(path.join(root, 'installation.json'), 'utf8')); + const run = (version = '0.1.0', options = {}) => + installRelease({ + root, + staged: staged(version), + version, + prepare() {}, + bootstrap() {}, + probe() {}, + ...options, + }); + function seed() { + for (const name of ['data', 'config']) privateDirectory(path.join(root, name)); + privateDirectory(path.join(root, 'data/content/generated')); + for (const [name, bytes] of Object.entries({ + 'data/app.db': 'offline database', + 'data/app.db-wal': 'committed WAL frames', + 'data/content/generated/bank.json': '{"questions":[{"id":"authored"}]}', + 'data/material.txt': 'Uploaded study material', + 'config/.env.local': 'EXAMIFY_LLM_MODEL=local-fixture\n', + 'config/secrets.json': '{"private":"preserve"}', + })) + fs.writeFileSync(path.join(root, name), bytes, { mode: 0o600 }); + } + return { root, staged, read, run, seed }; +} + +test('fresh installs commit protocol 1 and immutable release without moving initial state', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const marker = f.read(); + assert.equal(marker.protocol, 1); + assert.match(marker.release, /^releases\/0\.1\.0-[a-f0-9]{32}$/); + assert.equal(marker.state, undefined); + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'offline database'); +}); + +test('upgrade probes a complete independent state and commits its release/state together', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + let candidate; + const phases = []; + await f.run('0.2.0', { + onPhase: (phase) => phases.push(phase), + probe({ root, appDir, node }) { + candidate = root; + assert.notEqual(root, f.root); + assert.deepEqual(f.read(), before, 'Marker is unchanged while migration runs'); + assert.equal( + fs.readFileSync(path.join(root, 'data/app.db-wal'), 'utf8'), + 'committed WAL frames', + ); + assert.equal( + fs.readFileSync(path.join(root, 'config/.env.local'), 'utf8'), + 'EXAMIFY_LLM_MODEL=local-fixture\n', + ); + assert.equal(fs.readFileSync(path.join(appDir, 'payload'), 'utf8'), 'code-0.2.0'); + assert.ok(node.startsWith(appDir + path.sep)); + fs.writeFileSync(path.join(root, 'data/app.db'), 'migrated database', { mode: 0o600 }); + }, + }); + const after = f.read(); + assert.equal(path.join(f.root, after.state), candidate); + assert.equal(after.previous.release, before.release); + assert.equal(after.version, '0.2.0'); + assert.deepEqual(phases, ['state-copied', 'before-activate', 'activated']); + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'offline database'); + assert.equal(fs.readFileSync(path.join(candidate, 'data/app.db'), 'utf8'), 'migrated database'); + assert.ok(fs.existsSync(path.join(candidate, 'backup-source.json'))); +}); + +for (const phase of ['preparation', 'migration', 'before-activate']) { + test(`${phase} failure leaves old release and state selected and unchanged`, async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + const fail = () => { + throw new Error('Injected failure'); + }; + const options = + phase === 'preparation' + ? { prepare: fail } + : phase === 'migration' + ? { + probe({ root }) { + fs.writeFileSync(path.join(root, 'data/app.db'), 'failed migration'); + fail(); + }, + } + : { + onPhase(value) { + if (value === phase) fail(); + }, + }; + await assert.rejects(f.run('0.2.0', options), /Injected failure/); + assert.deepEqual(f.read(), before); + assert.equal( + fs.readFileSync(path.join(f.root, before.release, 'payload'), 'utf8'), + 'code-0.1.0', + ); + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'offline database'); + await f.run('0.2.0'); + assert.equal(f.read().version, '0.2.0', 'Failed transaction releases its locks for retry'); + }); +} + +test('same-version repair creates immutable code/state slots and retains old matched pair', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + await assert.rejects( + f.run('0.1.0', { + onPhase(phase) { + if (phase === 'before-activate') throw new Error('Crash boundary'); + }, + }), + /Crash boundary/, + ); + assert.deepEqual(f.read(), before); + assert.ok(fs.existsSync(path.join(f.root, before.release, 'payload'))); + await f.run(); + const after = f.read(); + assert.notEqual(after.release, before.release); + assert.equal(after.previous.release, before.release); + assert.ok(fs.existsSync(path.join(f.root, before.release, 'payload'))); +}); + +test('legacy repair stays legacy and cross-version legacy upgrade is refused', async (t) => { + const f = fixture(t); + f.seed(); + const legacy = { app: 'examify-solo', version: '0.1.0' }; + fs.writeFileSync(path.join(f.root, 'installation.json'), JSON.stringify(legacy), { mode: 0o600 }); + fs.mkdirSync(path.join(f.root, 'releases/0.1.0'), { recursive: true }); + await f.run(); + assert.deepEqual(f.read(), legacy); + await assert.rejects(f.run('0.2.0'), /cannot be upgraded/); + assert.deepEqual(f.read(), legacy); +}); + +test('live launcher and orphaned database worker both refuse upgrade before copying state', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + for (const acquire of [tryAcquireInstanceLock, tryAcquireWorkerLock]) { + const release = await acquire({ + root: f.root, + secureDirectory: privateDirectory, + secureFile: privateFile, + }); + try { + await assert.rejects(f.run('0.2.0'), /Examify is running/); + } finally { + release(); + } + assert.deepEqual(f.read(), before); + assert.equal(fs.existsSync(path.join(f.root, 'states')), false); + } +}); + +test('linked private state is rejected rather than following or omitting it', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + fs.linkSync( + path.join(f.root, 'data/material.txt'), + path.join(f.root, 'data/linked-material.txt'), + ); + await assert.rejects(f.run('0.2.0'), /Unsafe/); + assert.deepEqual(f.read(), before); +}); + +test('version ordering is numeric and unsupported or downward transitions fail closed', () => { + for (const pair of [ + ['0.9.0', '0.10.0'], + ['v1.0.0', 'v2.0.0'], + ['9.99.99', '10.0.0'], + ]) + assert.doesNotThrow(() => assertUpgradeVersion(...pair)); + for (const pair of [ + ['0.2.0', '0.1.0'], + ['1.0.0', '1.0.0'], + ['ci-old', 'ci-new'], + ['1.0.0', '1.1.0-beta'], + ['1.0.0', '01.1.0'], + ]) + assert.throws(() => assertUpgradeVersion(...pair)); + for (const fields of [ + { protocol: 2 }, + { protocol: 1, release: '../../elsewhere' }, + { protocol: 1, release: `releases/0.1.0-${'a'.repeat(32)}`, state: 'states/../../private' }, + ]) + assert.throws(() => validateInstallation({ app: 'examify-solo', version: '0.1.0', ...fields })); +}); + +for (const missing of ['', 'data', 'data/app.db', 'config', 'config/secrets.json']) { + test(`missing committed state ${missing || 'generation'} cannot turn an upgrade into a fresh profile`, async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + await f.run('0.2.0'); + const before = f.read(); + fs.rmSync(path.join(f.root, before.state, missing), { recursive: true, force: true }); + await assert.rejects(f.run('0.3.0')); + assert.deepEqual(f.read(), before); + }); +} + +test('insufficient copy/migration disk reserve preserves committed marker and data', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + const statfs = fs.statfsSync; + try { + fs.statfsSync = () => ({ bavail: 0n, bsize: 4096n }); + await assert.rejects(f.run('0.2.0'), /Not enough free space/); + } finally { + fs.statfsSync = statfs; + } + assert.deepEqual(f.read(), before); + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'offline database'); +}); + +test('successful probe cannot activate an incomplete migrated state', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const before = f.read(); + await assert.rejects( + f.run('0.2.0', { + probe({ root }) { + fs.unlinkSync(path.join(root, 'config/secrets.json')); + }, + }), + ); + assert.deepEqual(f.read(), before); + assert.equal( + fs.readFileSync(path.join(f.root, 'config/secrets.json'), 'utf8'), + '{"private":"preserve"}', + ); +}); + +test('inactive release cannot open or reuse a live upgraded installation', async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + const oldApp = path.join(f.root, f.read().release); + await f.run('0.2.0'); + const lease = await tryAcquireInstanceLock({ + root: f.root, + secureDirectory: privateDirectory, + secureFile: privateFile, + }); + const phases = []; + try { + await assert.rejects( + startLauncher({ + root: f.root, + appDir: oldApp, + timeout: 100, + onPhase: (phase) => phases.push(phase), + browser() { + throw new Error('Must not open'); + }, + }), + /not active/, + ); + assert.equal(phases.includes('reuse'), false); + assert.equal(phases.includes('preflight'), false); + } finally { + lease(); + } +}); + +for (const missing of ['', 'data', 'config', 'data/app.db', 'config/secrets.json']) { + test(`launcher gives private recovery guidance for missing selected ${missing || 'generation'}`, async (t) => { + const f = fixture(t); + f.seed(); + await f.run(); + await f.run('0.2.0'); + const selected = f.read(); + fs.rmSync(path.join(f.root, selected.state, missing), { recursive: true }); + await assert.rejects( + startLauncher({ + root: f.root, + appDir: path.join(f.root, selected.release), + browser() { + throw new Error('Must not open'); + }, + }), + (error) => { + assert.match(error.message, /active study state is missing/); + assert.match(error.message, /Restore the complete private backup/); + assert.equal(error.message.includes(f.root), false); + assert.equal(error.message.includes('ENOENT'), false); + return true; + }, + ); + assert.deepEqual(f.read(), selected); + assert.equal(fs.readFileSync(path.join(f.root, 'data/app.db'), 'utf8'), 'offline database'); + }); +} diff --git a/tests/desktop/worker-lock.test.mjs b/tests/desktop/worker-lock.test.mjs new file mode 100644 index 0000000..3d8b04e --- /dev/null +++ b/tests/desktop/worker-lock.test.mjs @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fork, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { test } from 'node:test'; +import { privateDirectory, privateFile } from '../../scripts/launcher.mjs'; +import { tryAcquireWorkerLock } from '../../scripts/desktop/operation-lock.mjs'; + +const runner = fileURLToPath(new URL('../../scripts/desktop/worker-runner.mjs', import.meta.url)); +const guard = fileURLToPath(new URL('../../scripts/desktop/worker-guard.cjs', import.meta.url)); +const targetFixture = fileURLToPath(new URL('./fixtures/worker-target.cjs', import.meta.url)); +const supervisorFixture = fileURLToPath( + new URL('./fixtures/worker-supervisor.cjs', import.meta.url), +); +const delayedFixture = fileURLToPath(new URL('./fixtures/delayed-worker.mjs', import.meta.url)); +const options = (root) => ({ root, secureDirectory: privateDirectory, secureFile: privateFile }); +const environment = (root) => ({ + ...process.env, + EXAMIFY_MODE: 'solo', + EXAMIFY_INSTALL_ROOT: root, + EXAMIFY_LAUNCHER_PID: String(process.pid), +}); +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'examify-worker-')); + privateDirectory(root); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return { root, target: targetFixture }; +} +function launch(t, root, target, args = [], env = {}) { + const child = fork(runner, [target, ...args], { + env: { ...environment(root), ...env }, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + let error = ''; + child.stderr.on('data', (chunk) => { + error += chunk; + }); + const exit = once(child, 'exit'); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await exit; + }); + return { child, exit, error: () => error }; +} +async function message(child, type) { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + cleanup(); + reject(new Error(`Worker never sent ${type}`)); + }, + process.platform === 'win32' ? 30000 : 10000, + ); + const cleanup = () => { + clearTimeout(timer); + child.off('message', onMessage); + child.off('exit', onExit); + }; + const onMessage = (value) => { + if (value?.type === type) { + cleanup(); + resolve(value); + } + }; + const onExit = () => { + cleanup(); + reject(new Error('Worker exited before readiness')); + }; + child.on('message', onMessage); + child.once('exit', onExit); + }); +} + +test('worker lease precedes data access, holds through lifespan and releases on SIGKILL', async (t) => { + const { root, target } = fixture(t); + // Reused or dead PID markers are irrelevant: only the kernel lease is authority. + fs.writeFileSync(path.join(root, 'running.json'), JSON.stringify({ pid: process.pid })); + const { child, exit } = launch(t, root, target); + await message(child, 'examify-worker-ready'); + assert.equal(fs.existsSync(path.join(root, 'opened')), false); + assert.equal(await tryAcquireWorkerLock(options(root)), null); + const opened = message(child, 'opened'); + child.send({ type: 'examify-worker-go' }); + await opened; + assert.equal(await tryAcquireWorkerLock(options(root)), null); + child.kill('SIGKILL'); + await exit; + const release = await tryAcquireWorkerLock(options(root)); + assert.equal(typeof release, 'function'); + release(); + assert.equal(fs.existsSync(path.join(root, '.examify-operations', 'worker.sqlite')), true); +}); + +test('candidate data/config paths cannot change the stable worker lock namespace', async (t) => { + const { root, target } = fixture(t); + const release = await tryAcquireWorkerLock(options(root)); + const { exit } = launch(t, root, target, [], { + EXAMIFY_CONFIG_DIR: path.join(root, 'candidate-config'), + EXAMIFY_DATA_DIR: path.join(root, 'candidate-data'), + }); + assert.equal((await exit)[0], 1); + assert.equal(fs.existsSync(path.join(root, 'opened')), false); + release(); +}); + +test('disconnect before GO exits without reading settings or running migrations', async (t) => { + const { root, target } = fixture(t); + const { child, exit } = launch(t, root, target, ['--settings']); + await message(child, 'examify-worker-ready'); + child.disconnect(); + await exit; + assert.equal(fs.existsSync(path.join(root, 'opened')), false); + const release = await tryAcquireWorkerLock(options(root)); + assert.equal(typeof release, 'function'); + release(); +}); + +test('a standalone preload fails closed before reading any application state', (t) => { + const { root, target } = fixture(t); + const result = spawnSync(process.execPath, ['--require', guard, target], { + env: environment(root), + }); + assert.notEqual(result.status, 0); + assert.equal(fs.existsSync(path.join(root, 'opened')), false); +}); + +test('worker preload refuses unsafe coordination files and directories', async (t) => { + for (const kind of [ + 'symlink-directory', + 'hardlink-file', + 'symlink-file', + 'public-file', + 'public-directory', + ]) { + if (process.platform === 'win32' && (kind.startsWith('public-') || kind === 'symlink-file')) + continue; + await t.test(kind, async (t) => { + const { root, target } = fixture(t); + const directory = path.join(root, '.examify-operations'); + const other = path.join(root, 'other'); + fs.writeFileSync(other, 'preserve', { mode: 0o600 }); + if (kind === 'symlink-directory') { + const elsewhere = path.join(root, 'elsewhere'); + fs.mkdirSync(elsewhere, { mode: 0o700 }); + fs.symlinkSync(elsewhere, directory, process.platform === 'win32' ? 'junction' : 'dir'); + } else { + fs.mkdirSync(directory, { mode: kind === 'public-directory' ? 0o755 : 0o700 }); + const file = path.join(directory, 'worker.sqlite'); + if (kind === 'hardlink-file') fs.linkSync(other, file); + if (kind === 'symlink-file') fs.symlinkSync(other, file); + if (kind === 'public-file') fs.writeFileSync(file, '', { mode: 0o644 }); + } + const { exit } = launch(t, root, target); + assert.equal((await exit)[0], 1); + assert.equal(fs.existsSync(path.join(root, 'opened')), false); + assert.equal(fs.readFileSync(other, 'utf8'), 'preserve'); + }); + } +}); + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // Permission errors or other failures are not evidence of termination. + if (error.code !== 'ESRCH') throw error; + return false; + } +} +function killIfRunning(pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } +} +async function waitUntil(predicate) { + const deadline = Date.now() + 10000; + while (!predicate() && Date.now() < deadline) await sleep(20); + return predicate(); +} +function supervisor(t, root, target, delayed = false) { + const parent = fork(supervisorFixture, [runner, target, ...(delayed ? [delayedFixture] : [])], { + env: { ...environment(root), EXAMIFY_TEST_WORKER_MODE: delayed ? '' : 'blocked' }, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + const exited = once(parent, 'exit'); + t.after(async () => { + if (parent.exitCode === null && parent.signalCode === null) parent.kill('SIGKILL'); + await exited; + }); + return { parent, exited }; +} + +test('disconnect cannot release a live worker blocked in data access', async (t) => { + const { root, target } = fixture(t); + const { child, exit } = launch(t, root, target, [], { EXAMIFY_TEST_WORKER_MODE: 'blocked' }); + await message(child, 'examify-worker-ready'); + const blocked = message(child, 'blocked'); + child.send({ type: 'examify-worker-go' }); + await blocked; + child.disconnect(); + assert.equal(processExists(child.pid), true); + assert.equal(await tryAcquireWorkerLock(options(root)), null); + child.kill('SIGKILL'); + await exit; + const release = await tryAcquireWorkerLock(options(root)); + assert.equal(typeof release, 'function'); + release(); +}); + +test('killed supervisor cannot release a still-running worker lease', async (t) => { + const { root, target } = fixture(t); + const { parent, exited } = supervisor(t, root, target); + const { pid } = await message(parent, 'blocked'); + t.after(() => killIfRunning(pid)); + assert.equal(await tryAcquireWorkerLock(options(root)), null); + parent.kill('SIGKILL'); + await exited; + let release = await tryAcquireWorkerLock(options(root)); + if (release) { + try { + // Windows job objects can terminate descendants with their supervisor. + // A free lease is safe only when the worker is independently proven dead. + assert.equal(processExists(pid), false, 'free lease requires a dead worker'); + } finally { + release(); + } + } else { + // POSIX normally leaves the blocked worker alive and its lease held. + killIfRunning(pid); + const deadline = Date.now() + 10000; + while (!(release = await tryAcquireWorkerLock(options(root))) && Date.now() < deadline) + await sleep(20); + assert.equal(typeof release, 'function'); + release(); + } +}); + +test('a delayed child cannot open data after its launcher died and installer obtained the lease', async (t) => { + const { root, target } = fixture(t); + const gate = path.join(root, 'gate'); + const childExit = path.join(root, 'child-exit'); + const { parent, exited } = supervisor(t, root, target, true); + const { pid } = await message(parent, 'waiting'); + t.after(() => killIfRunning(pid)); + assert.equal(fs.existsSync(path.join(root, 'opened')), false); + parent.kill('SIGKILL'); + await exited; + const release = await tryAcquireWorkerLock(options(root)); + assert.equal(typeof release, 'function'); + try { + fs.writeFileSync(gate, 'start'); + // A force-killed descendant cannot execute JS exit hooks. Accept this only + // with independent OS evidence of termination, never a missing sentinel alone. + assert.equal( + await waitUntil(() => fs.existsSync(childExit) || !processExists(pid)), + true, + 'delayed child must exit or be confirmed terminated without GO', + ); + if (fs.existsSync(childExit)) assert.equal(fs.readFileSync(childExit, 'utf8'), '1'); + assert.equal(fs.existsSync(path.join(root, 'opened')), false); + } finally { + release(); + } + assert.equal(fs.existsSync(path.join(root, 'opened')), false); +}); + +test('an authorized migration can finish naturally while IPC remains connected', async (t) => { + const { root, target } = fixture(t); + const { child, exit } = launch(t, root, target, [], { EXAMIFY_TEST_WORKER_MODE: 'natural' }); + await message(child, 'examify-worker-ready'); + child.send({ type: 'examify-worker-go' }); + assert.equal((await exit)[0], 0); + assert.equal(fs.readFileSync(path.join(root, 'opened'), 'utf8'), 'yes'); + const release = await tryAcquireWorkerLock(options(root)); + assert.equal(typeof release, 'function'); + release(); +});