ReviewOS

also looking at this

stacks/drone

feat: verify and stage signed runtime images

#41
Merged chris wants to merge codex/runtime-updates into main
20 files +793 -7

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -32,6 +32,8 @@ jobs:
3232 - run: python3 -m unittest discover -s tests/hardware
3333 - run: ./buddy test
3434 - run: bun run runtime:cli
35 - name: Verify signed test-image staging through the portable CLI outside the checkout
36 run: bun tests/fixtures/updates/qualify.ts "$RUNNER_TEMP/runtime-update-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
3537 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
3638 working-directory: ${{ runner.temp }}
3739 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+4-1
Changes to README.md
@@ -262,7 +262,10 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/
262262
263263The viewer opens files locally in the browser. The v1 format excludes imagery, location and
264264operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md).
265Physical logging/commissioning and signed firmware update/rollback remain pending.
265The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF
266candidates through `updates:stage` and `updates:inspect`, with explicit root pins,
267compatibility and externally supplied release floors. Physical logging/commissioning,
268persistent trust state, image installation and known-good rollback remain pending.
266269
267270## Sensor acquisition and replay
268271
app/Commands/UpdatesInspect.tsadded+18-0
Changes to app/Commands/UpdatesInspect.ts
@@ -0,0 +1,18 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command'
4import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging'
5
6export default defineCommand({
7 name: 'updates:inspect <directory>',
8 description: 'Re-authenticate a staged bench image against explicit trust and compatibility policy',
9 options: firmwarePolicyFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectStagedRuntimeFirmware({ ...firmwareCommandPolicy(options), directory }))) }
12 catch {
13 process.exitCode = 1
14 try { await printErrorAsync('Firmware candidate unavailable: invalid trust, rejected image or output failure') }
15 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
16 }
17 },
18})
app/Commands/UpdatesStage.tsadded+22-0
Changes to app/Commands/UpdatesStage.ts
@@ -0,0 +1,22 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command'
4import { stageRuntimeFirmware } from '../../resources/functions/drone/updates/staging'
5
6export default defineCommand({
7 name: 'updates:stage <repository>',
8 description: 'Verify an offline signed bench image into a new candidate without installing firmware',
9 options: { ...firmwarePolicyFlags, '--trusted-root <path>': 'Initial root file enrolled through an independent trusted channel', '--output <path>': 'New exclusive candidate directory' },
10 async handle(options, repository) {
11 try {
12 const policy = firmwareCommandPolicy(options)
13 if (typeof options.trustedRoot !== 'string' || typeof options.output !== 'string') throw new Error('Explicit root path and output required')
14 await printAsync(JSON.stringify(await stageRuntimeFirmware({ ...policy, repository, trustedRoot: options.trustedRoot, output: options.output })))
15 }
16 catch {
17 process.exitCode = 1
18 try { await printErrorAsync('Firmware staging unavailable: invalid trust, rejected image or output failure') }
19 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
20 }
21 },
22})
bin/drone.tsmodified+4-0
Changes to bin/drone.ts
@@ -19,6 +19,8 @@ import DiagnosticsRecord from '../app/Commands/DiagnosticsRecord'
1919import DiagnosticsReplay from '../app/Commands/DiagnosticsReplay'
2020import DiagnosticsInspect from '../app/Commands/DiagnosticsInspect'
2121import DiagnosticsExport from '../app/Commands/DiagnosticsExport'
22import UpdatesStage from '../app/Commands/UpdatesStage'
23import UpdatesInspect from '../app/Commands/UpdatesInspect'
2224
2325import MissionsSimulate from '../app/Commands/MissionsSimulate'
2426import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -81,6 +83,8 @@ DiagnosticsRecord(app)
8183DiagnosticsReplay(app)
8284DiagnosticsInspect(app)
8385DiagnosticsExport(app)
86UpdatesStage(app)
87UpdatesInspect(app)
8488MissionsSimulate(app)
8589MissionsValidate(app)
8690MissionsReplay(app)
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -67,6 +67,7 @@ const config: BunPressOptions = {
6767 { text: 'Owned mission engine', link: '/missions' },
6868 { text: 'Owned range-backed navigation', link: '/navigation' },
6969 { text: 'Owned runtime diagnostics, journals and replay', link: '/diagnostics' },
70 { text: 'Signed runtime image staging', link: '/runtime-updates' },
7071 ],
7172 },
7273 {
docs/diagnostics.mdmodified+5-2
Changes to docs/diagnostics.md
@@ -155,8 +155,11 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
155155- Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load.
156156 Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on
157157 the actual Pi/Pico setup. Disabled loads and independent authority remain required.
158- Build signed firmware manifests and trust/key lifecycle, target/version checks, anti-rollback,
159 verified staging, interrupted update recovery and a known-good rollback slot before flashing.
158- [Offline signed image staging](runtime-updates.md) now authenticates TUF roles,
159 declared target compatibility and a caller-supplied release floor. Build persistent
160 trust/key lifecycle and metadata/release high-water state, interrupted update
161 recovery and a known-good rollback slot before flashing. Each staging invocation
162 currently starts from the independently supplied root; it is not full anti-rollback.
160163- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
161164 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
162165
docs/index.mdmodified+2-0
Changes to docs/index.md
@@ -47,6 +47,8 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md),
4747[navigation](navigation.md) have separate simulation and hardware acceptance gates.
4848
4949[Diagnostics and replay](diagnostics.md) retain explicit loss and provenance.
50[Signed image staging](runtime-updates.md) authenticates offline candidates;
51persistent trust, boot selection and rollback remain required before installation.
5052The [library API](api.md) describes behavior extensions. The
5153[shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground,
5254surface and subsea profiles. [Optional external drivers](drivers.md) have
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, hash verification, crash-prefix recovery, pure replay, CLI and local stx viewer; full mission correlation, hardware storage/crash evidence and signed updates pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [offline signed image staging](../runtime-updates.md); full mission correlation, hardware evidence, persistent trust and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/runtime-updates.mdadded+114-0
Changes to docs/runtime-updates.md
@@ -0,0 +1,114 @@
1# Signed runtime image staging
2
3Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
4staging and independent candidate verification. These commands prepare files on
5the host. Persistent trust state, installed images, boot selection and known-good
6rollback slots remain to be implemented. Physical deployment is disabled.
7
8## Explicit trust and policy
9
10Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted
11channel. Do not calculate a pin from an untrusted bundle and call it trusted.
12The release floor must likewise come from externally trusted release policy.
13Every invocation requires the root pin, exact target, expected bench platform
14and positive minimum release sequence. There are no permissive defaults.
15
16```sh
17./buddy updates:stage /path/to/offline-repository \
18 --trusted-root /path/to/enrolled/root.json \
19 --root-sha256 "$ENROLLED_ROOT_SHA256" \
20 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
21 --minimum-release-sequence 7 --output /path/to/new-candidate
22
23./buddy updates:inspect /path/to/new-candidate \
24 --root-sha256 "$ENROLLED_ROOT_SHA256" \
25 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
26 --minimum-release-sequence 7
27```
28
29The same commands are registered in the [compiled standalone CLI](runtime.md).
30Run them as offline maintenance work, separately from a control-loop process.
31The implementation requires a POSIX filesystem and bounded regular files.
32Parent directories must already exist. An output directory must be new and
33separate from the source repository; existing candidates are never overwritten.
34Repository/candidate directories must belong to the invoking user and must not
35be group/world writable. Their parent paths must also be protected against
36replacement by other users. Same-user privileged filesystem mutation is outside
37the protection model; this is not an adversarial-filesystem sandbox.
38
39## Repository and signed image contract
40
41Use a standard TUF repository with `metadata/` and `targets/` directories,
42including consistent-snapshot names when enabled. Verification uses the pinned
43[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
44rotation, signature thresholds, expiration, inter-role versions/hashes and
45target length/hashes. A private, randomly addressed loopback server supplies
46bounded local files to the client's public HTTP interface. It is stopped after
47verification. No external network fetch or arbitrary URL option is provided.
48
49The selected target must have SHA-256, a nonzero length of at most 16 MiB and
50exactly these fields in its signed `custom.drone` object:
51
52```json
53{
54 "schema": "drone-runtime-image-v1",
55 "platform": "pico2-neutral-bench-v1",
56 "protocolVersion": 1,
57 "releaseSequence": 7,
58 "sourceRevision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
59 "profile": "neutral-bench"
60}
61```
62
63The other supported platform label is `host-neutral-bench-v1`. Target and metadata
64paths use bounded ASCII names without traversal, escapes or symlinks. Metadata
65is limited to 512 KiB per file, 16 root rotations, 16 delegations, 64 requests and
6632 MiB of distinct fetched bytes. FIFO, device, symlink and oversized inputs fail.
67The neutral profile is a **signer's declaration**, not an inspection of machine
68code or proof of neutral outputs. This module authenticates bytes and declared
69compatibility; it does not parse UF2/ELF or execute an image.
70
71## Candidate integrity and interruption
72
73The private candidate contains the original `trust-root.json`, verified
74`image.bin`, fetched repository files, SDK metadata cache and final `stage.json`.
75The image is verified again after copying. Files/directories are synced and the
76completion marker is written last. Failures propagate and leave evidence in the
77new directory. A partial directory without a valid marker fails inspection.
78Do not reuse or automatically resume that directory.
79
80Inspection starts a fresh verifier with the independently supplied root pin and
81policy. It verifies the saved repository's signed metadata and actual image
82again, then compares the receipt with authenticated values. It does not trust
83the receipt's claimed hash, platform, release or authority. A raised release
84floor can reject a previously accepted candidate. Inspection does not modify
85the candidate.
86
87The marker means logical completeness. It cannot prove that a final sync or
88subsequent power loss preserved bytes. A process can fail after the marker was
89written. Inspection must always re-authenticate the candidate; no consumer may
90treat marker existence alone as permission to install or boot it.
91
92## Remaining update and operations acceptance
93
94Each verification starts from the supplied root. `trustStatePersisted: false`
95means this module has no protected cross-invocation metadata or release
96high-water state. It is not a complete anti-rollback implementation. The caller
97must supply the trusted release floor every time. TUF expiry uses host wall
98time; trusted clock acquisition and protection against clock/storage rollback
99remain open. Control-loop freshness still uses its separate monotonic clock.
100
101Next, implement persistent trust enrollment/rotation and release floors, an
102interruption-safe coordinator, installed/known-good slot integrity, explicit
103health confirmation and rollback without lowering security floors. Then add
104the board bootloader/install protocol and measured power-loss, full-medium,
105storage-latency and recovery campaigns. Local privileged filesystem tampering
106is outside this host module's protection; it is not secure boot or hardware
107anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.github.io/specification/latest/).
108
109Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes.
110They cover real role signatures/rotation, expiration, compatibility, supplied
111release floors, tampering, bounded inputs, incomplete candidates and native/
112portable CLI behavior. They establish host behavior, not actual firmware
113installation, board boot recovery or commissioning. The diagnostic supervisor's
114update/rollback check remains blocked and `commissioningReady` stays false.
resources/functions/drone/diagnostics/replay.tsmodified+1-1
Changes to resources/functions/drone/diagnostics/replay.ts
@@ -59,7 +59,7 @@ export function diagnoseDiagnostics(journal: DiagnosticJournal) {
5959 { id: 'runtime-status', state: result.latest && !result.statusStale ? 'pass' : 'fail', detail: result.latest ? (result.statusStale ? 'stale' : 'recorded') : 'missing' },
6060 { id: 'runtime-health', state: result.health.length || result.replayedStatus || result.wireGaps ? 'fail' : 'pass', detail: result.health.join(', ') || (result.wireGaps ? 'status-gaps' : 'no-observed-fault') },
6161 { id: 'physical-commissioning', state: 'blocked', detail: 'independent-hardware-evidence-required' },
62 { id: 'signed-update-rollback', state: 'blocked', detail: 'not-implemented' },
62 { id: 'signed-update-rollback', state: 'blocked', detail: 'offline-staging-only; persistent-trust-and-rollback-required' },
6363 ] as const
6464 return { version: 1, runId: journal.header.runId, evidence: journal.header.evidence, verifiedHash: journal.verifiedHash, checks, diagnosticFailures: checks.filter(check => check.state === 'fail').length, commissioningReady: false as const, replay: result, physicalFlightEnabled: false as const }
6565}
resources/functions/drone/updates/command.tsadded+14-0
Changes to resources/functions/drone/updates/command.ts
@@ -0,0 +1,14 @@
1import type { RuntimeFirmwarePolicy } from './staging'
2
3export const firmwarePolicyFlags = {
4 '--target <path>': 'Explicit signed target path inside the offline repository',
5 '--platform <name>': 'Expected pico2-neutral-bench-v1 or host-neutral-bench-v1 platform',
6 '--root-sha256 <hex>': 'Independently trusted SHA-256 of the initial root metadata',
7 '--minimum-release-sequence <n>': 'Explicit externally trusted positive release floor',
8} as const
9
10export function firmwareCommandPolicy(options: { target?: unknown, platform?: unknown, rootSha256?: unknown, minimumReleaseSequence?: unknown }): RuntimeFirmwarePolicy {
11 if (typeof options.target !== 'string' || typeof options.rootSha256 !== 'string' || typeof options.minimumReleaseSequence !== 'string' || !/^[1-9]\d{0,15}$/.test(options.minimumReleaseSequence)) throw new Error('Explicit target, root pin and trusted release floor are required')
12 if (options.platform !== 'pico2-neutral-bench-v1' && options.platform !== 'host-neutral-bench-v1') throw new Error('Explicit implemented bench platform required')
13 return { target: options.target, platform: options.platform, trustedRootSha256: options.rootSha256, minimumReleaseSequence: Number(options.minimumReleaseSequence) }
14}
resources/functions/drone/updates/staging.tsadded+221-0
Changes to resources/functions/drone/updates/staging.ts
@@ -0,0 +1,221 @@
1import { createHash, randomBytes } from 'node:crypto'
2import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeSync } from 'node:fs'
3import { dirname, join, relative, resolve, sep } from 'node:path'
4import { tmpdir } from 'node:os'
5import { Readable } from 'node:stream'
6import { isDeepStrictEqual } from 'node:util'
7import { Updater } from 'tuf-js'
8import type { TargetFile } from 'tuf-js'
9
10const metadataLimit = 512 * 1024
11const imageLimit = 16 * 1024 * 1024
12const originPrefix = '/offline-'
13const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
14
15export interface RuntimeFirmwarePolicy {
16 target: string
17 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
18 trustedRootSha256: string
19 /** Explicit externally trusted floor. This module does not persist or advance it. */
20 minimumReleaseSequence: number
21}
22export interface RuntimeFirmwareStage {
23 schema: 'drone-runtime-stage-v1'
24 target: string
25 platform: RuntimeFirmwarePolicy['platform']
26 trustedRootSha256: string
27 rootVersion: number
28 releaseSequence: number
29 protocolVersion: 1
30 sourceRevision: string
31 signerDeclaredProfile: 'neutral-bench'
32 length: number
33 sha256: string
34 physicalDeploymentAllowed: false
35 trustStatePersisted: false
36}
37
38function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
39 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
40 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
41 safePath(policy.target)
42 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
43 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
44 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
45 return Object.freeze(policy)
46}
47function safePath(path: string) {
48 if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path')
49}
50function regularFile(path: string, limit: number): Buffer {
51 const before = lstatSync(path)
52 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
53 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
54 try {
55 const stat = fstatSync(fd)
56 if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file')
57 const bytes = Buffer.alloc(stat.size + 1)
58 let offset = 0
59 while (offset < bytes.length) {
60 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
61 if (count === 0) break
62 offset += count
63 }
64 if (offset !== stat.size) throw new Error('Source file changed length while reading')
65 return bytes.subarray(0, offset)
66 }
67 finally { closeSync(fd) }
68}
69function directory(path: string) {
70 const stat = lstatSync(path)
71 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
72 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Repository and candidate directories must be owned and not writable by other principals')
73}
74function repositoryFile(root: string, path: string, limit: number) {
75 safePath(path)
76 directory(root)
77 const parts = path.split('/')
78 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
79 return regularFile(join(root, ...parts), limit)
80}
81function writeExclusive(path: string, bytes: Uint8Array) {
82 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
83 try {
84 let offset = 0
85 while (offset < bytes.length) {
86 const count = writeSync(fd, bytes, offset, bytes.length - offset)
87 if (count <= 0) throw new Error('No progress writing firmware candidate')
88 offset += count
89 }
90 fsyncSync(fd)
91 }
92 finally { closeSync(fd) }
93}
94function syncDirectory(path: string) {
95 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
96 try { fsyncSync(fd) }
97 finally { closeSync(fd) }
98}
99function syncTree(path: string) {
100 directory(path)
101 for (const name of readdirSync(path)) {
102 const child = join(path, name), stat = lstatSync(child)
103 if (stat.isDirectory()) syncTree(child)
104 else {
105 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
106 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
107 try { fsyncSync(fd) }
108 finally { closeSync(fd) }
109 }
110 }
111 syncDirectory(path)
112}
113function object(value: unknown): Record<string, unknown> {
114 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
115 return value as Record<string, unknown>
116}
117function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
118 const data = object(info.custom.drone)
119 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
120 if (Object.keys(data).length !== keys.length || !keys.every(key => Object.hasOwn(data, key)) || data.schema !== 'drone-runtime-image-v1' || data.profile !== 'neutral-bench') throw new Error('Unsupported signed runtime manifest')
121 if (data.platform !== policy.platform || data.protocolVersion !== 1) throw new Error('Signed runtime platform or protocol does not match policy')
122 if (!Number.isSafeInteger(data.releaseSequence) || (data.releaseSequence as number) < policy.minimumReleaseSequence) throw new Error('Signed release is below the trusted minimum release sequence')
123 if (typeof data.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceRevision)) throw new Error('Invalid signed source revision')
124 if (!Number.isSafeInteger(info.length) || info.length < 1 || info.length > imageLimit || !/^[a-f0-9]{64}$/.test(info.hashes.sha256 ?? '')) throw new Error('Runtime image requires a bounded length and SHA-256')
125 return { releaseSequence: data.releaseSequence as number, sourceRevision: data.sourceRevision }
126}
127
128/** Use the SDK's public HTTP interface on a private loopback server. No external fetch is performed. */
129async function verifyRepository(repository: string, root: Buffer, work: string, policy: RuntimeFirmwarePolicy, download?: string) {
130 const fetched = new Map<string, Buffer>()
131 let requests = 0, received = 0
132 const prefix = `${originPrefix}${randomBytes(32).toString('hex')}/`
133 const server = Bun.serve({
134 hostname: '127.0.0.1', port: 0,
135 fetch(request) {
136 const url = new URL(request.url)
137 if (request.method !== 'GET' || url.search || !url.pathname.startsWith(prefix)) return new Response(null, { status: 404 })
138 if (++requests > 64) return new Response(null, { status: 429 })
139 const path = url.pathname.slice(prefix.length)
140 try {
141 safePath(path)
142 if (!path.startsWith('targets/') && !/^metadata\/[a-z0-9][a-z0-9._-]{0,179}\.json$/i.test(path)) throw new Error('Unsupported metadata path')
143 let bytes = fetched.get(path)
144 if (!bytes) {
145 bytes = repositoryFile(repository, path, path.startsWith('metadata/') ? metadataLimit : imageLimit)
146 received += bytes.length
147 if (received > 32 * 1024 * 1024) throw new Error('Repository byte budget exceeded')
148 fetched.set(path, bytes)
149 }
150 return new Response(new Uint8Array(bytes), { headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' } })
151 }
152 catch (error) {
153 // Only an absent file is a TUF 404. Rejected devices, symlinks and IO failures must fail refresh.
154 return new Response(null, { status: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 404 : 500 })
155 }
156 },
157 })
158 try {
159 mkdirSync(work, { mode: 0o700 })
160 writeExclusive(join(work, 'root.json'), root)
161 const base = `http://127.0.0.1:${server.port}${prefix}`
162 const updater = new Updater({ metadataDir: work, metadataBaseUrl: `${base}metadata/`, targetBaseUrl: `${base}targets/`, config: { maxRootRotations: 16, maxDelegations: 16, rootMaxLength: metadataLimit, timestampMaxLength: metadataLimit, snapshotMaxLength: metadataLimit, targetsMaxLength: metadataLimit, fetchTimeout: 5000, fetchRetry: false } })
163 await updater.refresh()
164 const info = await updater.getTargetInfo(policy.target)
165 if (!info) throw new Error('Selected firmware target is absent')
166 const runtime = signedRuntime(info, policy)
167 if (download) await updater.downloadTarget(info, download)
168 const latest = object(JSON.parse(regularFile(join(work, 'root.json'), metadataLimit).toString('utf8')))
169 const rootVersion = object(latest.signed).version
170 if (!Number.isSafeInteger(rootVersion)) throw new Error('Invalid verified root version')
171 const result: RuntimeFirmwareStage = { schema: 'drone-runtime-stage-v1', target: policy.target, platform: policy.platform, trustedRootSha256: policy.trustedRootSha256, rootVersion: rootVersion as number, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, physicalDeploymentAllowed: false, trustStatePersisted: false }
172 return { result, fetched, info }
173 }
174 finally { await server.stop(true) }
175}
176
177/** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */
178export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> {
179 const policy = admitPolicy(options)
180 const repository = realpathSync(options.repository), output = resolve(options.output)
181 directory(repository)
182 const root = regularFile(options.trustedRoot, metadataLimit)
183 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
184 // Resolve the existing parent before creating anything, so a symlinked parent cannot hide overlap.
185 const destination = join(realpathSync(dirname(output)), output.slice(dirname(output).length + 1))
186 const child = relative(repository, destination), parent = relative(destination, repository)
187 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!parent.startsWith(`..${sep}`) && parent !== '..')) throw new Error('Candidate and source repository must be separate')
188 mkdirSync(output, { mode: 0o700 })
189 writeExclusive(join(output, 'trust-root.json'), root)
190 const { result, fetched, info } = await verifyRepository(repository, root, join(output, '.verification'), policy, join(output, 'image.bin'))
191 // Re-read the actual staged bytes, rather than trusting a successful copy or receipt.
192 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
193 for (const [path, bytes] of fetched) {
194 const file = join(output, 'repository', path)
195 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
196 writeExclusive(file, bytes)
197 }
198 syncTree(output)
199 // A final marker means logical completeness. It cannot attest to power-loss durability after fsync.
200 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(result, null, 2)}\n`))
201 syncDirectory(output)
202 syncDirectory(dirname(output))
203 return result
204}
205
206/** Re-authenticate staged metadata and image using caller-supplied trust/policy, never a self-asserted receipt. */
207export async function inspectStagedRuntimeFirmware(options: RuntimeFirmwarePolicy & { directory: string }): Promise<RuntimeFirmwareStage> {
208 const policy = admitPolicy(options), candidate = resolve(options.directory)
209 directory(candidate)
210 const receipt: unknown = JSON.parse(regularFile(join(candidate, 'stage.json'), 16384).toString('utf8'))
211 const root = regularFile(join(candidate, 'trust-root.json'), metadataLimit)
212 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
213 const work = mkdtempSync(join(tmpdir(), 'drone-update-inspect-'))
214 try {
215 const { result, info } = await verifyRepository(join(candidate, 'repository'), root, join(work, 'metadata'), policy)
216 await info.verify(Readable.from(regularFile(join(candidate, 'image.bin'), imageLimit)))
217 if (!isDeepStrictEqual(receipt, result)) throw new Error('Staged receipt does not match re-authenticated image metadata')
218 return result
219 }
220 finally { rmSync(work, { recursive: true, force: true }) }
221}
tests/fixtures/updates/qualify.tsadded+36-0
Changes to tests/fixtures/updates/qualify.ts
@@ -0,0 +1,36 @@
1import { mkdir, readFile, writeFile } from 'node:fs/promises'
2import { resolve, join } from 'node:path'
3import { repository, platform, target } from './repository'
4
5/** Qualification uses generated test payloads/keys and never executes an image. */
6export async function qualifyRuntimeUpdates(directory: string, binary: string) {
7 await mkdir(directory, { mode: 0o700 })
8 const repo = join(directory, 'repo'), candidate = join(directory, 'candidate'), fixture = await repository(repo)
9 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
10 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
11 async function run(args: string[], expected: number) {
12 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
13 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
14 try {
15 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
16 results.push({ command: args[0]!, exit, stdout, stderr })
17 if (exit !== expected) throw new Error(`Unexpected portable update exit: ${exit}`)
18 return stdout
19 }
20 finally { clearTimeout(timer) }
21 }
22 try {
23 const staged = JSON.parse(await run(['updates:stage', repo, '--trusted-root', fixture.rootPath, '--output', candidate, ...flags], 0))
24 if (staged.sha256 !== fixture.imageHash || staged.physicalDeploymentAllowed !== false || staged.trustStatePersisted !== false) throw new Error('Invalid portable stage result')
25 const inspected = JSON.parse(await run(['updates:inspect', candidate, ...flags], 0))
26 if (JSON.stringify(inspected) !== JSON.stringify(staged)) throw new Error('Portable re-verification mismatch')
27 const rejected = await run(['updates:inspect', candidate, ...flags.slice(0, -1), '8'], 1)
28 if (rejected !== '' || !(await readFile(join(candidate, 'image.bin'))).equals(fixture.image)) throw new Error('Rejected verification changed the candidate')
29 }
30 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'generated-signed-test-payload-only', physicalFirmwareInstalled: false, results }, null, 2)) }
31 return { evidence: 'generated-signed-test-payload-only', operations: results.length, physicalFirmwareInstalled: false }
32}
33if (import.meta.main) {
34 if (process.argv.length !== 4) throw new Error('Use qualify.ts NEW_DIRECTORY COMPILED_BINARY')
35 console.log(JSON.stringify(await qualifyRuntimeUpdates(resolve(process.argv[2]!), resolve(process.argv[3]!))))
36}
tests/fixtures/updates/repository.tsadded+54-0
Changes to tests/fixtures/updates/repository.ts
@@ -0,0 +1,54 @@
1import { generateKeyPairSync, sign, createHash } from 'node:crypto'
2import { mkdir, writeFile } from 'node:fs/promises'
3import { join } from 'node:path'
4import { Key, Metadata, MetaFile, Root, Signature, Snapshot, TargetFile, Targets, Timestamp } from '@tufjs/models'
5
6export const target = 'runtime/pico2-bench.bin'
7export const platform = 'pico2-neutral-bench-v1'
8export const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
9const fields = { version: 1, specVersion: '1.0.31', expires: '2035-01-01T00:00:00Z' }
10
11function signer() {
12 const pair = generateKeyPairSync('ed25519')
13 const publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex')
14 const key = new Key({ keyID: digest(Buffer.from(publicKey, 'hex')), keyType: 'ed25519', scheme: 'ed25519', keyVal: { public: publicKey } })
15 return { key, sign: (data: Buffer) => new Signature({ keyID: key.keyID, sig: sign(null, data, pair.privateKey).toString('hex') }) }
16}
17
18/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
19export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, rotate?: boolean, badRotation?: boolean, badTargetsSignature?: boolean } = {}) {
20 await mkdir(join(directory, 'metadata'), { recursive: true })
21 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
22 const keys = { root: signer(), targets: signer(), snapshot: signer(), timestamp: signer() }
23 const root = new Root(fields)
24 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role)
25 const rootMetadata = new Metadata(root)
26 rootMetadata.sign(keys.root.sign)
27 const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON()))
28 await writeFile(join(directory, 'trusted-root.json'), rootBytes)
29 if (options.rotate) {
30 const nextKey = signer(), next = new Root({ ...fields, version: 2 })
31 next.addKey(nextKey.key, 'root')
32 for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role)
33 const rotated = new Metadata(next)
34 if (!options.badRotation) rotated.sign(keys.root.sign)
35 rotated.sign(nextKey.sign)
36 await writeFile(join(directory, 'metadata/2.root.json'), JSON.stringify(rotated.toJSON()))
37 }
38 const image = Buffer.from('test payload only, never execute or flash\n')
39 const imageHash = digest(image)
40 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom }
41 const targets = new Metadata(new Targets({ ...fields, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } }))
42 targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign)
43 const targetsBytes = Buffer.from(JSON.stringify(targets.toJSON()))
44 const snapshot = new Metadata(new Snapshot({ ...fields, meta: { 'targets.json': new MetaFile({ version: 1, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } }))
45 snapshot.sign(keys.snapshot.sign)
46 const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON()))
47 const timestamp = new Metadata(new Timestamp({ ...fields, expires: options.expires ?? fields.expires, snapshotMeta: new MetaFile({ version: 1, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) }))
48 timestamp.sign(keys.timestamp.sign)
49 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
50 await writeFile(join(directory, 'metadata/1.snapshot.json'), snapshotBytes)
51 await writeFile(join(directory, 'metadata/1.targets.json'), targetsBytes)
52 await writeFile(join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`), image)
53 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath: join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`) }
54}
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -30,6 +30,9 @@
3030 "./unit/payloads.test.ts",
3131 "./fixtures/payloads/**/*.ts",
3232 "../resources/functions/drone/diagnostics/**/*.ts",
33 "../resources/functions/drone/updates/**/*.ts",
34 "./unit/updates*.test.ts",
35 "./fixtures/updates/**/*.ts",
3336 "./unit/diagnostics.test.ts",
3437 "./fixtures/diagnostics/**/*.ts",
3538 "../resources/functions/drone/missions/**/*.ts",
tests/unit/updates-cli.test.tsadded+31-0
Changes to tests/unit/updates-cli.test.ts
@@ -0,0 +1,31 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { repository, platform, target } from '../fixtures/updates/repository'
7
8const roots: string[] = []
9afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
10
11test('native update commands stage and reverify with explicit caller trust, then reject a raised floor', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-update-cli-')); roots.push(root)
13 const repo = join(root, 'repo'), output = join(root, 'candidate'), fixture = await repository(repo)
14 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
15 const staged = await command(['updates:stage', repo, '--trusted-root', fixture.rootPath, '--output', output, ...flags]).withTimeout(20000)
16 expect(staged.exitCode).toBe(0)
17 expect(staged.timedOut).toBe(false)
18 const value = JSON.parse(staged.stdout)
19 expect(value.sha256).toBe(fixture.imageHash)
20 expect(value.physicalDeploymentAllowed).toBe(false)
21 const inspected = await command(['updates:inspect', output, ...flags]).withTimeout(20000)
22 expect(inspected.exitCode).toBe(0)
23 expect(JSON.parse(inspected.stdout)).toEqual(value)
24 const rejected = await command(['updates:inspect', output, ...flags.slice(0, -1), '8']).withTimeout(20000)
25 expect(rejected.exitCode).toBe(1)
26 expect(rejected.stdout).toBe('')
27 expect(rejected.stderr).toBe('Firmware candidate unavailable: invalid trust, rejected image or output failure\n')
28 const missing = await command(['updates:inspect', output]).withTimeout(20000)
29 expect(missing.exitCode).toBe(1)
30 expect(missing.stdout).toBe('')
31}, 100000)
tests/unit/updates.test.tsadded+162-0
Changes to tests/unit/updates.test.ts
@@ -0,0 +1,162 @@
1import { test, expect, afterEach } from 'bun:test'
2import { mkdtemp, rm, readFile, writeFile, mkdir, symlink, stat, truncate, chmod } from 'node:fs/promises'
3import { spawnSync } from 'node:child_process'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { repository, platform, target } from '../fixtures/updates/repository'
7import { stageRuntimeFirmware, inspectStagedRuntimeFirmware, type RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging'
8
9const roots: string[] = []
10afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
11
12test('stage and independently reverify an explicitly selected signed runtime image', async () => {
13 const root = await mkdtemp(join(tmpdir(), 'drone-update-test-')); roots.push(root)
14 const repo = join(root, 'repo'), output = join(root, 'candidate')
15 const fixture = await repository(repo)
16 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
17 const result = await stageRuntimeFirmware({ ...policy, repository: repo, trustedRoot: fixture.rootPath, output })
18 expect(result.sha256).toBe(fixture.imageHash)
19 expect(result.releaseSequence).toBe(7)
20 expect(result.physicalDeploymentAllowed).toBe(false)
21 expect(result.trustStatePersisted).toBe(false)
22 expect(await readFile(join(output, 'image.bin'))).toEqual(fixture.image)
23 expect(await inspectStagedRuntimeFirmware({ ...policy, directory: output })).toEqual(result)
24 expect((await stat(output)).mode & 0o777).toBe(0o700)
25 expect((await stat(join(output, 'stage.json'))).mode & 0o777).toBe(0o600)
26})
27
28async function setup(options: Parameters<typeof repository>[1] = {}) {
29 const root = await mkdtemp(join(tmpdir(), 'drone-update-test-')); roots.push(root)
30 const repo = join(root, 'repo'), output = join(root, 'candidate'), fixture = await repository(repo, options)
31 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
32 return { root, repo, output, fixture, policy, input: { ...policy, repository: repo, trustedRoot: fixture.rootPath, output } }
33}
34async function incomplete(output: string) {
35 expect(await Bun.file(join(output, 'stage.json')).exists()).toBe(false)
36}
37
38test('a root rotation needs signatures from both old and new root authorities', async () => {
39 const valid = await setup({ rotate: true })
40 expect((await stageRuntimeFirmware(valid.input)).rootVersion).toBe(2)
41 expect((await inspectStagedRuntimeFirmware({ ...valid.policy, directory: valid.output })).rootVersion).toBe(2)
42 const invalid = await setup({ rotate: true, badRotation: true })
43 await expect(stageRuntimeFirmware(invalid.input)).rejects.toThrow()
44 await incomplete(invalid.output)
45})
46
47test('reject expired metadata and signatures from an unauthorized targets key', async () => {
48 for (const options of [{ expires: '2020-01-01T00:00:00Z' }, { badTargetsSignature: true }]) {
49 const item = await setup(options)
50 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
51 await incomplete(item.output)
52 }
53})
54
55test('signed platform, protocol, profile and supplied release floor must all match', async () => {
56 const rejected: Record<string, string | number | boolean>[] = [{ platform: 'host-neutral-bench-v1' }, { protocolVersion: 2 }, { releaseSequence: 6 }, { releaseSequence: 0 }, { profile: 'flight' }, { sourceRevision: 'not-a-revision' }, { extraAuthority: true }]
57 for (const custom of rejected) {
58 const item = await setup({ custom })
59 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
60 await incomplete(item.output)
61 }
62})
63
64test('image length/hash and snapshot-bound metadata are verified before the completion marker', async () => {
65 const image = await setup()
66 await writeFile(image.fixture.imagePath, Buffer.alloc(image.fixture.image.length, 0))
67 await expect(stageRuntimeFirmware(image.input)).rejects.toThrow()
68 await incomplete(image.output)
69 const metadata = await setup()
70 const path = join(metadata.repo, 'metadata/1.targets.json'), data = JSON.parse(await readFile(path, 'utf8'))
71 data.signed.targets[target].custom.drone.releaseSequence = 99
72 await writeFile(path, JSON.stringify(data))
73 await expect(stageRuntimeFirmware(metadata.input)).rejects.toThrow()
74 await incomplete(metadata.output)
75})
76
77test('root pins, target paths and explicit floors cannot be taken from untrusted input', async () => {
78 const item = await setup()
79 for (const change of [{ trustedRootSha256: '0'.repeat(64) }, { trustedRootSha256: '' }, { minimumReleaseSequence: 0 }, { minimumReleaseSequence: 7.5 }, { minimumReleaseSequence: Number.NaN }, { target: '../root' }, { target: '/runtime/image' }, { target: 'runtime/%2e%2e/image' }, { target: 'runtime\\image' }, { target: 'missing/image.bin' }]) {
80 const output = join(item.root, `candidate-${Math.random().toString(16).slice(2)}`)
81 await expect(stageRuntimeFirmware({ ...item.input, ...change, output })).rejects.toThrow()
82 await incomplete(output)
83 }
84})
85
86test('an existing output and overlapping source repository are never overwritten', async () => {
87 const item = await setup()
88 await mkdir(item.output)
89 await writeFile(join(item.output, 'keep'), 'untouched')
90 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
91 expect(await readFile(join(item.output, 'keep'), 'utf8')).toBe('untouched')
92 await expect(stageRuntimeFirmware({ ...item.input, output: join(item.repo, 'inside') })).rejects.toThrow('separate')
93 await symlink(item.repo, join(item.root, 'source-link'))
94 await expect(stageRuntimeFirmware({ ...item.input, output: join(item.root, 'source-link/inside') })).rejects.toThrow('separate')
95})
96
97test('reject source symlinks, devices and over-budget regular files without marking complete', async () => {
98 const symlinked = await setup()
99 await rm(symlinked.fixture.imagePath)
100 await symlink(symlinked.fixture.rootPath, symlinked.fixture.imagePath)
101 await expect(stageRuntimeFirmware(symlinked.input)).rejects.toThrow()
102 await incomplete(symlinked.output)
103 const oversized = await setup()
104 await truncate(oversized.fixture.imagePath, 16 * 1024 * 1024 + 1)
105 await expect(stageRuntimeFirmware(oversized.input)).rejects.toThrow()
106 await incomplete(oversized.output)
107 const fifo = await setup()
108 await rm(fifo.fixture.imagePath)
109 expect(spawnSync('mkfifo', [fifo.fixture.imagePath]).status).toBe(0)
110 await expect(stageRuntimeFirmware(fifo.input)).rejects.toThrow()
111 await incomplete(fifo.output)
112})
113
114test('inspection rejects partial candidates, modified images/receipts and a newly raised floor', async () => {
115 const partial = await setup()
116 await mkdir(partial.output)
117 await writeFile(join(partial.output, 'trust-root.json'), await readFile(partial.fixture.rootPath))
118 await expect(inspectStagedRuntimeFirmware({ ...partial.policy, directory: partial.output })).rejects.toThrow()
119 const item = await setup()
120 const original = await stageRuntimeFirmware(item.input)
121 await expect(inspectStagedRuntimeFirmware({ ...item.policy, minimumReleaseSequence: 8, directory: item.output })).rejects.toThrow()
122 await expect(inspectStagedRuntimeFirmware({ ...item.policy, target: 'other/image.bin', directory: item.output })).rejects.toThrow()
123 await expect(inspectStagedRuntimeFirmware({ ...item.policy, trustedRootSha256: '0'.repeat(64), directory: item.output })).rejects.toThrow()
124 await writeFile(join(item.output, 'stage.json'), JSON.stringify({ ...original, physicalDeploymentAllowed: true }))
125 await expect(inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).rejects.toThrow('receipt')
126 await writeFile(join(item.output, 'stage.json'), JSON.stringify(original))
127 await writeFile(join(item.output, 'image.bin'), Buffer.alloc(item.fixture.image.length, 0))
128 await expect(inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).rejects.toThrow()
129})
130
131test('concurrent attempts cannot share an output directory', async () => {
132 const item = await setup(), attempts = await Promise.allSettled([stageRuntimeFirmware(item.input), stageRuntimeFirmware(item.input)])
133 expect(attempts.filter(x => x.status === 'fulfilled')).toHaveLength(1)
134 expect(attempts.filter(x => x.status === 'rejected')).toHaveLength(1)
135 expect((await inspectStagedRuntimeFirmware({ ...item.policy, directory: item.output })).sha256).toBe(item.fixture.imageHash)
136})
137
138test('repository directories writable by other principals cannot enter verification', async () => {
139 const item = await setup()
140 await chmod(join(item.repo, 'metadata'), 0o777)
141 await expect(stageRuntimeFirmware(item.input)).rejects.toThrow()
142 await incomplete(item.output)
143})
144
145test('admission owns the trust policy and inspection directory across asynchronous verification', async () => {
146 const stage = await setup(), input = { ...stage.input, minimumReleaseSequence: 8 }
147 const pendingStage = stageRuntimeFirmware(input)
148 input.minimumReleaseSequence = 7
149 await expect(pendingStage).rejects.toThrow('minimum release sequence')
150 await incomplete(stage.output)
151 const item = await setup(), accepted = await stageRuntimeFirmware(item.input)
152 const raised = { ...item.policy, directory: item.output, minimumReleaseSequence: 8 }
153 const pendingInspect = inspectStagedRuntimeFirmware(raised)
154 raised.minimumReleaseSequence = 7
155 await expect(pendingInspect).rejects.toThrow('minimum release sequence')
156 const stable = { ...item.policy, directory: item.output }
157 const pendingStable = inspectStagedRuntimeFirmware(stable)
158 stable.directory = join(item.root, 'does-not-exist')
159 stable.trustedRootSha256 = '0'.repeat(64)
160 stable.target = 'other/image.bin'
161 expect(await pendingStable).toEqual(accepted)
162}, 60000)