also looking at this
feat: bind native runtime host boots
#50
22 files
+882
-81
| @@ -42,6 +42,8 @@ jobs: | ||
| 42 | 42 | run: bun tests/fixtures/updates/qualify-store.ts "$RUNNER_TEMP/runtime-store-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" |
| 43 | 43 | - name: Verify protected signed-image preparation with a copied standalone CLI |
| 44 | 44 | 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" | |
| 45 | 47 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 46 | 48 | working-directory: ${{ runner.temp }} |
| 47 | 49 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -268,6 +268,8 @@ compatibility and externally supplied release floors. `updates:enroll` and | ||
| 268 | 268 | `updates:trust` preserve the initial root/policy record. The independently pinned |
| 269 | 269 | store retains verified metadata, clock/release floors and historical recovery; |
| 270 | 270 | `updates:prepare` authenticates actual image bytes under that protected state. |
| 271 | `updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate | |
| 272 | identity, recorded-byte status and explicit retirement while retaining files/history. | |
| 271 | 273 | Physical logging/commissioning, key revocation/repair, installed/known-good slots |
| 272 | 274 | and image-bound boot/session health remain pending. |
| 273 | 275 | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { inspectRuntimeFirmwareCandidates } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command' | |
| 4 | import { retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -28,6 +28,9 @@ import UpdatesStore from '../app/Commands/UpdatesStore' | ||
| 28 | 28 | import UpdatesRefresh from '../app/Commands/UpdatesRefresh' |
| 29 | 29 | import UpdatesRecover from '../app/Commands/UpdatesRecover' |
| 30 | 30 | import UpdatesPrepare from '../app/Commands/UpdatesPrepare' |
| 31 | import UpdatesReserve from '../app/Commands/UpdatesReserve' | |
| 32 | import UpdatesCandidates from '../app/Commands/UpdatesCandidates' | |
| 33 | import UpdatesRetire from '../app/Commands/UpdatesRetire' | |
| 31 | 34 | |
| 32 | 35 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 33 | 36 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -99,6 +102,9 @@ UpdatesStore(app) | ||
| 99 | 102 | UpdatesRefresh(app) |
| 100 | 103 | UpdatesRecover(app) |
| 101 | 104 | UpdatesPrepare(app) |
| 105 | UpdatesReserve(app) | |
| 106 | UpdatesCandidates(app) | |
| 107 | UpdatesRetire(app) | |
| 102 | 108 | MissionsSimulate(app) |
| 103 | 109 | MissionsValidate(app) |
| 104 | 110 | MissionsReplay(app) |
| @@ -53,7 +53,20 @@ same file writer serves standalone staging. Candidate inspection requires a root | ||
| 53 | 53 | pin from authenticated store readback when preparation uses the current rotated |
| 54 | 54 | root. A candidate receipt supplies no current trust or selection authority. |
| 55 | 55 | |
| 56 | This v1 database journals metadata, not durable candidate ownership or slot | |
| 57 | transitions. Installed, candidate and known-good slots and health confirmation | |
| 58 | bound to an exact image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 56 | The original format1 database journals metadata. Candidate reservation upgrades | |
| 57 | its exact schema to format2 in a single transaction, preserving all earlier | |
| 58 | records, floors and the independently pinned store identity. The writer database | |
| 59 | stays format1. New code reads both formats; older binaries reject format2 before | |
| 60 | rewriting it. Missing/corrupt or unknown schemas are never reinitialized. | |
| 61 | ||
| 62 | Format2 adds bounded owned candidate records and one active-reservation index. | |
| 63 | A record binds the exact attempt/generation and generated signed description, | |
| 64 | is committed before image IO, and changes to prepared only after authenticated | |
| 65 | bytes and full candidate-file readback. The files use exclusive immutable-ID | |
| 66 | directories inside the private store. Explicit retirement preserves the files | |
| 67 | and history. Metadata-only recovery cannot promote a reserved candidate based on | |
| 68 | its receipt. Recorded-byte status is not current-policy authorization. | |
| 69 | ||
| 70 | Installed and known-good slot roles and health confirmation bound to an exact | |
| 71 | image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation, | |
| 59 | 72 | bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9. |
| @@ -158,7 +158,8 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st | ||
| 158 | 158 | - [Signed image staging and protected preparation](runtime-updates.md) authenticate |
| 159 | 159 | TUF roles, declared compatibility and actual image bytes. Independently pinned |
| 160 | 160 | 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 | |
| 162 | 163 | known-good slot coordination, current-policy rollback and exact-image boot/session |
| 163 | 164 | health before flashing. Standalone staging/inspection still need explicit trust |
| 164 | 165 | and are separate from protected-current-policy selection. Host preparation does |
| @@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 13 | 13 | | 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) | |
| 14 | 14 | | 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) | |
| 15 | 15 | | 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) | | |
| 17 | 17 | | 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) | |
| 18 | 18 | | 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) | |
| 19 | 19 | | 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) | |
| @@ -270,8 +270,13 @@ | ||
| 270 | 270 | "tests/unit/diagnostics.test.ts", |
| 271 | 271 | "resources/functions/drone/updates/", |
| 272 | 272 | "app/Commands/UpdatesPrepare.ts", |
| 273 | "app/Commands/UpdatesReserve.ts", | |
| 274 | "app/Commands/UpdatesCandidates.ts", | |
| 275 | "app/Commands/UpdatesRetire.ts", | |
| 273 | 276 | "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" | |
| 275 | 280 | ], |
| 276 | 281 | "remaining": [ |
| 277 | 282 | "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations", |
| @@ -47,7 +47,8 @@ and board-clock echo limit constrain diagnostic leases rather than comparing unr | ||
| 47 | 47 | timestamp, lease duration and all diagnostic/status-only fields must be zero. |
| 48 | 48 | 2. The board immediately clears any existing diagnostic lease and binds the host token. Status |
| 49 | 49 | 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. | |
| 51 | 52 | 3. The Pi verifies the echoed token, nonzero boot token, known capabilities, valid sample, health, |
| 52 | 53 | state-size bound and monotonic status sequence. Invalid status loses the session. |
| 53 | 54 | 4. 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. | ||
| 77 | 78 | |
| 78 | 79 | ## Host HAL test channel |
| 79 | 80 | |
| 80 | `drone_runtime_host --stdio` accepts only `tick <uint64 microseconds>` and `frame <128 lowercase hex | |
| 81 | Start the host with `drone_runtime_host --stdio --boot-id <16 lowercase hex characters>`. | |
| 82 | The supervisor allocates a fresh nonzero 64-bit cryptographic random nonce for each launch; | |
| 83 | missing, zero, malformed or additional arguments fail before processing input. This startup | |
| 84 | argument binds the host process to its supervisor and does not attest its executable or hardware. | |
| 85 | Older supervisors and host binaries must be rebuilt together. The 64-byte wire format stays v1. | |
| 86 | ||
| 87 | The host accepts only `tick <uint64 microseconds>` and `frame <128 lowercase hex | |
| 81 | 88 | characters>`, each newline-terminated and bounded by a 160-byte input buffer. Each returns one |
| 82 | 89 | 128-character status-frame hex line. `tick` advances virtual time; it does not sleep in real time. |
| 83 | 90 | This channel is not a firmware transport or public network service. The Stacks harness serializes |
| 84 | requests, bounds response lines, times out unresponsive processes and closes them after each run. | |
| 91 | requests, bounds response lines and times out unresponsive processes. Every returned status must | |
| 92 | match the supervisor's launch nonce. Invalid responses kill the process and permanently fail | |
| 93 | that transport. Returned status frames are immutable. Closing waits for actual process exit and | |
| 94 | returns its exit code and signal; repeated closes retain the same result. | |
| 85 | 95 | |
| 86 | 96 | The optional [DS sensor stream](sensors.md) shares UART TX as complete 128-byte frames. |
| 87 | 97 | DR remains 64 bytes. Awaited DR replies take priority when the current frame finishes; partial |
| @@ -177,7 +177,7 @@ actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a | ||
| 177 | 177 | competing process, restart and retain the timestamp before a fresh refresh. These |
| 178 | 178 | cuts do not cover every write/sync boundary or actual physical power loss. |
| 179 | 179 | |
| 180 | The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB | |
| 180 | The metadata format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB | |
| 181 | 181 | state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per |
| 182 | 182 | file. Exhaustion fails closed; history is not automatically deleted or compacted. |
| 183 | 183 | Capacity migration, the full storage-fault campaign, authenticated repair/key |
| @@ -224,8 +224,9 @@ still has `trustStatePersisted: false`: that portable receipt is not the protect | ||
| 224 | 224 | store or current authorization. A successful preparation authenticates these |
| 225 | 225 | candidate bytes at this operation's trusted time. It does not install, select, |
| 226 | 226 | boot or confirm them, and does not promise future trust/expiry policy will allow |
| 227 | selection. Durable candidate ownership, installed/known-good slots and current | |
| 228 | policy revalidation before selection remain coordinator work in #42. | |
| 227 | selection. The owned-candidate operation below adds durable reservation records; | |
| 228 | installed/known-good slots and current-policy revalidation before selection remain | |
| 229 | coordinator work in #42. | |
| 229 | 230 | |
| 230 | 231 | Image or candidate-output failure during an attempt leaves accepted metadata and |
| 231 | 232 | release 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 | ||
| 244 | 245 | cuts do not establish every write/sync boundary, electrical power-loss behavior |
| 245 | 246 | or hardware anti-rollback. |
| 246 | 247 | |
| 248 | ## Owned candidate reservations and retirement | |
| 249 | ||
| 250 | Use `updates:reserve` when the pinned store should own the candidate, rather than | |
| 251 | writing a standalone output directory. Repository and enrollment directories must | |
| 252 | remain 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 | ||
| 268 | The library functions are `reserveRuntimeFirmwareCandidate`, | |
| 269 | `inspectRuntimeFirmwareCandidates` and `retireRuntimeFirmwareCandidate`. The | |
| 270 | native Buddy commands and copied standalone commands share them. A writer lease | |
| 271 | covers metadata verification, reservation, image output and completion. Only one | |
| 272 | candidate can remain active; another reservation fails before starting a metadata | |
| 273 | attempt. A fresh trusted clock and the existing protected SDK operation verify | |
| 274 | the signed selection, then commit its release floor before image acquisition. | |
| 275 | ||
| 276 | Reservation upgrades an exact format1 `state.sqlite` to format2 in one SQLite | |
| 277 | transaction, adding `candidates` and a partial unique index for one active | |
| 278 | reservation. It retains every metadata/attempt record, clock/release floor and | |
| 279 | store identity. `writer.sqlite` stays format1, and the independently retained | |
| 280 | `store.json` hash is unchanged. New code still reads format1 stores without | |
| 281 | migrating them during inspection. Older binaries fail closed on format2; a | |
| 282 | retained older executable was tested before and after upgrade, including unchanged | |
| 283 | state and writer database hashes after its rejection. Do not downgrade or reset a | |
| 284 | store to make an older binary operate on it. Unknown schemas remain rejected | |
| 285 | before configuration that could rewrite their format. | |
| 286 | ||
| 287 | Each candidate record binds a random ID, exact metadata attempt/generation and | |
| 288 | the 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 | |
| 291 | as protected preparation. Only complete file, image and archive readback permits | |
| 292 | the `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 | |
| 296 | includes retired identities and paths; retired file integrity is not inspected. | |
| 297 | There are at most 128 retained candidate records. Exhaustion fails closed rather | |
| 298 | than 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 | ||
| 307 | This status is a local byte check. It does not establish fresh TUF expiration, | |
| 308 | revocation or current-policy authorization. `currentTrustValidated` and | |
| 309 | `physicalDeploymentAllowed` remain false. No installed, booted or healthy result | |
| 310 | is invented from a prepared record or matching files. | |
| 311 | ||
| 312 | After an interrupted or failed operation, preserve the whole store and its | |
| 313 | candidate files. Resolve the pending/maintenance metadata attempt with | |
| 314 | `updates:recover` first. Recovery never promotes `reserved` to `prepared`, even | |
| 315 | when a completed stage marker exists. A process can also stop after the prepared | |
| 316 | record commit but before attempt completion; the record survives, while metadata | |
| 317 | recovery still supplies no current selection authority. Missing/corrupt files | |
| 318 | remain actionable integrity failures. Explicit retirement requires the exact | |
| 319 | active ID and a resolved metadata attempt. It changes the record to `retired`, | |
| 320 | retains all files/history/floors, and allows a new ID with a new directory. It does | |
| 321 | not erase or repair the earlier artifact. | |
| 322 | ||
| 323 | Host qualification kills actual processes after reservation, image write, stage | |
| 324 | marker completion, prepared-record commit and retirement commit. Each cut observes | |
| 325 | an actual competing process rejected with SQLITE_BUSY, then restarts, checks | |
| 326 | unchanged candidate phase/floors and prepares a distinct fresh ID after explicit | |
| 327 | recovery/retirement. This does not cover every SQLite/filesystem write or sync, | |
| 328 | ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. Installed | |
| 329 | and known-good slot roles, identity-bound runtime health, current-policy selection | |
| 330 | and rollback, authenticated key maintenance and the full fault campaign remain | |
| 331 | required by #42. | |
| 332 | ||
| 333 | The [host bench](runtime.md) now binds each process to a fresh supervisor-owned boot nonce | |
| 334 | and requires a clean exit. This prerequisite provides no installed image identity, bounded | |
| 335 | health transcript or known-good confirmation. Slot health must still bind the verified image, | |
| 336 | owned boot/session challenge and actual runtime evidence before changing known-good state. | |
| 337 | ||
| 247 | 338 | ## Repository and signed image contract |
| 248 | 339 | |
| 249 | 340 | Use a standard TUF repository with `metadata/` and `targets/` directories, |
| @@ -364,10 +455,10 @@ time. Neither path is a complete anti-rollback coordinator. Trusted clock | ||
| 364 | 455 | acquisition and protection against privileged clock/storage rollback remain |
| 365 | 456 | open. Control-loop freshness still uses its separate monotonic clock. |
| 366 | 457 | |
| 367 | Initial enrollment, the bounded persistent metadata protocol and protected | |
| 368 | signed-image preparation are available above. Complete authenticated | |
| 369 | maintenance/key revocation and the full storage-fault campaign, then durable | |
| 370 | candidate ownership, installed/known-good slot integrity, explicit health | |
| 458 | Initial enrollment, the bounded persistent metadata protocol, protected | |
| 459 | signed-image preparation and durable candidate ownership are available above. | |
| 460 | Complete authenticated maintenance/key revocation and the full storage-fault | |
| 461 | campaign, then installed/known-good slot integrity, explicit health | |
| 371 | 462 | confirmation and rollback without lowering security floors. Then add |
| 372 | 463 | the board bootloader/install protocol and measured power-loss, full-medium, |
| 373 | 464 | storage-latency and recovery campaigns. Local privileged filesystem tampering |
| @@ -41,6 +41,12 @@ boundaries, clock rollback, identity/version rejection, reset state and ten thou | ||
| 41 | 41 | The same fixed-buffer UART state machine used by Pico is also exercised through a host I/O harness |
| 42 | 42 | for fragmentation, resynchronization, corruption, RX/TX budgets and pending transmit buffers. |
| 43 | 43 | |
| 44 | Each 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 | |
| 46 | success only after its actual process exits cleanly. This is host session separation, without | |
| 47 | image attestation or update health confirmation. It does not measure board boot entropy or | |
| 48 | physical timing. See the [host startup contract](runtime-protocol.md#host-hal-test-channel). | |
| 49 | ||
| 44 | 50 | Build a standalone CLI for the current platform: |
| 45 | 51 | |
| 46 | 52 | ```sh |
| @@ -10,8 +10,16 @@ static int nibble(char c) { | ||
| 10 | 10 | return -1; |
| 11 | 11 | } |
| 12 | 12 | int 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); | |
| 15 | 23 | uint64_t now = 0; |
| 16 | 24 | char line[160]; |
| 17 | 25 | while (std::fgets(line, sizeof(line), stdin)) { |
| @@ -6,7 +6,7 @@ import { decodeRuntimeFrame, encodeRuntimeFrame, runtimeCrc32, runtimeFlags } fr | ||
| 6 | 6 | |
| 7 | 7 | export const runtimeBenchFaults = ['none', 'deadline', 'stale', 'expiry', 'replay', 'version'] as const |
| 8 | 8 | export type RuntimeBenchFault = typeof runtimeBenchFaults[number] |
| 9 | export 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> } = {}) { | |
| 9 | export 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> } = {}) { | |
| 10 | 10 | const ticks = options.ticks ?? 1000 |
| 11 | 11 | const fault = options.fault ?? 'none' |
| 12 | 12 | 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 | ||
| 16 | 16 | const session = createRuntimeSession() |
| 17 | 17 | const started = performance.now() |
| 18 | 18 | let time = 1000 |
| 19 | let operationFailed = false, operationError: unknown | |
| 19 | 20 | async function exchange(line: string) { |
| 20 | 21 | await options.onRequest?.(line, time / 1000) |
| 21 | 22 | const status = await harness.exchange(line) |
| @@ -62,11 +63,21 @@ export async function runRuntimeBench(options: { project?: string, ticks?: numbe | ||
| 62 | 63 | || (fault === 'deadline' && !(status.flags & runtimeFlags.deadlineFault))) throw new Error('Native bench failure did not return the required neutral-only, unleased state') |
| 63 | 64 | return { |
| 64 | 65 | evidence: 'host-harness', boardCandidate: 'pico2', protocolVersion: 1, fault, |
| 66 | bootId: String(harness.bootId), | |
| 65 | 67 | ticks: status.ticks, virtualElapsedUs: time, hostElapsedMs: Math.round((performance.now() - started) * 1000) / 1000, |
| 66 | 68 | stateBytes: status.stateBytes, maxExecutionUs: status.maxExecutionUs, deadlineMisses: status.deadlineMisses, |
| 67 | 69 | neutralOnly: true, leaseActive: false, physicalFlightEnabled: false, |
| 68 | 70 | note: 'Host virtual-clock evidence. GPIO timing, stack high-water and hardware watchdog require a Pico 2 bench measurement.', |
| 69 | 71 | } |
| 70 | 72 | } |
| 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 | } | |
| 72 | 83 | } |
| @@ -1,14 +1,25 @@ | ||
| 1 | 1 | import { decodeRuntimeFrame } from './protocol' |
| 2 | import { randomBytes } from 'node:crypto' | |
| 3 | ||
| 4 | function freshBootId() { | |
| 5 | let value = 0n | |
| 6 | while (value === 0n) value = randomBytes(8).readBigUInt64BE() | |
| 7 | return value | |
| 8 | } | |
| 9 | export interface RuntimeHarnessExit { exitCode: number, signal: string | number | null } | |
| 2 | 10 | |
| 3 | 11 | /** ASCII stepping exists only in the host HAL. Board UART carries raw 64-byte frames. */ |
| 4 | 12 | export 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' }) | |
| 6 | 15 | const reader = child.stdout.getReader() |
| 7 | 16 | let pending = '' |
| 8 | 17 | let active = false |
| 9 | 18 | let closed = false |
| 19 | let failed = false | |
| 20 | let closing: Promise<Readonly<RuntimeHarnessExit>> | undefined | |
| 10 | 21 | 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') | |
| 12 | 23 | active = true |
| 13 | 24 | const timeout = setTimeout(() => child.kill('SIGKILL'), 2000) |
| 14 | 25 | try { |
| @@ -24,19 +35,37 @@ export function openRuntimeHarness(artifact: string) { | ||
| 24 | 35 | const hex = pending.slice(0, end) |
| 25 | 36 | pending = pending.slice(end + 1) |
| 26 | 37 | 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) | |
| 28 | 41 | } |
| 42 | catch (error) { failed = true; child.kill('SIGKILL'); throw error } | |
| 29 | 43 | finally { active = false; clearTimeout(timeout) } |
| 30 | 44 | } |
| 31 | 45 | return { |
| 46 | get bootId() { return bootId }, | |
| 32 | 47 | exchange, |
| 33 | async close() { | |
| 34 | if (closed) return | |
| 48 | async close(): Promise<Readonly<RuntimeHarnessExit>> { | |
| 49 | if (closing) return closing | |
| 35 | 50 | 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 | |
| 40 | 69 | }, |
| 41 | 70 | } |
| 42 | 71 | } |
| @@ -25,6 +25,10 @@ const schema = [ | ||
| 25 | 25 | `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`, |
| 26 | 26 | `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`, |
| 27 | 27 | ] |
| 28 | const 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 | ] | |
| 28 | 32 | |
| 29 | 33 | interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string } |
| 30 | 34 | interface StoreReference extends TrustReference { directory: string, storeSha256: string } |
| @@ -63,7 +67,7 @@ function configure(db: Database) { | ||
| 63 | 67 | if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable') |
| 64 | 68 | } |
| 65 | 69 | } |
| 66 | function openDatabase(path: string, expectedSchema: string[]) { | |
| 70 | function openDatabase(path: string, expectedSchema: string[], allowCandidates = false) { | |
| 67 | 71 | const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes) |
| 68 | 72 | const db = new Database(path, { create: false, strict: true }) |
| 69 | 73 | try { |
| @@ -72,19 +76,20 @@ function openDatabase(path: string, expectedSchema: string[]) { | ||
| 72 | 76 | db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;') |
| 73 | 77 | // Authenticate the protocol format before any configuration that could |
| 74 | 78 | // 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) | |
| 76 | 81 | if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format') |
| 77 | 82 | configure(db) |
| 78 | 83 | return db |
| 79 | 84 | } |
| 80 | 85 | catch (error) { db.close(); throw error } |
| 81 | 86 | } |
| 82 | function checkSchema(db: Database, expected: string[]) { | |
| 87 | function databaseVersion(db: Database) { return db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version } | |
| 88 | function checkSchema(db: Database, expected: string[], version = 1) { | |
| 83 | 89 | 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() |
| 84 | 90 | if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema') |
| 85 | 91 | 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') | |
| 88 | 93 | } |
| 89 | 94 | function createDatabase(path: string, statements: string[]) { |
| 90 | 95 | writeExclusive(path, Buffer.alloc(0)) |
| @@ -148,17 +153,22 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b | ||
| 148 | 153 | let writer: Database | undefined, db: Database | undefined |
| 149 | 154 | try { |
| 150 | 155 | 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') | |
| 152 | 157 | if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true) |
| 153 | 158 | } |
| 154 | 159 | writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema]) |
| 155 | 160 | writer.exec('BEGIN IMMEDIATE') |
| 156 | 161 | const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all() |
| 157 | 162 | 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 | } | |
| 159 | 168 | const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get() |
| 160 | 169 | 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') |
| 161 | 170 | summary(db, binding, options.storeSha256) |
| 171 | for (const row of candidateRows(db)) ownedDescription(db, row, trusted) | |
| 162 | 172 | return await operation(db, binding, trusted, path) |
| 163 | 173 | } |
| 164 | 174 | finally { |
| @@ -207,7 +217,7 @@ export interface RuntimeUpdateClock { | ||
| 207 | 217 | } |
| 208 | 218 | |
| 209 | 219 | export 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' | |
| 211 | 221 | ordinal: number |
| 212 | 222 | } |
| 213 | 223 | type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void> |
| @@ -270,11 +280,14 @@ interface VerifiedSelection { | ||
| 270 | 280 | source: string |
| 271 | 281 | info: TargetFile |
| 272 | 282 | runtime: ReturnType<typeof admitSignedRuntime> |
| 283 | attempt: string | |
| 284 | path: string | |
| 273 | 285 | } |
| 274 | 286 | |
| 275 | async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> { | |
| 287 | async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> { | |
| 276 | 288 | options = { ...options } |
| 277 | return withStore(options, async (db, binding, trusted) => { | |
| 289 | return withStore(options, async (db, binding, trusted, path) => { | |
| 290 | before?.(db) | |
| 278 | 291 | if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery') |
| 279 | 292 | const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms) |
| 280 | 293 | 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 | ||
| 323 | 336 | db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence) |
| 324 | 337 | db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt) |
| 325 | 338 | })() |
| 326 | const value = await complete({ db, binding, trusted, source, info, runtime }) | |
| 339 | const value = await complete({ db, binding, trusted, source, info, runtime, attempt, path }) | |
| 327 | 340 | db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt) |
| 328 | 341 | return { store: summary(db, binding, options.storeSha256), value } |
| 329 | 342 | } |
| @@ -353,28 +366,186 @@ export interface PreparedRuntimeFirmware { | ||
| 353 | 366 | export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> { |
| 354 | 367 | options = { ...options } |
| 355 | 368 | 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 | ||
| 373 | function 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 | ||
| 378 | async 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 | ||
| 401 | interface CandidateRow { id: string, attempt_id: string, generation: number, phase: 'reserved' | 'prepared' | 'retired', description: Uint8Array, sha256: string } | |
| 402 | export 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 | } | |
| 412 | export 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 | ||
| 423 | function 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 | } | |
| 429 | function 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 | } | |
| 440 | function 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')! | |
| 358 | 444 | 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`)]]) | |
| 363 | 447 | 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)) | |
| 366 | 449 | } |
| 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 | } | |
| 484 | function 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. */ | |
| 502 | export 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. */ | |
| 507 | export 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) | |
| 376 | 517 | }) |
| 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. */ | |
| 521 | export 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 }) | |
| 378 | 549 | } |
| 379 | 550 | |
| 380 | 551 | function causedBy(error: unknown, marker: Error) { |
| @@ -0,0 +1,90 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { constants } from 'node:fs' | |
| 3 | import { createHash } from 'node:crypto' | |
| 4 | import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 5 | import { join, resolve } from 'node:path' | |
| 6 | import { platform, repository, target } from './repository' | |
| 7 | ||
| 8 | async 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. */ | |
| 11 | export 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 | } | |
| 87 | if (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 | } | |
| @@ -3,13 +3,13 @@ import { spawnSync } from 'node:child_process' | ||
| 3 | 3 | import { mkdir, readFile, writeFile } from 'node:fs/promises' |
| 4 | 4 | import { join, resolve } from 'node:path' |
| 5 | 5 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' |
| 6 | import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store' | |
| 6 | import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store' | |
| 7 | 7 | import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store' |
| 8 | 8 | import { platform, repository, target } from './repository' |
| 9 | 9 | |
| 10 | 10 | type Reference = Parameters<typeof inspectRuntimeTrustStore>[0] |
| 11 | type Cut = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | |
| 12 | interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string } | |
| 11 | type Cut = RuntimeUpdateProgress['phase'] | |
| 12 | interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string, managed?: boolean, candidateId?: string } | |
| 13 | 13 | const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') } |
| 14 | 14 | |
| 15 | 15 | async function worker(mode: string, file: string) { |
| @@ -29,16 +29,19 @@ async function worker(mode: string, file: string) { | ||
| 29 | 29 | console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal })) |
| 30 | 30 | await Bun.stdin.text() |
| 31 | 31 | } } |
| 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 }) | |
| 33 | 35 | else await refreshRuntimeTrustStore(options) |
| 34 | 36 | throw new Error('Interruption worker unexpectedly finished') |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | /** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */ |
| 38 | async function qualifyInterruptions(directory: string, preparation: boolean) { | |
| 40 | async function qualifyInterruptions(directory: string, operation: 'refresh' | 'prepare' | 'reserve') { | |
| 39 | 41 | await mkdir(directory, { mode: 0o700 }) |
| 40 | 42 | 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'] | |
| 42 | 45 | for (const cut of cuts) { |
| 43 | 46 | const root = join(directory, cut); await mkdir(root, { mode: 0o700 }) |
| 44 | 47 | const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } }) |
| @@ -46,8 +49,10 @@ async function qualifyInterruptions(directory: string, preparation: boolean) { | ||
| 46 | 49 | const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory }) |
| 47 | 50 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) |
| 48 | 51 | 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 }) | |
| 51 | 56 | const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) |
| 52 | 57 | const timer = setTimeout(() => child.kill('SIGKILL'), 15000) |
| 53 | 58 | const reader = child.stdout.getReader(), decoder = new TextDecoder() |
| @@ -68,25 +73,42 @@ async function qualifyInterruptions(directory: string, preparation: boolean) { | ||
| 68 | 73 | const exit = await child.exited, signal = child.signalCode |
| 69 | 74 | assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0) |
| 70 | 75 | const interrupted = await inspectRuntimeTrustStore(reference) |
| 71 | assert.equal(interrupted.attempt?.phase, 'pending') | |
| 76 | assert.equal(interrupted.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'pending') | |
| 72 | 77 | assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1) |
| 73 | 78 | 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) | |
| 74 | 86 | const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false |
| 75 | 87 | 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)) | |
| 78 | 90 | } |
| 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') | |
| 81 | 93 | assert.equal(recovered.releaseFloor, preparation ? 9 : 7) |
| 82 | 94 | 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 | } | |
| 85 | 107 | assert.equal(fresh.generation >= interrupted.generation, true) |
| 86 | 108 | if (!preparation) assert.equal(fresh.generation, 3) |
| 87 | 109 | assert.equal(fresh.releaseFloor, 9) |
| 88 | 110 | 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 }) | |
| 90 | 112 | } |
| 91 | 113 | finally { |
| 92 | 114 | clearTimeout(timer) |
| @@ -95,18 +117,20 @@ async function qualifyInterruptions(directory: string, preparation: boolean) { | ||
| 95 | 117 | reader.releaseLock() |
| 96 | 118 | await writeFile(join(root, 'worker.stdout'), stdout) |
| 97 | 119 | 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)) | |
| 99 | 121 | } |
| 100 | 122 | } |
| 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 } | |
| 102 | 124 | } |
| 103 | 125 | |
| 104 | export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) } | |
| 105 | export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) } | |
| 126 | export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, 'refresh') } | |
| 127 | export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, 'prepare') } | |
| 128 | export async function qualifyCandidateInterruptions(directory: string) { return qualifyInterruptions(directory, 'reserve') } | |
| 106 | 129 | |
| 107 | 130 | if (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]!)))) | |
| 109 | 133 | else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!) |
| 110 | 134 | 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') | |
| 112 | 136 | } |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import { beforeAll, describe, expect, test } from 'bun:test' |
| 2 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 2 | 5 | import fixtures from '../fixtures/runtime/frames.json' |
| 3 | 6 | import { commandFrame, decodeRuntimeFrame, encodeRuntimeFrame, runtimeCrc32, type RuntimeFrame } from '../../resources/functions/drone/runtime/protocol' |
| 4 | 7 | import { createRuntimeSession } from '../../resources/functions/drone/runtime/session' |
| @@ -86,13 +89,60 @@ async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>) | ||
| 86 | 89 | } |
| 87 | 90 | |
| 88 | 91 | describe('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 | }) | |
| 89 | 138 | test('reports timestamped samples and cross-language handshake/lease/stop', async () => withNative(async harness => { |
| 90 | 139 | const first = await harness.exchange('tick 1000') |
| 91 | 140 | expect(first).toMatchObject({ ticks: 1, sample: -999, sampleTimeUs: 1000n, flags: 67 }) |
| 92 | 141 | const link = createRuntimeSession({ nonce: () => 45n }) |
| 93 | 142 | const send = (bytes: Uint8Array) => harness.exchange(`frame ${Buffer.from(bytes).toString('hex')}`) |
| 94 | 143 | 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) | |
| 96 | 146 | const lease = await send(link.lease(1)); link.accept(encodeRuntimeFrame(lease), 1) |
| 97 | 147 | expect(lease.leaseUs).toBe(100000) |
| 98 | 148 | expect((await send(link.stop(1))).leaseUs).toBe(0) |
| @@ -126,6 +176,7 @@ describe('actual native kernel process', () => { | ||
| 126 | 176 | expect(report).toMatchObject({ evidence: 'host-harness', fault, neutralOnly: true, leaseActive: false, physicalFlightEnabled: false }) |
| 127 | 177 | expect(report.stateBytes).toBeLessThanOrEqual(128) |
| 128 | 178 | expect(report.deadlineMisses).toBe(fault === 'deadline' ? 1 : 0) |
| 179 | expect(report.bootId).toMatch(/^[1-9][0-9]{0,19}$/) | |
| 129 | 180 | } |
| 130 | 181 | }, 20000) |
| 131 | 182 | test('invalid CLI parameters cannot silently execute a default bench run', async () => { |
| @@ -139,6 +190,8 @@ describe('actual native kernel process', () => { | ||
| 139 | 190 | await expect(harness.exchange('tick 2000')).rejects.toThrow('concurrent') |
| 140 | 191 | expect((await pending).ticks).toBe(1) |
| 141 | 192 | 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') | |
| 142 | 195 | }) |
| 143 | 196 | }) |
| 144 | 197 | }) |
| @@ -0,0 +1,43 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, readFile, rm } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 7 | import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 9 | ||
| 10 | const roots: string[] = [] | |
| 11 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 12 | ||
| 13 | test('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) | |
| @@ -0,0 +1,188 @@ | ||
| 1 | import { afterEach, expect, test } from 'bun:test' | |
| 2 | import { Database } from 'bun:sqlite' | |
| 3 | import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 7 | import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 8 | import { platform, repository, target } from '../fixtures/updates/repository' | |
| 9 | import { qualifyCandidateInterruptions } from '../fixtures/updates/store-interruptions' | |
| 10 | import { epochRepository } from '../fixtures/updates/epochs' | |
| 11 | ||
| 12 | const roots: string[] = [] | |
| 13 | afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) | |
| 14 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 15 | async 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 | ||
| 24 | test('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 | ||
| 47 | test('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 | ||
| 70 | test('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 | ||
| 94 | test('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 | ||
| 108 | test('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 | ||
| 125 | test('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 | ||
| 132 | test('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 | ||
| 147 | test('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 | }) | |