ReviewOS

also looking at this

stacks/drone

feat: bind native runtime host boots

#50
Merged chris wants to merge codex/runtime-host-boot-identity into main
22 files +882 -81
.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -42,6 +42,8 @@ jobs:
4242 run: bun tests/fixtures/updates/qualify-store.ts "$RUNNER_TEMP/runtime-store-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4343 - name: Verify protected signed-image preparation with a copied standalone CLI
4444 run: bun tests/fixtures/updates/qualify-preparation.ts "$RUNNER_TEMP/runtime-preparation-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
45 - name: Verify owned candidate identity and retirement with a copied standalone CLI
46 run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4547 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4648 working-directory: ${{ runner.temp }}
4749 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+2-0
Changes to README.md
@@ -268,6 +268,8 @@ compatibility and externally supplied release floors. `updates:enroll` and
268268`updates:trust` preserve the initial root/policy record. The independently pinned
269269store retains verified metadata, clock/release floors and historical recovery;
270270`updates:prepare` authenticates actual image bytes under that protected state.
271`updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate
272identity, recorded-byte status and explicit retirement while retaining files/history.
271273Physical logging/commissioning, key revocation/repair, installed/known-good slots
272274and image-bound boot/session health remain pending.
273275
app/Commands/UpdatesCandidates.tsadded+14-0
Changes to app/Commands/UpdatesCandidates.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { inspectRuntimeFirmwareCandidates } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:candidates <directory>',
8 description: 'Inspect owned candidate identity and recorded-byte integrity without granting selection authority',
9 options: storeReferenceFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectRuntimeFirmwareCandidates(storeCommandReference(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesReserve.tsadded+17-0
Changes to app/Commands/UpdatesReserve.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:reserve <directory>',
8 description: 'Reserve an owned signed-image candidate in a pinned store without selecting or installing firmware',
9 options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image repository', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository and independently trusted system-clock source required')
13 await printAsync(JSON.stringify(await reserveRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), repository: options.repository, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
app/Commands/UpdatesRetire.tsadded+17-0
Changes to app/Commands/UpdatesRetire.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:retire <directory>',
8 description: 'Retire the exact active candidate reservation while retaining its history, bytes and trust floors',
9 options: { ...storeReferenceFlags, '--candidate <id>': 'Exact active candidate identity from pinned store status' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.candidate !== 'string') throw new Error('Explicit candidate identity required')
13 await printAsync(JSON.stringify(await retireRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), candidateId: options.candidate })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
bin/drone.tsmodified+6-0
Changes to bin/drone.ts
@@ -28,6 +28,9 @@ import UpdatesStore from '../app/Commands/UpdatesStore'
2828import UpdatesRefresh from '../app/Commands/UpdatesRefresh'
2929import UpdatesRecover from '../app/Commands/UpdatesRecover'
3030import UpdatesPrepare from '../app/Commands/UpdatesPrepare'
31import UpdatesReserve from '../app/Commands/UpdatesReserve'
32import UpdatesCandidates from '../app/Commands/UpdatesCandidates'
33import UpdatesRetire from '../app/Commands/UpdatesRetire'
3134
3235import MissionsSimulate from '../app/Commands/MissionsSimulate'
3336import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -99,6 +102,9 @@ UpdatesStore(app)
99102UpdatesRefresh(app)
100103UpdatesRecover(app)
101104UpdatesPrepare(app)
105UpdatesReserve(app)
106UpdatesCandidates(app)
107UpdatesRetire(app)
102108MissionsSimulate(app)
103109MissionsValidate(app)
104110MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdmodified+16-3
Changes to docs/adr/0028-runtime-trust-store.md
@@ -53,7 +53,20 @@ same file writer serves standalone staging. Candidate inspection requires a root
5353pin from authenticated store readback when preparation uses the current rotated
5454root. A candidate receipt supplies no current trust or selection authority.
5555
56This v1 database journals metadata, not durable candidate ownership or slot
57transitions. Installed, candidate and known-good slots and health confirmation
58bound to an exact image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
56The original format1 database journals metadata. Candidate reservation upgrades
57its exact schema to format2 in a single transaction, preserving all earlier
58records, floors and the independently pinned store identity. The writer database
59stays format1. New code reads both formats; older binaries reject format2 before
60rewriting it. Missing/corrupt or unknown schemas are never reinitialized.
61
62Format2 adds bounded owned candidate records and one active-reservation index.
63A record binds the exact attempt/generation and generated signed description,
64is committed before image IO, and changes to prepared only after authenticated
65bytes and full candidate-file readback. The files use exclusive immutable-ID
66directories inside the private store. Explicit retirement preserves the files
67and history. Metadata-only recovery cannot promote a reserved candidate based on
68its receipt. Recorded-byte status is not current-policy authorization.
69
70Installed and known-good slot roles and health confirmation bound to an exact
71image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
5972bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/diagnostics.mdmodified+2-1
Changes to docs/diagnostics.md
@@ -158,7 +158,8 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
158158- [Signed image staging and protected preparation](runtime-updates.md) authenticate
159159 TUF roles, declared compatibility and actual image bytes. Independently pinned
160160 enrollment/store records retain metadata and clock/release floors; historical
161 recovery retains floors only. Complete key revocation/repair, installed and
161 recovery retains floors only. Owned candidate reservation/status/retirement retain
162 exact identities and files without granting boot authority. Complete key revocation/repair, installed and
162163 known-good slot coordination, current-policy rollback and exact-image boot/session
163164 health before flashing. Standalone staging/inspection still need explicit trust
164165 and are separate from protected-current-policy selection. Host preparation does
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, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors and actual image preparation](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, slot coordination and install/rollback remain 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 [signed staging, protected trust floors, actual image preparation and durable candidate ownership](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, installed/known-good slots 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/roadmap/issues.jsonmodified+6-1
Changes to docs/roadmap/issues.json
@@ -270,8 +270,13 @@
270270 "tests/unit/diagnostics.test.ts",
271271 "resources/functions/drone/updates/",
272272 "app/Commands/UpdatesPrepare.ts",
273 "app/Commands/UpdatesReserve.ts",
274 "app/Commands/UpdatesCandidates.ts",
275 "app/Commands/UpdatesRetire.ts",
273276 "docs/runtime-updates.md",
274 "tests/unit/updates-preparation.test.ts"
277 "tests/unit/updates-preparation.test.ts",
278 "tests/unit/updates-candidates.test.ts",
279 "tests/unit/updates-candidates-cli.test.ts"
275280 ],
276281 "remaining": [
277282 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
docs/runtime-protocol.mdmodified+13-3
Changes to docs/runtime-protocol.md
@@ -47,7 +47,8 @@ and board-clock echo limit constrain diagnostic leases rather than comparing unr
4747 timestamp, lease duration and all diagnostic/status-only fields must be zero.
48482. The board immediately clears any existing diagnostic lease and binds the host token. Status
4949 echoes it alongside the random per-boot token, exact version and capability flags. Boot tokens
50 are generated by the Pico SDK random API; the host test HAL uses a documented fixed test token.
50 are generated by the Pico SDK random API on the board. The host test HAL uses a fresh
51 supervisor-owned launch nonce, checked on every host status response.
51523. The Pi verifies the echoed token, nonzero boot token, known capabilities, valid sample, health,
5253 state-size bound and monotonic status sequence. Invalid status loses the session.
53544. Lease/stop commands must match both tokens and have a strictly larger command sequence.
@@ -77,11 +78,20 @@ capability link, not a `FlightAdapter`, and cannot cause non-neutral output.
7778
7879## Host HAL test channel
7980
80`drone_runtime_host --stdio` accepts only `tick <uint64 microseconds>` and `frame <128 lowercase hex
81Start the host with `drone_runtime_host --stdio --boot-id <16 lowercase hex characters>`.
82The supervisor allocates a fresh nonzero 64-bit cryptographic random nonce for each launch;
83missing, zero, malformed or additional arguments fail before processing input. This startup
84argument binds the host process to its supervisor and does not attest its executable or hardware.
85Older supervisors and host binaries must be rebuilt together. The 64-byte wire format stays v1.
86
87The host accepts only `tick <uint64 microseconds>` and `frame <128 lowercase hex
8188characters>`, each newline-terminated and bounded by a 160-byte input buffer. Each returns one
8289128-character status-frame hex line. `tick` advances virtual time; it does not sleep in real time.
8390This channel is not a firmware transport or public network service. The Stacks harness serializes
84requests, bounds response lines, times out unresponsive processes and closes them after each run.
91requests, bounds response lines and times out unresponsive processes. Every returned status must
92match the supervisor's launch nonce. Invalid responses kill the process and permanently fail
93that transport. Returned status frames are immutable. Closing waits for actual process exit and
94returns its exit code and signal; repeated closes retain the same result.
8595
8696The optional [DS sensor stream](sensors.md) shares UART TX as complete 128-byte frames.
8797DR remains 64 bytes. Awaited DR replies take priority when the current frame finishes; partial
docs/runtime-updates.mdmodified+98-7
Changes to docs/runtime-updates.md
@@ -177,7 +177,7 @@ actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a
177177competing process, restart and retain the timestamp before a fresh refresh. These
178178cuts do not cover every write/sync boundary or actual physical power loss.
179179
180The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
180The metadata format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
181181state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per
182182file. Exhaustion fails closed; history is not automatically deleted or compacted.
183183Capacity migration, the full storage-fault campaign, authenticated repair/key
@@ -224,8 +224,9 @@ still has `trustStatePersisted: false`: that portable receipt is not the protect
224224store or current authorization. A successful preparation authenticates these
225225candidate bytes at this operation's trusted time. It does not install, select,
226226boot or confirm them, and does not promise future trust/expiry policy will allow
227selection. Durable candidate ownership, installed/known-good slots and current
228policy revalidation before selection remain coordinator work in #42.
227selection. The owned-candidate operation below adds durable reservation records;
228installed/known-good slots and current-policy revalidation before selection remain
229coordinator work in #42.
229230
230231Image or candidate-output failure during an attempt leaves accepted metadata and
231232release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never
@@ -244,6 +245,96 @@ summary; a fresh preparation uses another new directory. These measured process
244245cuts do not establish every write/sync boundary, electrical power-loss behavior
245246or hardware anti-rollback.
246247
248## Owned candidate reservations and retirement
249
250Use `updates:reserve` when the pinned store should own the candidate, rather than
251writing a standalone output directory. Repository and enrollment directories must
252remain separate from the store and cannot contain it or be contained by it.
253
254```sh
255./buddy updates:reserve /path/to/store --enrollment /path/to/trust \
256 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
257 --repository /path/to/offline-repository \
258 --trusted-system-clock-source operator-managed-utc-clock
259
260./buddy updates:candidates /path/to/store --enrollment /path/to/trust \
261 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256"
262
263./buddy updates:retire /path/to/store --enrollment /path/to/trust \
264 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
265 --candidate "$EXACT_ACTIVE_CANDIDATE_ID"
266```
267
268The library functions are `reserveRuntimeFirmwareCandidate`,
269`inspectRuntimeFirmwareCandidates` and `retireRuntimeFirmwareCandidate`. The
270native Buddy commands and copied standalone commands share them. A writer lease
271covers metadata verification, reservation, image output and completion. Only one
272candidate can remain active; another reservation fails before starting a metadata
273attempt. A fresh trusted clock and the existing protected SDK operation verify
274the signed selection, then commit its release floor before image acquisition.
275
276Reservation upgrades an exact format1 `state.sqlite` to format2 in one SQLite
277transaction, adding `candidates` and a partial unique index for one active
278reservation. It retains every metadata/attempt record, clock/release floor and
279store identity. `writer.sqlite` stays format1, and the independently retained
280`store.json` hash is unchanged. New code still reads format1 stores without
281migrating them during inspection. Older binaries fail closed on format2; a
282retained older executable was tested before and after upgrade, including unchanged
283state and writer database hashes after its rejection. Do not downgrade or reset a
284store to make an older binary operate on it. Unknown schemas remain rejected
285before configuration that could rewrite their format.
286
287Each candidate record binds a random ID, exact metadata attempt/generation and
288the generated signed-stage description/hash. The record is committed as
289`reserved` before image IO. Files are exclusively created under
290`store/candidates/<id>` with the same actual-image verification and archive writer
291as protected preparation. Only complete file, image and archive readback permits
292the `prepared` record commit. A receipt cannot introduce an owned record.
293
294`updates:candidates` returns `drone-runtime-candidates-v1`, `stateFormat`, protected
295`store` status, the active `candidate`, and bounded declaration `history`. History
296includes retired identities and paths; retired file integrity is not inspected.
297There are at most 128 retained candidate records. Exhaustion fails closed rather
298than deleting history or images. Active candidate `integrity` is:
299
300| Value | Meaning |
301| --- | --- |
302| `incomplete` | The record is still reserved, even if a stage marker exists |
303| `recorded-bytes-match` | Prepared image, receipt, root and archive match the owned record and its historical metadata bytes |
304| `mismatch` | Available file content or layout differs from the owned candidate |
305| `unavailable` | Files cannot be completely read or their protected file/directory requirements fail |
306
307This status is a local byte check. It does not establish fresh TUF expiration,
308revocation or current-policy authorization. `currentTrustValidated` and
309`physicalDeploymentAllowed` remain false. No installed, booted or healthy result
310is invented from a prepared record or matching files.
311
312After an interrupted or failed operation, preserve the whole store and its
313candidate files. Resolve the pending/maintenance metadata attempt with
314`updates:recover` first. Recovery never promotes `reserved` to `prepared`, even
315when a completed stage marker exists. A process can also stop after the prepared
316record commit but before attempt completion; the record survives, while metadata
317recovery still supplies no current selection authority. Missing/corrupt files
318remain actionable integrity failures. Explicit retirement requires the exact
319active ID and a resolved metadata attempt. It changes the record to `retired`,
320retains all files/history/floors, and allows a new ID with a new directory. It does
321not erase or repair the earlier artifact.
322
323Host qualification kills actual processes after reservation, image write, stage
324marker completion, prepared-record commit and retirement commit. Each cut observes
325an actual competing process rejected with SQLITE_BUSY, then restarts, checks
326unchanged candidate phase/floors and prepares a distinct fresh ID after explicit
327recovery/retirement. This does not cover every SQLite/filesystem write or sync,
328ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. Installed
329and known-good slot roles, identity-bound runtime health, current-policy selection
330and rollback, authenticated key maintenance and the full fault campaign remain
331required by #42.
332
333The [host bench](runtime.md) now binds each process to a fresh supervisor-owned boot nonce
334and requires a clean exit. This prerequisite provides no installed image identity, bounded
335health transcript or known-good confirmation. Slot health must still bind the verified image,
336owned boot/session challenge and actual runtime evidence before changing known-good state.
337
247338## Repository and signed image contract
248339
249340Use a standard TUF repository with `metadata/` and `targets/` directories,
@@ -364,10 +455,10 @@ time. Neither path is a complete anti-rollback coordinator. Trusted clock
364455acquisition and protection against privileged clock/storage rollback remain
365456open. Control-loop freshness still uses its separate monotonic clock.
366457
367Initial enrollment, the bounded persistent metadata protocol and protected
368signed-image preparation are available above. Complete authenticated
369maintenance/key revocation and the full storage-fault campaign, then durable
370candidate ownership, installed/known-good slot integrity, explicit health
458Initial enrollment, the bounded persistent metadata protocol, protected
459signed-image preparation and durable candidate ownership are available above.
460Complete authenticated maintenance/key revocation and the full storage-fault
461campaign, then installed/known-good slot integrity, explicit health
371462confirmation and rollback without lowering security floors. Then add
372463the board bootloader/install protocol and measured power-loss, full-medium,
373464storage-latency and recovery campaigns. Local privileged filesystem tampering
docs/runtime.mdmodified+6-0
Changes to docs/runtime.md
@@ -41,6 +41,12 @@ boundaries, clock rollback, identity/version rejection, reset state and ten thou
4141The same fixed-buffer UART state machine used by Pico is also exercised through a host I/O harness
4242for fragmentation, resynchronization, corruption, RX/TX budgets and pending transmit buffers.
4343
44Each host launch receives a fresh supervisor-owned boot nonce. The report includes its decimal
45`bootId`, and the harness checks that identity on every immutable response. The bench reports
46success only after its actual process exits cleanly. This is host session separation, without
47image attestation or update health confirmation. It does not measure board boot entropy or
48physical timing. See the [host startup contract](runtime-protocol.md#host-hal-test-channel).
49
4450Build a standalone CLI for the current platform:
4551
4652```sh
firmware/runtime/src/host.cppmodified+10-2
Changes to firmware/runtime/src/host.cpp
@@ -10,8 +10,16 @@ static int nibble(char c) {
1010 return -1;
1111}
1212int main(int argc, char** argv) {
13 if (argc != 2 || std::strcmp(argv[1], "--stdio") != 0) return 2;
14 drone::Runtime runtime(0x1122334455667788ull);
13 if (argc != 4 || std::strcmp(argv[1], "--stdio") != 0
14 || std::strcmp(argv[2], "--boot-id") != 0 || std::strlen(argv[3]) != 16) return 2;
15 uint64_t boot = 0;
16 for (std::size_t i = 0; i < 16; ++i) {
17 const int value = nibble(argv[3][i]);
18 if (value < 0) return 2;
19 boot = (boot << 4) | uint64_t(value);
20 }
21 if (boot == 0) return 2;
22 drone::Runtime runtime(boot);
1523 uint64_t now = 0;
1624 char line[160];
1725 while (std::fgets(line, sizeof(line), stdin)) {
resources/functions/drone/runtime/bench.tsmodified+13-2
Changes to resources/functions/drone/runtime/bench.ts
@@ -6,7 +6,7 @@ import { decodeRuntimeFrame, encodeRuntimeFrame, runtimeCrc32, runtimeFlags } fr
66
77export const runtimeBenchFaults = ['none', 'deadline', 'stale', 'expiry', 'replay', 'version'] as const
88export type RuntimeBenchFault = typeof runtimeBenchFaults[number]
9export async function runRuntimeBench(options: { project?: string, ticks?: number, fault?: RuntimeBenchFault, onBuild?: (artifact: string) => Promise<void>, onRequest?: (line: string, atMs: number) => void | Promise<void>, onStatus?: (status: RuntimeFrame, atMs: number) => void | Promise<void> } = {}) {
9export async function runRuntimeBench(options: { project?: string, ticks?: number, fault?: RuntimeBenchFault, onBuild?: (artifact: string) => Promise<void>, onRequest?: (line: string, atMs: number) => void | Promise<void>, onStatus?: (status: Readonly<RuntimeFrame>, atMs: number) => void | Promise<void> } = {}) {
1010 const ticks = options.ticks ?? 1000
1111 const fault = options.fault ?? 'none'
1212 if (!Number.isSafeInteger(ticks) || ticks < 1 || ticks > 100000 || !runtimeBenchFaults.includes(fault)) throw new RangeError('Use 1..100000 ticks and a supported bench fault')
@@ -16,6 +16,7 @@ export async function runRuntimeBench(options: { project?: string, ticks?: numbe
1616 const session = createRuntimeSession()
1717 const started = performance.now()
1818 let time = 1000
19 let operationFailed = false, operationError: unknown
1920 async function exchange(line: string) {
2021 await options.onRequest?.(line, time / 1000)
2122 const status = await harness.exchange(line)
@@ -62,11 +63,21 @@ export async function runRuntimeBench(options: { project?: string, ticks?: numbe
6263 || (fault === 'deadline' && !(status.flags & runtimeFlags.deadlineFault))) throw new Error('Native bench failure did not return the required neutral-only, unleased state')
6364 return {
6465 evidence: 'host-harness', boardCandidate: 'pico2', protocolVersion: 1, fault,
66 bootId: String(harness.bootId),
6567 ticks: status.ticks, virtualElapsedUs: time, hostElapsedMs: Math.round((performance.now() - started) * 1000) / 1000,
6668 stateBytes: status.stateBytes, maxExecutionUs: status.maxExecutionUs, deadlineMisses: status.deadlineMisses,
6769 neutralOnly: true, leaseActive: false, physicalFlightEnabled: false,
6870 note: 'Host virtual-clock evidence. GPIO timing, stack high-water and hardware watchdog require a Pico 2 bench measurement.',
6971 }
7072 }
71 finally { await harness.close() }
73 catch (error) { operationFailed = true; operationError = error; throw error }
74 finally {
75 let exit
76 try { exit = await harness.close() }
77 catch (error) {
78 if (operationFailed) throw new AggregateError([operationError, error], 'Native bench operation and process cleanup failed')
79 throw error
80 }
81 if (!operationFailed && (exit.exitCode !== 0 || exit.signal !== null)) throw new Error('Native bench process did not exit cleanly')
82 }
7283}
resources/functions/drone/runtime/harness.tsmodified+38-9
Changes to resources/functions/drone/runtime/harness.ts
@@ -1,14 +1,25 @@
11import { decodeRuntimeFrame } from './protocol'
2import { randomBytes } from 'node:crypto'
3
4function freshBootId() {
5 let value = 0n
6 while (value === 0n) value = randomBytes(8).readBigUInt64BE()
7 return value
8}
9export interface RuntimeHarnessExit { exitCode: number, signal: string | number | null }
210
311/** ASCII stepping exists only in the host HAL. Board UART carries raw 64-byte frames. */
412export function openRuntimeHarness(artifact: string) {
5 const child = Bun.spawn([artifact, '--stdio'], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
13 const bootId = freshBootId()
14 const child = Bun.spawn([artifact, '--stdio', '--boot-id', bootId.toString(16).padStart(16, '0')], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
615 const reader = child.stdout.getReader()
716 let pending = ''
817 let active = false
918 let closed = false
19 let failed = false
20 let closing: Promise<Readonly<RuntimeHarnessExit>> | undefined
1021 async function exchange(line: string) {
11 if (closed || active || line.length > 150 || line.includes('\n')) throw new Error('Invalid or concurrent harness request')
22 if (closed || failed || active || line.length > 150 || line.includes('\n')) throw new Error('Invalid, failed or concurrent harness request')
1223 active = true
1324 const timeout = setTimeout(() => child.kill('SIGKILL'), 2000)
1425 try {
@@ -24,19 +35,37 @@ export function openRuntimeHarness(artifact: string) {
2435 const hex = pending.slice(0, end)
2536 pending = pending.slice(end + 1)
2637 if (!/^[a-f\d]{128}$/.test(hex)) throw new Error('Invalid native harness output')
27 return decodeRuntimeFrame(Uint8Array.fromHex(hex))
38 const frame = decodeRuntimeFrame(Uint8Array.fromHex(hex))
39 if (frame.kind !== 'status' || frame.bootId !== bootId) throw new Error('Native harness boot identity rejected')
40 return Object.freeze(frame)
2841 }
42 catch (error) { failed = true; child.kill('SIGKILL'); throw error }
2943 finally { active = false; clearTimeout(timeout) }
3044 }
3145 return {
46 get bootId() { return bootId },
3247 exchange,
33 async close() {
34 if (closed) return
48 async close(): Promise<Readonly<RuntimeHarnessExit>> {
49 if (closing) return closing
3550 closed = true
36 child.stdin.end()
37 const timeout = setTimeout(() => child.kill('SIGKILL'), 1000)
38 try { await child.exited }
39 finally { clearTimeout(timeout); await reader.cancel().catch(() => {}); reader.releaseLock() }
51 if (active) child.kill('SIGKILL')
52 closing = (async () => {
53 let inputFailed = false, inputError: unknown
54 try { child.stdin.end() }
55 catch (error) { inputFailed = true; inputError = error; child.kill('SIGKILL') }
56 const timeout = setTimeout(() => child.kill('SIGKILL'), 1000)
57 try {
58 const exitCode = await child.exited
59 if (inputFailed) throw inputError
60 return Object.freeze({ exitCode, signal: child.signalCode ?? null })
61 }
62 finally {
63 clearTimeout(timeout)
64 try { await reader.cancel() }
65 finally { reader.releaseLock() }
66 }
67 })()
68 return closing
4069 },
4170 }
4271}
resources/functions/drone/updates/store.tsmodified+200-29
Changes to resources/functions/drone/updates/store.ts
@@ -25,6 +25,10 @@ const schema = [
2525 `CREATE TABLE inputs (attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), path TEXT NOT NULL CHECK(length(path) BETWEEN 1 AND 256), status INTEGER NOT NULL CHECK(status IN (200, 404)), bytes BLOB, sha256 TEXT, PRIMARY KEY(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id), CHECK((status = 404 AND bytes IS NULL AND sha256 IS NULL) OR (status = 200 AND bytes IS NOT NULL AND sha256 IS NOT NULL AND length(bytes) BETWEEN 1 AND ${metadataLimit} AND length(sha256) = 64))) STRICT`,
2626 `CREATE TABLE metadata (generation INTEGER PRIMARY KEY CHECK(generation BETWEEN 0 AND ${maximumGeneration}), attempt_id TEXT, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), role TEXT NOT NULL CHECK(length(role) BETWEEN 1 AND 180), version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 9007199254740991), epoch INTEGER NOT NULL CHECK(epoch BETWEEN 0 AND generation), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND ${metadataLimit}), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), UNIQUE(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id)) STRICT`,
2727]
28const candidateSchema = [
29 'CREATE TABLE candidates (id TEXT PRIMARY KEY CHECK(length(id) = 32), attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN (\'reserved\', \'prepared\', \'retired\')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT',
30 "CREATE UNIQUE INDEX candidate_active ON candidates ((1)) WHERE phase IN ('reserved', 'prepared')",
31]
2832
2933interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
3034interface StoreReference extends TrustReference { directory: string, storeSha256: string }
@@ -63,7 +67,7 @@ function configure(db: Database) {
6367 if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable')
6468 }
6569}
66function openDatabase(path: string, expectedSchema: string[]) {
70function openDatabase(path: string, expectedSchema: string[], allowCandidates = false) {
6771 const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes)
6872 const db = new Database(path, { create: false, strict: true })
6973 try {
@@ -72,19 +76,20 @@ function openDatabase(path: string, expectedSchema: string[]) {
7276 db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;')
7377 // Authenticate the protocol format before any configuration that could
7478 // rewrite a foreign database's journal/header format.
75 checkSchema(db, expectedSchema)
79 const version = databaseVersion(db)
80 checkSchema(db, allowCandidates && version === 2 ? [...expectedSchema, ...candidateSchema] : expectedSchema, allowCandidates && version === 2 ? 2 : 1)
7681 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
7782 configure(db)
7883 return db
7984 }
8085 catch (error) { db.close(); throw error }
8186}
82function checkSchema(db: Database, expected: string[]) {
87function databaseVersion(db: Database) { return db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version }
88function checkSchema(db: Database, expected: string[], version = 1) {
8389 const definitions = db.query<{ sql: string }, []>("SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").all().map(row => row.sql).sort()
8490 if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema')
8591 const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get()
86 const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()
87 if (values?.application_id !== applicationId || version?.user_version !== 1 || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
92 if (values?.application_id !== applicationId || databaseVersion(db) !== version || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
8893}
8994function createDatabase(path: string, statements: string[]) {
9095 writeExclusive(path, Buffer.alloc(0))
@@ -148,17 +153,22 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b
148153 let writer: Database | undefined, db: Database | undefined
149154 try {
150155 for (const name of readdirSync(path)) {
151 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry')
156 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates'].includes(name)) throw new Error('Unexpected update-store entry')
152157 if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true)
153158 }
154159 writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema])
155160 writer.exec('BEGIN IMMEDIATE')
156161 const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all()
157162 if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity')
158 db = openDatabase(join(path, 'state.sqlite'), schema)
163 db = openDatabase(join(path, 'state.sqlite'), schema, true)
164 if (readdirSync(path).includes('candidates')) {
165 if (databaseVersion(db) !== 2) throw new Error('Candidate files require an owned format2 store')
166 protectedDirectory(join(path, 'candidates'))
167 }
159168 const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
160169 if (initial?.sha256 !== trusted.policy.trustedRootSha256 || initial.version !== trusted.rootVersion || state(db, binding).release_floor < trusted.policy.minimumReleaseSequence) throw new Error('Update-store anchor or floor mismatch')
161170 summary(db, binding, options.storeSha256)
171 for (const row of candidateRows(db)) ownedDescription(db, row, trusted)
162172 return await operation(db, binding, trusted, path)
163173 }
164174 finally {
@@ -207,7 +217,7 @@ export interface RuntimeUpdateClock {
207217}
208218
209219export interface RuntimeUpdateProgress {
210 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
220 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired'
211221 ordinal: number
212222}
213223type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
@@ -270,11 +280,14 @@ interface VerifiedSelection {
270280 source: string
271281 info: TargetFile
272282 runtime: ReturnType<typeof admitSignedRuntime>
283 attempt: string
284 path: string
273285}
274286
275async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
287async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
276288 options = { ...options }
277 return withStore(options, async (db, binding, trusted) => {
289 return withStore(options, async (db, binding, trusted, path) => {
290 before?.(db)
278291 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
279292 const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms)
280293 if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached')
@@ -323,7 +336,7 @@ async function refreshAndComplete<T>(options: RefreshOptions, complete: (selecti
323336 db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence)
324337 db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt)
325338 })()
326 const value = await complete({ db, binding, trusted, source, info, runtime })
339 const value = await complete({ db, binding, trusted, source, info, runtime, attempt, path })
327340 db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt)
328341 return { store: summary(db, binding, options.storeSha256), value }
329342 }
@@ -353,28 +366,186 @@ export interface PreparedRuntimeFirmware {
353366export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> {
354367 options = { ...options }
355368 const output = runtimeCandidateOutput(options.output, [options.repository, options.directory, options.enrollmentDirectory])
356 const result = await refreshAndComplete(options, async ({ db, binding, trusted, source, info, runtime }) => {
357 const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')!
369 const result = await refreshAndComplete(options, selection => writePreparedCandidate(selection, output, options.onProgress))
370 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
371}
372
373function candidateDescription({ db, binding, trusted, info, runtime }: VerifiedSelection): RuntimeFirmwareStage {
374 const root = activeMetadata(db, state(db, binding).generation).find(row => row.role === 'root')!
375 return { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false }
376}
377
378async function writePreparedCandidate(selection: VerifiedSelection, output: string, onProgress?: ProgressObserver) {
379 const { db, binding, source, info } = selection
380 const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')!
381 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
382 const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path
383 const image = repositoryFile(source, `targets/${path}`, imageLimit)
384 await info.verify(Readable.from(image))
385 const files = new Map<string, Buffer>()
386 for (const row of rows) {
387 if (row.role === 'root') continue
388 files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
389 }
390 files.set(`targets/${path}`, image)
391 const candidate = candidateDescription(selection)
392 mkdirSync(output, { mode: 0o700 })
393 writeExclusive(join(output, 'trust-root.json'), rootBytes)
394 writeExclusive(join(output, 'image.bin'), image)
395 await onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 }))
396 await finishRuntimeCandidate(output, info, candidate, files)
397 await onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 }))
398 return Object.freeze(candidate)
399}
400
401interface CandidateRow { id: string, attempt_id: string, generation: number, phase: 'reserved' | 'prepared' | 'retired', description: Uint8Array, sha256: string }
402export interface RuntimeFirmwareCandidate {
403 id: string
404 attemptId: string
405 generation: number
406 phase: CandidateRow['phase']
407 directory: string
408 description: Readonly<RuntimeFirmwareStage>
409 integrity: 'incomplete' | 'recorded-bytes-match' | 'mismatch' | 'unavailable'
410 currentTrustValidated: false
411}
412export interface RuntimeFirmwareCandidates {
413 schema: 'drone-runtime-candidates-v1'
414 stateFormat: 1 | 2
415 store: Readonly<RuntimeTrustStoreSummary>
416 retainedCandidates: number
417 /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */
418 history: ReadonlyArray<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>>
419 candidate: Readonly<RuntimeFirmwareCandidate> | null
420 physicalDeploymentAllowed: false
421}
422
423function candidateRows(db: Database) {
424 if (databaseVersion(db) === 1) return []
425 const rows = db.query<CandidateRow, []>('SELECT * FROM candidates ORDER BY rowid LIMIT 129').all()
426 if (rows.length > 128) throw new Error('Candidate history budget exceeded')
427 return rows
428}
429function ownedDescription(db: Database, row: CandidateRow, trusted: Readonly<RuntimeTrustSummary>): Readonly<RuntimeFirmwareStage> {
430 const bytes = Buffer.from(row.description)
431 if (!/^[a-f0-9]{32}$/.test(row.id) || sha256(bytes) !== row.sha256) throw new Error('Invalid owned candidate record')
432 const value = object(JSON.parse(bytes.toString('utf8')))
433 const keys = ['schema', 'target', 'platform', 'trustedRootSha256', 'rootVersion', 'releaseSequence', 'protocolVersion', 'sourceRevision', 'signerDeclaredProfile', 'length', 'sha256', 'trustStatePersisted', 'physicalDeploymentAllowed']
434 const root = activeMetadata(db, row.generation).find(item => item.role === 'root')!
435 checkedMetadataBytes(root)
436 const attempt = db.query<Attempt, [string]>('SELECT * FROM attempts WHERE id = ?').get(row.attempt_id)
437 if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key)) || value.schema !== 'drone-runtime-stage-v1' || value.target !== trusted.policy.target || value.platform !== trusted.policy.platform || value.trustedRootSha256 !== root.sha256 || value.rootVersion !== root.version || !Number.isSafeInteger(value.releaseSequence) || (value.releaseSequence as number) < trusted.policy.minimumReleaseSequence || value.protocolVersion !== 1 || typeof value.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(value.sourceRevision) || value.signerDeclaredProfile !== 'neutral-bench' || !Number.isSafeInteger(value.length) || (value.length as number) < 1 || (value.length as number) > imageLimit || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || value.trustStatePersisted !== false || value.physicalDeploymentAllowed !== false || !attempt || attempt.accepted_release !== value.releaseSequence || row.generation < attempt.base_generation) throw new Error('Owned candidate does not match protected selection')
438 return Object.freeze(value as unknown as RuntimeFirmwareStage)
439}
440function candidateIntegrity(db: Database, generation: number, path: string, description: Readonly<RuntimeFirmwareStage>): RuntimeFirmwareCandidate['integrity'] {
441 try {
442 protectedDirectory(path)
443 const rows = activeMetadata(db, generation), root = rows.find(row => row.role === 'root')!
358444 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
359 const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path
360 const image = repositoryFile(source, `targets/${path}`, imageLimit)
361 await info.verify(Readable.from(image))
362 const files = new Map<string, Buffer>()
445 const imagePath = consistent ? description.target.replace(/([^/]+)$/, `${description.sha256}.$1`) : description.target
446 const expected = new Map<string, Buffer>([['trust-root.json', rootBytes], ['stage.json', Buffer.from(`${JSON.stringify(description, null, 2)}\n`)]])
363447 for (const row of rows) {
364 if (row.role === 'root') continue
365 files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
448 if (row.role !== 'root') expected.set(`repository/metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
366449 }
367 files.set(`targets/${path}`, image)
368 const candidate: RuntimeFirmwareStage = { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false }
369 mkdirSync(output, { mode: 0o700 })
370 writeExclusive(join(output, 'trust-root.json'), rootBytes)
371 writeExclusive(join(output, 'image.bin'), image)
372 await options.onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 }))
373 await finishRuntimeCandidate(output, info, candidate, files)
374 await options.onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 }))
375 return Object.freeze(candidate)
450 for (const file of ['image.bin', `repository/targets/${imagePath}`]) {
451 const address = join(path, file)
452 protectedFile(address, imageLimit)
453 const bytes = regularFile(address, imageLimit)
454 if (bytes.length !== description.length || sha256(bytes) !== description.sha256) return 'mismatch'
455 expected.set(file, bytes)
456 }
457 const directories = new Set<string>()
458 for (const name of expected.keys()) {
459 for (let parent = dirname(name); parent !== '.'; parent = dirname(parent)) directories.add(parent)
460 }
461 const visit = (relative: string): boolean => {
462 const base = relative ? join(path, relative) : path
463 protectedDirectory(base)
464 const names = readdirSync(base)
465 if (names.length > 64) return false
466 return names.every(name => {
467 const child = relative ? `${relative}/${name}` : name
468 if (directories.has(child)) return visit(child)
469 const bytes = expected.get(child)
470 if (!bytes) return false
471 protectedFile(join(path, child), child.endsWith('image.bin') || child.startsWith('repository/targets/') ? imageLimit : metadataLimit)
472 return regularFile(join(path, child), Math.max(bytes.length, 1)).equals(bytes)
473 })
474 }
475 if (!visit('')) return 'mismatch'
476 for (const [name, bytes] of expected) {
477 protectedFile(join(path, name), Math.max(bytes.length, 1))
478 if (!regularFile(join(path, name), Math.max(bytes.length, 1)).equals(bytes)) return 'mismatch'
479 }
480 return 'recorded-bytes-match'
481 }
482 catch { return 'unavailable' }
483}
484function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string, storeSha256: string): Readonly<RuntimeFirmwareCandidates> {
485 const rows = candidateRows(db), active = rows.filter(row => row.phase !== 'retired')
486 if (active.length > 1) throw new Error('Multiple active candidate records')
487 let candidate: Readonly<RuntimeFirmwareCandidate> | null = null
488 const history: Array<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>> = []
489 for (const row of rows) {
490 const description = ownedDescription(db, row, trusted)
491 const directory = join(path, 'candidates', row.id)
492 const recorded = Object.freeze({ id: row.id, attemptId: row.attempt_id, generation: row.generation, phase: row.phase, directory, description })
493 history.push(recorded)
494 if (row === active[0]) {
495 candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false })
496 }
497 }
498 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
499}
500
501/** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */
502export async function inspectRuntimeFirmwareCandidates(options: StoreReference): Promise<Readonly<RuntimeFirmwareCandidates>> {
503 return withStore(options, (db, binding, trusted, path) => candidatesSummary(db, binding, trusted, path, options.storeSha256))
504}
505
506/** Release only the exact active candidate reservation. Retain its records and bytes; never reset trust floors. */
507export async function retireRuntimeFirmwareCandidate(options: StoreReference & { candidateId: string, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeFirmwareCandidates>> {
508 options = { ...options }
509 if (typeof options.candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(options.candidateId)) throw new Error('Exact active candidate identity required')
510 return withStore(options, async (db, binding, trusted, path) => {
511 const current = candidatesSummary(db, binding, trusted, path, options.storeSha256)
512 if (current.store.attempt?.phase === 'pending' || current.store.attempt?.phase === 'maintenance') throw new Error('Resolve the metadata attempt before candidate retirement')
513 if (!current.candidate || current.candidate.id !== options.candidateId) throw new Error('Exact active candidate identity required')
514 db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase IN ('reserved', 'prepared')").run(options.candidateId)
515 await options.onProgress?.(Object.freeze({ phase: 'candidate-retired', ordinal: 0 }))
516 return candidatesSummary(db, binding, trusted, path, options.storeSha256)
376517 })
377 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
518}
519
520/** Reserve one owned candidate and prepare signed bytes. No installed slot or health confirmation is changed. */
521export async function reserveRuntimeFirmwareCandidate(options: RefreshOptions): Promise<Readonly<RuntimeFirmwareCandidates>> {
522 options = { ...options }
523 runtimeCandidateOutput(options.directory, [options.repository, options.enrollmentDirectory])
524 const result = await refreshAndComplete(options, async selection => {
525 const { db, binding, trusted, path, attempt } = selection
526 if (databaseVersion(db) === 1) {
527 db.transaction(() => { for (const sql of candidateSchema) db.exec(sql); db.exec('PRAGMA user_version=2') })()
528 checkSchema(db, [...schema, ...candidateSchema], 2)
529 }
530 const id = randomBytes(16).toString('hex'), description = candidateDescription(selection)
531 const bytes = Buffer.from(`${JSON.stringify(description)}\n`), generation = state(db, binding).generation
532 db.query("INSERT INTO candidates VALUES(?, ?, ?, 'reserved', ?, ?)").run(id, attempt, generation, bytes, sha256(bytes))
533 await options.onProgress?.(Object.freeze({ phase: 'candidate-reserved', ordinal: 0 }))
534 const parent = join(path, 'candidates')
535 if (!readdirSync(path).includes('candidates')) { mkdirSync(parent, { mode: 0o700 }); syncDirectory(path) }
536 protectedDirectory(parent)
537 const output = join(parent, id)
538 await writePreparedCandidate(selection, output, options.onProgress)
539 if (candidateIntegrity(db, generation, output, description) !== 'recorded-bytes-match') throw new Error('Owned candidate file readback failed')
540 db.query("UPDATE candidates SET phase = 'prepared' WHERE id = ? AND phase = 'reserved'").run(id)
541 await options.onProgress?.(Object.freeze({ phase: 'candidate-recorded', ordinal: 0 }))
542 return candidatesSummary(db, binding, trusted, path, options.storeSha256)
543 }, db => {
544 const rows = candidateRows(db)
545 if (rows.some(row => row.phase !== 'retired')) throw new Error('An active candidate requires explicit retirement or slot transition')
546 if (rows.length >= 128) throw new Error('Candidate history budget reached')
547 })
548 return Object.freeze({ ...result.value, store: result.store })
378549}
379550
380551function causedBy(error: unknown, marker: Error) {
tests/fixtures/updates/qualify-candidates.tsadded+90-0
Changes to tests/fixtures/updates/qualify-candidates.ts
@@ -0,0 +1,90 @@
1import assert from 'node:assert/strict'
2import { constants } from 'node:fs'
3import { createHash } from 'node:crypto'
4import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
5import { join, resolve } from 'node:path'
6import { platform, repository, target } from './repository'
7
8async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
9
10/** Real copied commands, signed synthetic bytes and optional retained older binary. Never executes a candidate. */
11export async function qualifyPortableCandidates(directory: string, source: string, olderBinary?: string) {
12 await mkdir(directory, { mode: 0o700 })
13 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
14 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
15 assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256)
16 const olderSha256 = olderBinary ? await hash(olderBinary) : null
17 const repo = join(directory, 'repo'), fixture = await repository(repo, { rotate: true, custom: { releaseSequence: 9 } })
18 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store')
19 const results: { command: string, binarySha256: string, exit: number, stdout: string, stderr: string }[] = []
20 async function run(args: string[], expected = 0, executable = binary) {
21 const child = Bun.spawn([executable, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
22 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
23 try {
24 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
25 results.push({ command: args[0]!, binarySha256: executable === binary ? sourceSha256 : olderSha256!, exit, stdout, stderr }); assert.equal(exit, expected)
26 if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') }
27 return stdout ? JSON.parse(stdout) : undefined
28 }
29 finally { clearTimeout(timer) }
30 }
31 try {
32 const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'])
33 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
34 const created = await run(['updates:store-init', ...flags, '--output', store])
35 const pinned = [...flags, '--store-sha256', created.storeSha256]
36 const repositoryFlags = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']
37 const initialHash = await hash(join(store, 'state.sqlite'))
38 const empty = await run(['updates:candidates', store, ...pinned])
39 assert.equal(empty.stateFormat, 1); assert.equal(empty.candidate, null)
40 assert.equal(await hash(join(store, 'state.sqlite')), initialHash)
41 if (olderBinary) assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), created)
42 await run(['updates:reserve', store, ...pinned, '--repository', repo], 1)
43 const prepared = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
44 assert.equal(prepared.stateFormat, 2); assert.equal(prepared.store.releaseFloor, 9)
45 assert.equal(prepared.store.attempt.phase, 'verified'); assert.equal(prepared.store.metadata.find((row: { role: string }) => row.role === 'root').version, 2)
46 assert.equal(prepared.candidate.phase, 'prepared'); assert.equal(prepared.candidate.integrity, 'recorded-bytes-match')
47 assert.equal(prepared.candidate.description.sha256, fixture.imageHash); assert.equal(prepared.candidate.currentTrustValidated, false); assert.equal(prepared.physicalDeploymentAllowed, false)
48 assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared)
49 await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1)
50 await run(['updates:retire', store, ...pinned, '--candidate', '0'.repeat(32)], 1)
51 assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared)
52 if (olderBinary) {
53 const stateHash = await hash(join(store, 'state.sqlite')), writerHash = await hash(join(store, 'writer.sqlite'))
54 await run(['updates:store', store, ...pinned], 1, olderBinary)
55 assert.equal(await hash(join(store, 'state.sqlite')), stateHash); assert.equal(await hash(join(store, 'writer.sqlite')), writerHash)
56 assert.equal(await hash(olderBinary), olderSha256)
57 }
58 await run(['updates:candidates', store, ...flags, '--store-sha256', '0'.repeat(64)], 1)
59 const original = await readFile(join(prepared.candidate.directory, 'stage.json'))
60 await writeFile(join(prepared.candidate.directory, 'image.bin'), Buffer.alloc(fixture.image.length, 0xff))
61 const damaged = await run(['updates:candidates', store, ...pinned])
62 assert.equal(damaged.candidate.integrity, 'mismatch'); assert.equal(damaged.candidate.currentTrustValidated, false)
63 await writeFile(join(prepared.candidate.directory, 'image.bin'), fixture.image)
64 const retired = await run(['updates:retire', store, ...pinned, '--candidate', prepared.candidate.id])
65 assert.equal(retired.candidate, null); assert.equal(retired.retainedCandidates, 1); assert.equal(retired.store.releaseFloor, 9)
66 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original)
67 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
68 await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1)
69 const stopped = await run(['updates:candidates', store, ...pinned])
70 assert.equal(stopped.candidate.phase, 'reserved'); assert.equal(stopped.candidate.integrity, 'incomplete')
71 assert.equal(stopped.store.releaseFloor, 9); assert.equal(stopped.store.attempt.phase, 'maintenance')
72 await run(['updates:recover', store, ...pinned])
73 const recovered = await run(['updates:candidates', store, ...pinned])
74 assert.deepEqual(recovered.candidate, stopped.candidate); assert.equal(recovered.store.releaseFloor, 9)
75 await run(['updates:retire', store, ...pinned, '--candidate', stopped.candidate.id])
76 await writeFile(fixture.imagePath, fixture.image)
77 const fresh = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
78 assert.equal(fresh.retainedCandidates, 3); assert.notEqual(fresh.candidate.id, prepared.candidate.id)
79 assert.equal(fresh.candidate.integrity, 'recorded-bytes-match'); assert.equal(fresh.store.releaseFloor, 9)
80 assert.equal(fresh.candidate.currentTrustValidated, false); assert.equal(fresh.physicalDeploymentAllowed, false)
81 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'image.bin')), fixture.image)
82 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original)
83 }
84 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-owned-candidates-v1', sourceSha256, olderSha256, results, physicalDeploymentAllowed: false }, null, 2)) }
85 return { evidence: 'copied-standalone-owned-candidates-v1', operations: results.length, sourceSha256, olderSha256, physicalDeploymentAllowed: false }
86}
87if (import.meta.main) {
88 if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [OLDER_BINARY]')
89 console.log(JSON.stringify(await qualifyPortableCandidates(resolve(process.argv[2]!), resolve(process.argv[3]!), process.argv[4] ? resolve(process.argv[4]) : undefined)))
90}
tests/fixtures/updates/store-interruptions.tsmodified+46-22
Changes to tests/fixtures/updates/store-interruptions.ts
@@ -3,13 +3,13 @@ import { spawnSync } from 'node:child_process'
33import { mkdir, readFile, writeFile } from 'node:fs/promises'
44import { join, resolve } from 'node:path'
55import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
6import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
6import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
77import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store'
88import { platform, repository, target } from './repository'
99
1010type Reference = Parameters<typeof inspectRuntimeTrustStore>[0]
11type Cut = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string }
11type Cut = RuntimeUpdateProgress['phase']
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string, managed?: boolean, candidateId?: string }
1313const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
1414
1515async function worker(mode: string, file: string) {
@@ -29,16 +29,19 @@ async function worker(mode: string, file: string) {
2929 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
3030 await Bun.stdin.text()
3131 } }
32 if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
32 if (input.candidateId) await retireRuntimeFirmwareCandidate({ ...options, candidateId: input.candidateId })
33 else if (input.managed) await reserveRuntimeFirmwareCandidate(options)
34 else if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
3335 else await refreshRuntimeTrustStore(options)
3436 throw new Error('Interruption worker unexpectedly finished')
3537}
3638
3739/** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */
38async function qualifyInterruptions(directory: string, preparation: boolean) {
40async function qualifyInterruptions(directory: string, operation: 'refresh' | 'prepare' | 'reserve') {
3941 await mkdir(directory, { mode: 0o700 })
4042 const results = []
41 const cuts: Cut[] = preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
43 const managed = operation === 'reserve', preparation = operation !== 'refresh'
44 const cuts: Cut[] = managed ? ['candidate-reserved', 'image-written', 'candidate-completed', 'candidate-recorded', 'candidate-retired'] : preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
4245 for (const cut of cuts) {
4346 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
4447 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
@@ -46,8 +49,10 @@ async function qualifyInterruptions(directory: string, preparation: boolean) {
4649 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
4750 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
4851 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
49 const file = join(root, 'worker.json'), output = preparation ? join(root, 'candidate') : undefined
50 await writeFile(file, JSON.stringify({ reference, repository: source, cut, output } satisfies WorkerInput), { mode: 0o600 })
52 const prior = cut === 'candidate-retired' ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : null
53 const file = join(root, 'worker.json')
54 let output = prior?.candidate?.directory ?? (!managed && preparation ? join(root, 'candidate') : undefined)
55 await writeFile(file, JSON.stringify({ reference, repository: source, cut, output: managed ? undefined : output, managed, candidateId: prior?.candidate?.id } satisfies WorkerInput), { mode: 0o600 })
5156 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
5257 const timer = setTimeout(() => child.kill('SIGKILL'), 15000)
5358 const reader = child.stdout.getReader(), decoder = new TextDecoder()
@@ -68,25 +73,42 @@ async function qualifyInterruptions(directory: string, preparation: boolean) {
6873 const exit = await child.exited, signal = child.signalCode
6974 assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0)
7075 const interrupted = await inspectRuntimeTrustStore(reference)
71 assert.equal(interrupted.attempt?.phase, 'pending')
76 assert.equal(interrupted.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'pending')
7277 assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1)
7378 assert.equal(interrupted.releaseFloor, preparation ? 9 : 7)
79 const candidateBefore = managed ? await inspectRuntimeFirmwareCandidates(reference) : null
80 if (managed && cut !== 'candidate-retired') {
81 assert.equal(candidateBefore!.candidate!.phase, cut === 'candidate-recorded' ? 'prepared' : 'reserved')
82 assert.equal(candidateBefore!.candidate!.currentTrustValidated, false)
83 output = candidateBefore!.candidate!.directory
84 }
85 if (cut === 'candidate-retired') assert.equal(candidateBefore!.candidate, null)
7486 const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false
7587 if (output) {
76 assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
77 assert.equal(candidateStagePresent, cut === 'candidate-completed')
88 if (cut !== 'candidate-reserved') assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
89 assert.equal(candidateStagePresent, ['candidate-completed', 'candidate-recorded', 'candidate-retired'].includes(cut))
7890 }
79 const recovered = await recoverRuntimeTrustStore(reference)
80 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, 'recovered')
91 const recovered = cut === 'candidate-retired' ? await inspectRuntimeTrustStore(reference) : await recoverRuntimeTrustStore(reference)
92 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'recovered')
8193 assert.equal(recovered.releaseFloor, preparation ? 9 : 7)
8294 assert.equal(Object.hasOwn(recovered, 'candidate'), false)
83 const completed = preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
84 const fresh = completed.schema === 'drone-runtime-prepared-image-v1' ? completed.store : completed
95 if (managed) {
96 assert.deepEqual((await inspectRuntimeFirmwareCandidates(reference)).candidate, candidateBefore!.candidate)
97 if (candidateBefore!.candidate) await retireRuntimeFirmwareCandidate({ ...reference, candidateId: candidateBefore!.candidate.id })
98 }
99 const completed = managed ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
100 const fresh = completed.schema === 'drone-runtime-prepared-image-v1' || completed.schema === 'drone-runtime-candidates-v1' ? completed.store : completed
101 if (completed.schema === 'drone-runtime-candidates-v1') {
102 assert.equal(completed.retainedCandidates, 2)
103 assert.equal(completed.candidate!.integrity, 'recorded-bytes-match')
104 assert.notEqual(completed.candidate!.id, candidateBefore!.candidate?.id ?? prior!.candidate!.id)
105 if (candidateStagePresent) assert.deepEqual(await readFile(join(output!, 'image.bin')), fixture.image)
106 }
85107 assert.equal(fresh.generation >= interrupted.generation, true)
86108 if (!preparation) assert.equal(fresh.generation, 3)
87109 assert.equal(fresh.releaseFloor, 9)
88110 assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false)
89 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, interrupted, recovered, fresh })
111 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, candidateBefore, interrupted, recovered, fresh })
90112 }
91113 finally {
92114 clearTimeout(timer)
@@ -95,18 +117,20 @@ async function qualifyInterruptions(directory: string, preparation: boolean) {
95117 reader.releaseLock()
96118 await writeFile(join(root, 'worker.stdout'), stdout)
97119 await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text())
98 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', results, physicalDeploymentAllowed: false }, null, 2))
120 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation, results, physicalDeploymentAllowed: false }, null, 2))
99121 }
100122 }
101 return { evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', cuts: results.length, physicalDeploymentAllowed: false }
123 return { evidence: 'host-process-interruption-only', operation, cuts: results.length, physicalDeploymentAllowed: false }
102124}
103125
104export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) }
105export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) }
126export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, 'refresh') }
127export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, 'prepare') }
128export async function qualifyCandidateInterruptions(directory: string) { return qualifyInterruptions(directory, 'reserve') }
106129
107130if (import.meta.main) {
108 if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
131 if (process.argv.length === 4 && process.argv[2] === 'reserve') console.log(JSON.stringify(await qualifyCandidateInterruptions(resolve(process.argv[3]!))))
132 else if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
109133 else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
110134 else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!))))
111 else throw new Error('Use store-interruptions.ts [prepare] NEW_DIRECTORY')
135 else throw new Error('Use store-interruptions.ts [prepare|reserve] NEW_DIRECTORY')
112136}
tests/unit/runtime.test.tsmodified+54-1
Changes to tests/unit/runtime.test.ts
@@ -1,4 +1,7 @@
11import { beforeAll, describe, expect, test } from 'bun:test'
2import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
25import fixtures from '../fixtures/runtime/frames.json'
36import { commandFrame, decodeRuntimeFrame, encodeRuntimeFrame, runtimeCrc32, type RuntimeFrame } from '../../resources/functions/drone/runtime/protocol'
47import { createRuntimeSession } from '../../resources/functions/drone/runtime/session'
@@ -86,13 +89,60 @@ async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>)
8689}
8790
8891describe('actual native kernel process', () => {
92 test('separate supervised boots have fresh owned identities and a clean idempotent process exit', async () => {
93 const first = openRuntimeHarness(artifact), second = openRuntimeHarness(artifact)
94 try {
95 expect(typeof first.bootId).toBe('bigint')
96 expect(first.bootId).not.toBe(0n)
97 expect(second.bootId).not.toBe(first.bootId)
98 expect((await first.exchange('tick 1000')).bootId).toBe(first.bootId)
99 expect((await second.exchange('tick 1000')).bootId).toBe(second.bootId)
100 const exit = await first.close()
101 expect(exit).toEqual({ exitCode: 0, signal: null })
102 expect(await first.close()).toEqual(exit)
103 }
104 finally { await first.close(); await second.close() }
105 })
106 test('native host startup refuses missing, zero, malformed or extra boot identity arguments', async () => {
107 const invalid = [
108 ['--stdio'], ['--stdio', '--boot-id', '0'.repeat(16)],
109 ['--stdio', '--boot-id', 'f'.repeat(15)], ['--stdio', '--boot-id', 'F'.repeat(16)],
110 ['--stdio', '--boot-id', 'z'.repeat(16)], ['--stdio', '--boot-id', '1'.repeat(16), 'extra'],
111 ]
112 for (const args of invalid) {
113 const child = Bun.spawn([artifact, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
114 const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()])
115 expect(exit).toBe(2)
116 expect(stdout).toBe('')
117 expect(stderr).toBe('')
118 }
119 })
120 test('a valid-CRC response from a different boot fails the transport and its actual process is reaped', async () => {
121 const root = await mkdtemp(join(tmpdir(), 'drone-wrong-host-boot-')), script = join(root, 'wrong-boot.ts'), launcher = join(root, 'launch')
122 const protocol = join(import.meta.dir, '../../resources/functions/drone/runtime/protocol.ts')
123 await writeFile(script, `#!${process.execPath}\nimport { decodeRuntimeFrame, encodeRuntimeFrame } from ${JSON.stringify(protocol)}\nconst frame = decodeRuntimeFrame(Uint8Array.fromHex(${JSON.stringify(fixtures.frames.status)}))\nframe.bootId = BigInt('0x' + Bun.argv.at(-1)) ^ 1n\nawait Bun.stdin.stream().getReader().read()\nconsole.log(Buffer.from(encodeRuntimeFrame(frame)).toString('hex'))\nawait Bun.sleep(10000)\n`, { mode: 0o700 })
124 // A standalone fixture must not load the application's Bun preloads.
125 const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
126 await writeFile(launcher, `#!/bin/sh\ncd ${quote(root)} || exit 1\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`, { mode: 0o700 })
127 const harness = openRuntimeHarness(launcher)
128 try {
129 await expect(harness.exchange('tick 1000')).rejects.toThrow('boot identity')
130 await expect(harness.exchange('tick 2000')).rejects.toThrow('failed')
131 const exit = await harness.close()
132 expect(exit.exitCode).not.toBe(0)
133 expect(exit.signal).not.toBeNull()
134 expect(await harness.close()).toEqual(exit)
135 }
136 finally { await harness.close(); await rm(root, { recursive: true, force: true }) }
137 })
89138 test('reports timestamped samples and cross-language handshake/lease/stop', async () => withNative(async harness => {
90139 const first = await harness.exchange('tick 1000')
91140 expect(first).toMatchObject({ ticks: 1, sample: -999, sampleTimeUs: 1000n, flags: 67 })
92141 const link = createRuntimeSession({ nonce: () => 45n })
93142 const send = (bytes: Uint8Array) => harness.exchange(`frame ${Buffer.from(bytes).toString('hex')}`)
94143 const hello = await send(link.hello(1)); link.accept(encodeRuntimeFrame(hello), 1)
95 expect(hello.bootId).toBe(0x1122334455667788n)
144 expect(hello.bootId).toBe(harness.bootId)
145 expect(Object.isFrozen(hello)).toBe(true)
96146 const lease = await send(link.lease(1)); link.accept(encodeRuntimeFrame(lease), 1)
97147 expect(lease.leaseUs).toBe(100000)
98148 expect((await send(link.stop(1))).leaseUs).toBe(0)
@@ -126,6 +176,7 @@ describe('actual native kernel process', () => {
126176 expect(report).toMatchObject({ evidence: 'host-harness', fault, neutralOnly: true, leaseActive: false, physicalFlightEnabled: false })
127177 expect(report.stateBytes).toBeLessThanOrEqual(128)
128178 expect(report.deadlineMisses).toBe(fault === 'deadline' ? 1 : 0)
179 expect(report.bootId).toMatch(/^[1-9][0-9]{0,19}$/)
129180 }
130181 }, 20000)
131182 test('invalid CLI parameters cannot silently execute a default bench run', async () => {
@@ -139,6 +190,8 @@ describe('actual native kernel process', () => {
139190 await expect(harness.exchange('tick 2000')).rejects.toThrow('concurrent')
140191 expect((await pending).ticks).toBe(1)
141192 await expect(harness.exchange('not-a-host-command')).rejects.toThrow('exited')
193 expect(await harness.close()).toEqual({ exitCode: 2, signal: null })
194 await expect(harness.exchange('tick 3000')).rejects.toThrow('failed')
142195 })
143196 })
144197})
tests/unit/updates-candidates-cli.test.tsadded+43-0
Changes to tests/unit/updates-candidates-cli.test.ts
@@ -0,0 +1,43 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, readFile, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
8import { platform, repository, target } from '../fixtures/updates/repository'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('native candidate commands require pinned trust and clock, retain identity and preserve explicit retirement semantics', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-cli-')); roots.push(root)
15 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
16 const enrollment = join(root, 'enrollment'), directory = join(root, 'store')
17 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollment })
18 const created = await createRuntimeTrustStore({ enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
19 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256]
20 const reserve = await command(['updates:reserve', directory, ...flags, '--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000)
21 expect(reserve.exitCode).toBe(0)
22 const value = JSON.parse(reserve.stdout)
23 expect(value.candidate.phase).toBe('prepared')
24 expect(value.candidate.integrity).toBe('recorded-bytes-match')
25 expect(value.candidate.currentTrustValidated).toBe(false)
26 expect(value.store.releaseFloor).toBe(9)
27 expect(value.physicalDeploymentAllowed).toBe(false)
28 const status = await command(['updates:candidates', directory, ...flags]).withTimeout(20000)
29 expect(status.exitCode).toBe(0)
30 expect(JSON.parse(status.stdout)).toEqual(value)
31 const missingClock = await command(['updates:reserve', directory, ...flags, '--repository', source]).withTimeout(20000)
32 expect(missingClock.exitCode).toBe(1)
33 expect(missingClock.stdout).toBe('')
34 expect(missingClock.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n')
35 const wrong = await command(['updates:retire', directory, ...flags, '--candidate', '0'.repeat(32)]).withTimeout(20000)
36 expect(wrong.exitCode).toBe(1)
37 expect(wrong.stdout).toBe('')
38 const retired = await command(['updates:retire', directory, ...flags, '--candidate', value.candidate.id]).withTimeout(20000)
39 expect(retired.exitCode).toBe(0)
40 expect(JSON.parse(retired.stdout).candidate).toBeNull()
41 expect(JSON.parse(retired.stdout).store.releaseFloor).toBe(9)
42 expect(await readFile(join(value.candidate.directory, 'image.bin'))).toEqual(fixture.image)
43}, 120000)
tests/unit/updates-candidates.test.tsadded+188-0
Changes to tests/unit/updates-candidates.test.ts
@@ -0,0 +1,188 @@
1import { afterEach, expect, test } from 'bun:test'
2import { Database } from 'bun:sqlite'
3import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
8import { platform, repository, target } from '../fixtures/updates/repository'
9import { qualifyCandidateInterruptions } from '../fixtures/updates/store-interruptions'
10import { epochRepository } from '../fixtures/updates/epochs'
11
12const roots: string[] = []
13afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
14const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
15async function setup() {
16 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-')); roots.push(root)
17 const source = join(root, 'repository'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
18 const enrollmentDirectory = join(root, 'enrollment')
19 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
20 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
21 return { root, source, fixture, reference: { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } }
22}
23
24test('the pinned store exclusively owns a prepared candidate and retains its authenticated identity across reopening', async () => {
25 const item = await setup(), before = await inspectRuntimeTrustStore(item.reference)
26 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate).toBeNull()
27 const result = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
28 expect(result.schema).toBe('drone-runtime-candidates-v1')
29 expect(result.stateFormat).toBe(2)
30 expect(result.store.storeId).toBe(before.storeId)
31 expect(result.store.releaseFloor).toBe(9)
32 expect(result.store.generation).toBe(3)
33 expect(result.store.attempt?.phase).toBe('verified')
34 expect(result.retainedCandidates).toBe(1)
35 expect(result.candidate?.phase).toBe('prepared')
36 expect(result.candidate?.integrity).toBe('recorded-bytes-match')
37 expect(result.candidate?.description.sha256).toBe(item.fixture.imageHash)
38 expect(result.candidate?.description.releaseSequence).toBe(9)
39 expect(result.candidate?.currentTrustValidated).toBe(false)
40 expect(result.physicalDeploymentAllowed).toBe(false)
41 expect(await readFile(join(result.candidate!.directory, 'image.bin'))).toEqual(item.fixture.image)
42 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(result)
43 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })).rejects.toThrow('active candidate')
44 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(result)
45})
46
47test('explicit retirement retains old bytes and floors while allowing a distinct new candidate identity', async () => {
48 const item = await setup()
49 const first = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
50 const candidate = first.candidate!, original = await readFile(join(candidate.directory, 'stage.json'))
51 await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: '0'.repeat(32) })).rejects.toThrow('active candidate')
52 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(first)
53 const retired = await retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })
54 expect(retired.candidate).toBeNull()
55 expect(retired.retainedCandidates).toBe(1)
56 expect(retired.history.map(row => ({ id: row.id, phase: row.phase, sha256: row.description.sha256 }))).toEqual([{ id: candidate.id, phase: 'retired', sha256: item.fixture.imageHash }])
57 expect(retired.store.releaseFloor).toBe(9)
58 expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original)
59 expect(await readFile(join(candidate.directory, 'image.bin'))).toEqual(item.fixture.image)
60 const next = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
61 expect(next.candidate?.id).not.toBe(candidate.id)
62 expect(next.candidate?.integrity).toBe('recorded-bytes-match')
63 expect(next.retainedCandidates).toBe(2)
64 expect(next.store.releaseFloor).toBe(9)
65 expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original)
66 await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })).rejects.toThrow('active candidate')
67 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(next)
68})
69
70test('a completed stage marker after an interrupted reservation cannot promote itself during metadata recovery', async () => {
71 const item = await setup(), interruption = new Error('Stop after durable candidate files')
72 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock, onProgress(event) {
73 if (event.phase === 'candidate-completed') throw interruption
74 } })).rejects.toBe(interruption)
75 const stopped = await inspectRuntimeFirmwareCandidates(item.reference), candidate = stopped.candidate!
76 expect(candidate.phase).toBe('reserved')
77 expect(candidate.integrity).toBe('incomplete')
78 expect(JSON.parse(await readFile(join(candidate.directory, 'stage.json'), 'utf8')).sha256).toBe(item.fixture.imageHash)
79 expect(stopped.store.releaseFloor).toBe(9)
80 expect(stopped.store.attempt?.phase).toBe('maintenance')
81 await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })).rejects.toThrow('Resolve the metadata attempt')
82 await recoverRuntimeTrustStore(item.reference)
83 const recovered = await inspectRuntimeFirmwareCandidates(item.reference)
84 expect(recovered.candidate).toEqual(candidate)
85 expect(recovered.store.attempt?.phase).toBe('recovered')
86 expect(recovered.store.releaseFloor).toBe(9)
87 expect(recovered.physicalDeploymentAllowed).toBe(false)
88 const original = await readFile(join(candidate.directory, 'stage.json'))
89 const retired = await retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })
90 expect(retired.candidate).toBeNull()
91 expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original)
92})
93
94test('a rejected actual image retains the reserved identity and accepted floors without creating a prepared artifact', async () => {
95 const item = await setup()
96 await writeFile(item.fixture.imagePath, Buffer.alloc(item.fixture.image.length, 0xff))
97 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })).rejects.toThrow()
98 const stopped = await inspectRuntimeFirmwareCandidates(item.reference)
99 expect(stopped.candidate?.phase).toBe('reserved')
100 expect(stopped.candidate?.integrity).toBe('incomplete')
101 expect(stopped.store.releaseFloor).toBe(9)
102 expect(stopped.store.attempt?.phase).toBe('maintenance')
103 expect(await Bun.file(join(stopped.candidate!.directory, 'stage.json')).exists()).toBe(false)
104 await recoverRuntimeTrustStore(item.reference)
105 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.phase).toBe('reserved')
106})
107
108test('missing or corrupt owned bytes produce actionable integrity status and never current-trust authorization', async () => {
109 const item = await setup(), prepared = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
110 const candidate = prepared.candidate!, image = join(candidate.directory, 'image.bin')
111 await writeFile(image, Buffer.alloc(item.fixture.image.length, 0xff))
112 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.integrity).toBe('mismatch')
113 await rm(image)
114 const missing = await inspectRuntimeFirmwareCandidates(item.reference)
115 expect(missing.candidate?.integrity).toBe('unavailable')
116 expect(missing.candidate?.phase).toBe('prepared')
117 expect(missing.candidate?.currentTrustValidated).toBe(false)
118 expect(missing.store.releaseFloor).toBe(9)
119 expect(missing.physicalDeploymentAllowed).toBe(false)
120 await writeFile(image, item.fixture.image, { mode: 0o600 })
121 await writeFile(join(candidate.directory, 'repository/metadata/1.targets.json'), '{}\n')
122 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.integrity).not.toBe('recorded-bytes-match')
123})
124
125test('actual killed candidate writers preserve reserved, prepared and retired states with concurrent writers excluded', async () => {
126 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-cuts-')); roots.push(root)
127 const result = await qualifyCandidateInterruptions(join(root, 'campaign'))
128 expect(result.cuts).toBe(5)
129 expect(result.physicalDeploymentAllowed).toBe(false)
130}, 90000)
131
132test('managed candidates reject repository overlap before advancing state and preserve a foreign state format unchanged', async () => {
133 const item = await setup(), initial = await inspectRuntimeTrustStore(item.reference)
134 for (const repository of [item.root, item.reference.directory]) {
135 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository, clock })).rejects.toThrow('separate')
136 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(initial)
137 }
138 await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
139 const file = join(item.reference.directory, 'state.sqlite'), db = new Database(file, { create: false, readwrite: true })
140 try { db.exec('PRAGMA user_version=99') }
141 finally { db.close() }
142 const original = await readFile(file)
143 await expect(inspectRuntimeFirmwareCandidates(item.reference)).rejects.toThrow('schema')
144 expect(await readFile(file)).toEqual(original)
145})
146
147test('migration retains a prior rotated-root epoch, metadata history and clock/release floors across later rejections', async () => {
148 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-migration-')); roots.push(root)
149 const source = join(root, 'repository'), fixture = await epochRepository(source)
150 const enrollmentDirectory = join(root, 'enrollment'), directory = join(root, 'store')
151 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
152 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
153 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
154 await fixture.publish(1, 100, 12)
155 await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
156 const nextClock = { source: clock.source, read: () => new Date('2030-01-02T00:00:00Z') }
157 const image = await fixture.publish(2, 1, 12)
158 const before = await refreshRuntimeTrustStore({ ...reference, repository: source, clock: nextClock })
159 expect(before.epoch).toBe(1)
160 expect(before.generation).toBe(7)
161 expect(before.releaseFloor).toBe(12)
162 expect((await inspectRuntimeFirmwareCandidates(reference)).stateFormat).toBe(1)
163 const candidateClock = { source: clock.source, read: () => new Date('2030-01-03T00:00:00Z') }
164 const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: candidateClock })
165 expect(prepared.stateFormat).toBe(2)
166 expect(prepared.store.storeId).toBe(before.storeId)
167 expect(prepared.store.storeSha256).toBe(before.storeSha256)
168 expect(prepared.store.enrollmentSha256).toBe(before.enrollmentSha256)
169 expect(prepared.store.metadata).toEqual(before.metadata)
170 expect(prepared.store.retainedMetadataRecords).toBe(before.retainedMetadataRecords)
171 expect(prepared.store.epoch).toBe(1)
172 expect(prepared.store.releaseFloor).toBe(12)
173 expect(prepared.store.clockFloorMs).toBe(Date.parse('2030-01-03T00:00:00Z'))
174 expect(prepared.candidate?.description.rootVersion).toBe(2)
175 const retired = await retireRuntimeFirmwareCandidate({ ...reference, candidateId: prepared.candidate!.id })
176 await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })).rejects.toThrow('clock')
177 expect(await inspectRuntimeFirmwareCandidates(reference)).toEqual(retired)
178 await fixture.publish(2, 2, 11)
179 await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: candidateClock })).rejects.toThrow('below the trusted minimum')
180 await recoverRuntimeTrustStore(reference)
181 const recovered = await inspectRuntimeFirmwareCandidates(reference)
182 expect(recovered.store.releaseFloor).toBe(12)
183 expect(recovered.store.epoch).toBe(1)
184 expect(recovered.store.attempt?.phase).toBe('recovered')
185 expect(recovered.candidate).toBeNull()
186 expect(recovered.history[0]?.description.releaseSequence).toBe(12)
187 expect(await readFile(join(prepared.candidate!.directory, 'image.bin'))).toEqual(image.image)
188})