also looking at this
test: control purge history preparation clock
#55
37 files
+1804
-168
| @@ -30,6 +30,8 @@ jobs: | ||
| 30 | 30 | - run: bun run parity:check |
| 31 | 31 | - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py |
| 32 | 32 | - run: python3 -m unittest discover -s tests/hardware |
| 33 | - name: Verify a kernel-confirmed full input pipe on Linux | |
| 34 | run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO' | |
| 33 | 35 | - run: ./buddy test |
| 34 | 36 | - run: bun run runtime:cli |
| 35 | 37 | - name: Verify signed test-image staging through the portable CLI outside the checkout |
| @@ -44,6 +46,15 @@ jobs: | ||
| 44 | 46 | run: bun tests/fixtures/updates/qualify-preparation.ts "$RUNNER_TEMP/runtime-preparation-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" |
| 45 | 47 | - name: Verify owned candidate identity and retirement with a copied standalone CLI |
| 46 | 48 | run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" |
| 49 | - name: Verify host slots, observed health and interrupted recovery with a copied standalone CLI | |
| 50 | run: bun tests/fixtures/updates/qualify-host-slots.ts "$RUNNER_TEMP/runtime-host-slots-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$GITHUB_WORKSPACE" | |
| 51 | - uses: actions/upload-artifact@v4 | |
| 52 | if: always() | |
| 53 | with: | |
| 54 | name: runtime-host-slots-evidence | |
| 55 | if-no-files-found: ignore | |
| 56 | retention-days: 7 | |
| 57 | path: ${{ runner.temp }}/runtime-host-slots-qualification | |
| 47 | 58 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline |
| 48 | 59 | working-directory: ${{ runner.temp }} |
| 49 | 60 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500 |
| @@ -270,8 +270,10 @@ store retains verified metadata, clock/release floors and historical recovery; | ||
| 270 | 270 | `updates:prepare` authenticates actual image bytes under that protected state. |
| 271 | 271 | `updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate |
| 272 | 272 | identity, recorded-byte status and explicit retirement while retaining files/history. |
| 273 | Physical logging/commissioning, key revocation/repair, installed/known-good slots | |
| 274 | and image-bound boot/session health remain pending. | |
| 273 | `updates:trial`, `updates:slots`, `updates:rollback` and `updates:abandon` add retained | |
| 274 | host slots, actual image/boot/session health and fresh-policy rollback for the owned | |
| 275 | neutral host build. Physical logging/commissioning, key repair, complete storage-fault | |
| 276 | coverage and board image/install/boot/rollback qualification remain pending. | |
| 275 | 277 | |
| 276 | 278 | ## Sensor acquisition and replay |
| 277 | 279 | |
| @@ -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 { abandonRuntimeFirmwareTrial } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:abandon <directory>', | |
| 8 | description: 'Abandon an exact interrupted host trial after metadata recovery while retaining artifacts and known-good state', | |
| 9 | options: { ...storeReferenceFlags, '--trial <id>': 'Exact interrupted host trial identity' }, | |
| 10 | async handle(options, directory) { | |
| 11 | try { | |
| 12 | if (typeof options.trial !== 'string') throw new Error('Explicit interrupted trial required') | |
| 13 | await printAsync(JSON.stringify(await abandonRuntimeFirmwareTrial({ ...storeCommandReference(options, directory), trialId: options.trial }))) | |
| 14 | } | |
| 15 | catch { await storeCommandFailure() } | |
| 16 | }, | |
| 17 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync } from '../../resources/functions/cli/output' | |
| 3 | import { hostTrialCommandOptions, hostTrialFlags, storeCommandFailure } from '../../resources/functions/drone/updates/command' | |
| 4 | import { rollbackRuntimeFirmwareSlot } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:rollback <directory>', | |
| 8 | description: 'Re-verify the retained known-good host image under current policy and observe a fresh health session', | |
| 9 | options: hostTrialFlags, | |
| 10 | async handle(options, directory) { | |
| 11 | try { await printAsync(JSON.stringify(await rollbackRuntimeFirmwareSlot(hostTrialCommandOptions(options, directory)))) } | |
| 12 | catch { await storeCommandFailure() } | |
| 13 | }, | |
| 14 | }) | |
| @@ -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 { inspectRuntimeFirmwareSlots } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:slots <directory>', | |
| 8 | description: 'Inspect retained host slot roles, evidence and recorded-byte integrity without current trust authority', | |
| 9 | options: storeReferenceFlags, | |
| 10 | async handle(options, directory) { | |
| 11 | try { await printAsync(JSON.stringify(await inspectRuntimeFirmwareSlots(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 { hostTrialCommandOptions, hostTrialFlags, storeCommandFailure } from '../../resources/functions/drone/updates/command' | |
| 4 | import { trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'updates:trial <directory>', | |
| 8 | description: 'Confirm an owned host image only through current signed policy and its actual neutral runtime health', | |
| 9 | options: { ...hostTrialFlags, '--candidate <id>': 'Exact owned prepared candidate identity' }, | |
| 10 | async handle(options, directory) { | |
| 11 | try { | |
| 12 | if (typeof options.candidate !== 'string') throw new Error('Explicit owned candidate required') | |
| 13 | await printAsync(JSON.stringify(await trialRuntimeFirmwareCandidate({ ...hostTrialCommandOptions(options, directory), candidateId: options.candidate }))) | |
| 14 | } | |
| 15 | catch { await storeCommandFailure() } | |
| 16 | }, | |
| 17 | }) | |
| @@ -31,6 +31,10 @@ import UpdatesPrepare from '../app/Commands/UpdatesPrepare' | ||
| 31 | 31 | import UpdatesReserve from '../app/Commands/UpdatesReserve' |
| 32 | 32 | import UpdatesCandidates from '../app/Commands/UpdatesCandidates' |
| 33 | 33 | import UpdatesRetire from '../app/Commands/UpdatesRetire' |
| 34 | import UpdatesTrial from '../app/Commands/UpdatesTrial' | |
| 35 | import UpdatesRollback from '../app/Commands/UpdatesRollback' | |
| 36 | import UpdatesSlots from '../app/Commands/UpdatesSlots' | |
| 37 | import UpdatesAbandon from '../app/Commands/UpdatesAbandon' | |
| 34 | 38 | |
| 35 | 39 | import MissionsSimulate from '../app/Commands/MissionsSimulate' |
| 36 | 40 | import MissionsValidate from '../app/Commands/MissionsValidate' |
| @@ -105,6 +109,10 @@ UpdatesPrepare(app) | ||
| 105 | 109 | UpdatesReserve(app) |
| 106 | 110 | UpdatesCandidates(app) |
| 107 | 111 | UpdatesRetire(app) |
| 112 | UpdatesTrial(app) | |
| 113 | UpdatesRollback(app) | |
| 114 | UpdatesSlots(app) | |
| 115 | UpdatesAbandon(app) | |
| 108 | 116 | MissionsSimulate(app) |
| 109 | 117 | MissionsValidate(app) |
| 110 | 118 | MissionsReplay(app) |
| @@ -67,6 +67,7 @@ directories inside the private store. Explicit retirement preserves the files | ||
| 67 | 67 | and history. Metadata-only recovery cannot promote a reserved candidate based on |
| 68 | 68 | its receipt. Recorded-byte status is not current-policy authorization. |
| 69 | 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, | |
| 70 | The [host slot health decision](0029-host-slot-health.md) extends this protocol to | |
| 71 | format3 with retained host images and actual owned runtime confirmation. No receipt | |
| 72 | or caller-supplied healthy boolean can select firmware. Board installation, | |
| 72 | 73 | bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9. |
| @@ -0,0 +1,63 @@ | ||
| 1 | # Owned host slot health | |
| 2 | ||
| 3 | Status: implementation in progress for #42. Physical deployment remains disabled. | |
| 4 | ||
| 5 | The metadata store and candidate archive authenticate bytes without observing a | |
| 6 | runtime. Add host slot roles and health confirmation to that same exclusive | |
| 7 | protocol operation. Keep the immutable enrollment and pinned store identity. | |
| 8 | Format3 extends format2 atomically with host slot records, one installed role, | |
| 9 | one known-good role and bounded ordered command/status inputs. The writer file | |
| 10 | remains format1. The [bounded protocol SQLite decision](0028-runtime-trust-store.md) | |
| 11 | still applies; these records are not application CRUD. | |
| 12 | ||
| 13 | The host trial requires `host-neutral-bench-v1`, an explicit trusted local source | |
| 14 | checkout at the signed Git revision, and a clean firmware tree. Build and CTest | |
| 15 | that source, then require exact byte equality with the currently authenticated | |
| 16 | candidate. A signed profile declaration alone cannot reach runtime execution. | |
| 17 | Retain a separate private, non-executable slot image. Observe the corresponding | |
| 18 | byte-identical local host build. The source checkout, compiler and host OS remain | |
| 19 | trusted inputs; this is not a general executable attestation scheme. | |
| 20 | ||
| 21 | Commit selection before image IO. After copied-image readback, record the running | |
| 22 | host role while retaining the previous known-good role. Allocate boot and session | |
| 23 | challenges within the actual supervisor. Commit every bounded command/status input | |
| 24 | before interpreting health. Require progressing samples, the exact 100-tick neutral | |
| 25 | trial, the matching stop reply, clean process exit and complete allowed output. | |
| 26 | Recheck executable/slot bytes and the independently supplied clock. Commit the | |
| 27 | final time observation and its floor before checking expiration, so a rejected | |
| 28 | confirmation cannot discard trusted time. Check metadata expiration with local | |
| 29 | elapsed time and a one-second sample freshness limit before atomically recording confirmation, the new known-good role, | |
| 30 | the completed clock floor and release of the candidate reservation. No caller | |
| 31 | boolean, typed receipt or existing marker can perform that transition. | |
| 32 | ||
| 33 | The Linux transport regression uses a native C++17 fixture with the existing | |
| 34 | wire codec. It reports kernel FIFO capacity and queued bytes without consuming | |
| 35 | stdin, filling a complete kernel page before testing blocked-write cleanup. | |
| 36 | The test supports 4 KiB, 16 KiB and 64 KiB pages. It is host transport evidence; | |
| 37 | physical UART, board timing and electrical faults have separate acceptance gates. | |
| 38 | ||
| 39 | Reservation lifetime and slot lifetime are separate. A confirmed candidate's | |
| 40 | reservation becomes retired so a new candidate can be prepared. Its candidate | |
| 41 | history and files remain, and the separately confirmed slot role retains its own | |
| 42 | image and transcript. Historical confirmed records are not fresh current-policy | |
| 43 | authorization. Slot inspection rechecks record/byte integrity and the whole | |
| 44 | retained host transcript; it does not grant runtime authority. | |
| 45 | ||
| 46 | Metadata recovery preserves its verified prefix and security floors. An unfinished | |
| 47 | selected/running trial needs explicit abandonment by exact ID after that recovery. | |
| 48 | Abandonment retains files, inputs and the prior known-good role. A real completed | |
| 49 | confirmation transaction survives a crash before the enclosing attempt returns; | |
| 50 | recovery preserves that observed result without inventing another confirmation. | |
| 51 | ||
| 52 | Rollback selects the retained known-good identity under the same writer lease, | |
| 53 | checks its retained image, performs fresh current-time signed verification, and | |
| 54 | observes another actual runtime session. It cannot reuse the old health record as | |
| 55 | new health. Expired, revoked, below-floor, missing or corrupt images fail closed. | |
| 56 | The fixed policy target must currently authorize the exact image; older cached | |
| 57 | metadata cannot override a replacement target or the global release floor. | |
| 58 | ||
| 59 | These are host virtual-clock and local-file assertions. Actual board image/build | |
| 60 | attestation, bootloader installation, electrical power-loss recovery, secure boot, | |
| 61 | hardware anti-rollback, physical timing and commissioning still need their own | |
| 62 | implementation and measurements under #2/#9. Authenticated key repair and the full | |
| 63 | short/zero-write, ENOSPC/EDQUOT/EIO and sync-failure campaign remain required by #42. | |
| @@ -159,11 +159,12 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st | ||
| 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 | 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 | |
| 163 | known-good slot coordination, current-policy rollback and exact-image boot/session | |
| 164 | health before flashing. Standalone staging/inspection still need explicit trust | |
| 165 | and are separate from protected-current-policy selection. Host preparation does | |
| 166 | not satisfy full update/rollback or hardware anti-rollback acceptance. | |
| 162 | exact identities and files without granting boot authority. Host slots now add | |
| 163 | actual owned-image session confirmation and fresh-policy rollback. Complete key | |
| 164 | repair, full write/sync/storage-fault coverage and board image/install/boot/health | |
| 165 | qualification before flashing. Standalone staging/inspection still need explicit | |
| 166 | trust and remain separate from protected-current-policy selection. Host evidence | |
| 167 | does not satisfy physical update/rollback or hardware anti-rollback acceptance. | |
| 167 | 168 | - Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog, |
| 168 | 169 | estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6. |
| 169 | 170 | |
| @@ -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, 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) | | |
| 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification 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) | |
| @@ -19,7 +19,8 @@ | ||
| 19 | 19 | "firmware/runtime/", |
| 20 | 20 | "resources/functions/drone/runtime/", |
| 21 | 21 | "docs/runtime.md", |
| 22 | "tests/unit/runtime.test.ts" | |
| 22 | "tests/unit/runtime.test.ts", | |
| 23 | "tests/fixtures/runtime/full-input.cpp" | |
| 23 | 24 | ], |
| 24 | 25 | "remaining": [ |
| 25 | 26 | "Physical Pico 2 boot/flash evidence", |
| @@ -273,16 +274,26 @@ | ||
| 273 | 274 | "app/Commands/UpdatesReserve.ts", |
| 274 | 275 | "app/Commands/UpdatesCandidates.ts", |
| 275 | 276 | "app/Commands/UpdatesRetire.ts", |
| 277 | "app/Commands/UpdatesTrial.ts", | |
| 278 | "app/Commands/UpdatesSlots.ts", | |
| 279 | "app/Commands/UpdatesRollback.ts", | |
| 280 | "app/Commands/UpdatesAbandon.ts", | |
| 281 | "docs/adr/0029-host-slot-health.md", | |
| 276 | 282 | "docs/runtime-updates.md", |
| 277 | 283 | "tests/unit/updates-preparation.test.ts", |
| 278 | 284 | "tests/unit/updates-candidates.test.ts", |
| 279 | "tests/unit/updates-candidates-cli.test.ts" | |
| 285 | "tests/unit/updates-candidates-cli.test.ts", | |
| 286 | "tests/unit/updates-host-slots.test.ts", | |
| 287 | "tests/unit/updates-host-policy.test.ts", | |
| 288 | "tests/unit/updates-host-slots-cli.test.ts", | |
| 289 | "tests/fixtures/updates/qualify-host-slots.ts", | |
| 290 | "tests/fixtures/updates/host-slot-interruptions.ts" | |
| 280 | 291 | ], |
| 281 | 292 | "remaining": [ |
| 282 | 293 | "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations", |
| 283 | 294 | "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence", |
| 284 | 295 | "Measured board logging deadlines, memory, rotation and long-mission retention", |
| 285 | "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health", | |
| 296 | "Complete coordinator key/clock maintenance, storage-fault recovery and board image/install/boot health and rollback qualification", | |
| 286 | 297 | "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence" |
| 287 | 298 | ] |
| 288 | 299 | }, |
| @@ -92,6 +92,16 @@ requests, bounds response lines and times out unresponsive processes. Every retu | ||
| 92 | 92 | match the supervisor's launch nonce. Invalid responses kill the process and permanently fail |
| 93 | 93 | that transport. Returned status frames are immutable. Closing waits for actual process exit and |
| 94 | 94 | returns its exit code and signal; repeated closes retain the same result. |
| 95 | Successful close also requires stdout completion without extra bytes and empty | |
| 96 | stderr. The one-second close deadline rejects inherited pipes that remain open | |
| 97 | after parent exit, cancels readers and retains cleanup failures. These checks | |
| 98 | support the [owned host slot trial](runtime-updates.md#owned-host-slots-and-observed-health). | |
| 99 | Host stdin uses an owned nonblocking POSIX pipe. The supervisor creates it with | |
| 100 | `mkfifo`, opens and verifies its descriptors, then unlinks its private directory | |
| 101 | before launching the process. Writes handle partial progress and EAGAIN within the | |
| 102 | existing two-second response deadline. Closing the writer drops the owned descriptor | |
| 103 | without waiting for a buffered flush. The deadline also cancels an inherited output | |
| 104 | read; killing an already-exited parent alone cannot finish that read. | |
| 95 | 105 | |
| 96 | 106 | The optional [DS sensor stream](sensors.md) shares UART TX as complete 128-byte frames. |
| 97 | 107 | DR remains 64 bytes. Awaited DR replies take priority when the current frame finishes; partial |
| @@ -2,10 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image |
| 4 | 4 | staging, independent candidate verification, protected initial trust enrollment |
| 5 | and a persistent host metadata protocol with protected image preparation. These | |
| 6 | commands prepare and verify host state and actual candidate image bytes. Key | |
| 7 | revocation/repair, installed images, boot selection and known-good rollback slots | |
| 8 | remain part of the full coordinator. Physical deployment is disabled. | |
| 5 | and a persistent host metadata protocol with protected image preparation. The host | |
| 6 | coordinator also retains image slots and observes owned neutral runtime health for | |
| 7 | confirmation and fresh-policy rollback. Key repair, complete storage-fault coverage | |
| 8 | and board installation/boot recovery remain part of the full coordinator. Physical | |
| 9 | deployment is disabled. | |
| 9 | 10 | |
| 10 | 11 | ## Explicit trust and policy |
| 11 | 12 | |
| @@ -80,8 +81,9 @@ writes cache files in place; a host write-limit probe truncated its cached root. | ||
| 80 | 81 | Never refresh against the enrollment directory. Staging/inspection still require |
| 81 | 82 | their explicit policy and do not load or advance this enrollment automatically. |
| 82 | 83 | The metadata-store functions below retain verified metadata/release floors. |
| 83 | The complete key lifecycle, storage-fault campaigns, image integration and | |
| 84 | known-good coordinator remain in [issue #42](https://github.com/stacksjs/drone/issues/42). | |
| 84 | Authenticated key repair, complete storage-fault campaigns and board image/install/boot | |
| 85 | qualification remain in [issue #42](https://github.com/stacksjs/drone/issues/42). | |
| 86 | The host coordinator below adds protected image preparation and observed known-good slots. | |
| 85 | 87 | Do not discard or re-enroll over failed state to reset a security floor. |
| 86 | 88 | |
| 87 | 89 | ## Persistent host metadata protocol |
| @@ -325,15 +327,104 @@ marker completion, prepared-record commit and retirement commit. Each cut observ | ||
| 325 | 327 | an actual competing process rejected with SQLITE_BUSY, then restarts, checks |
| 326 | 328 | unchanged candidate phase/floors and prepares a distinct fresh ID after explicit |
| 327 | 329 | 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. | |
| 330 | ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. The host slot | |
| 331 | operation below adds actual image/session confirmation. Authenticated key repair, | |
| 332 | the full storage-fault campaign and physical board acceptance remain required by #42. | |
| 332 | 333 | |
| 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. | |
| 334 | ## Owned host slots and observed health | |
| 335 | ||
| 336 | Host trials support `host-neutral-bench-v1`. Supply a trusted local source checkout | |
| 337 | whose HEAD is the signed `sourceRevision` and whose `firmware/runtime` tree is clean. | |
| 338 | The coordinator builds that source and runs CTest. Candidate bytes must exactly | |
| 339 | match the resulting neutral host executable before it can run. A signed text | |
| 340 | payload or a profile label cannot substitute for this equality check. The checkout, | |
| 341 | compiler and host OS remain trusted inputs. | |
| 342 | ||
| 343 | ```sh | |
| 344 | ./buddy updates:trial /private/runtime-store \ | |
| 345 | --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \ | |
| 346 | --store-sha256 "$STORE_SHA256" --candidate "$CANDIDATE_ID" \ | |
| 347 | --repository /private/runtime-repository --project /trusted/drone-source \ | |
| 348 | --trusted-system-clock-source operator-managed-clock | |
| 349 | ||
| 350 | ./buddy updates:slots /private/runtime-store \ | |
| 351 | --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \ | |
| 352 | --store-sha256 "$STORE_SHA256" | |
| 353 | ||
| 354 | ./buddy updates:rollback /private/runtime-store \ | |
| 355 | --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \ | |
| 356 | --store-sha256 "$STORE_SHA256" --repository /private/runtime-repository \ | |
| 357 | --project /trusted/drone-source --trusted-system-clock-source operator-managed-clock | |
| 358 | ``` | |
| 359 | ||
| 360 | The same native commands are registered in the standalone CLI. Clock-source names | |
| 361 | are audit labels for independently managed clocks, not clock attestations. The | |
| 362 | trusted clock is sampled before selection and again before confirmation. The final | |
| 363 | observation is committed to the retained clock floor before expiration checks, even | |
| 364 | if confirmation is later rejected. `clockCheckedMs` and `confirmedClockMs` distinguish | |
| 365 | an accepted time observation from a completed confirmation. Release floors cannot decrease. | |
| 366 | ||
| 367 | Format3 adds `host_slots`, `host_roles` and `host_inputs` in one transaction over | |
| 368 | the exact authenticated format2 schema. Existing identity, metadata, attempts, | |
| 369 | epochs and release/clock floors remain. Readers inspect format1/2 without upgrading | |
| 370 | them. Host trials upgrade only an owned format2 store. Older binaries reject the | |
| 371 | new format; retain state and use a supported reader instead of downgrading or | |
| 372 | reinitializing it. The writer database remains format1. | |
| 373 | ||
| 374 | Selection records an immutable slot ID before image IO. Each slot retains its own | |
| 375 | private image copy; the observed process executes the byte-identical local build. | |
| 376 | The `installed` host role records the copied image selected for that trial. It | |
| 377 | does not describe board installation. A failed trial can leave this role `failed` | |
| 378 | while the previous `knownGood` role and image remain intact. | |
| 379 | ||
| 380 | The supervisor owns fresh boot/session challenges and records 214 ordered inputs | |
| 381 | for its fixed 100-tick virtual-clock trial. Each input is committed before health | |
| 382 | interpretation. The final stop response must match the owned command, and success | |
| 383 | requires actual clean process exit, allowed stdout completion and empty stderr. | |
| 384 | Executable and copied-image bytes are rechecked before confirmation. A second | |
| 385 | trusted-clock sample must remain valid, and all active metadata must still be | |
| 386 | unexpired after accounting for local elapsed time. The interval after that sample | |
| 387 | has a one-second freshness guard. Confirmation, the known-good role, the completion clock floor and release | |
| 388 | of that candidate reservation commit atomically. | |
| 389 | ||
| 390 | Candidate retirement releases its reservation and retains its files/history. | |
| 391 | Separately confirmed slots retain their own images and transcripts. Thus a new | |
| 392 | candidate can be prepared without overwriting the prior known-good image. Slot | |
| 393 | inspection verifies retained image integrity and the full boot/session-bound | |
| 394 | transcript. Its `currentTrustValidated: false` means historical health does not | |
| 395 | authorize use under current policy. A corrupt or missing retained rollback image | |
| 396 | is actionable even when another archive contains matching bytes. | |
| 397 | ||
| 398 | Rollback performs fresh signed verification under current roots, metadata and | |
| 399 | security floors, then observes another actual host process with new challenges. | |
| 400 | The fixed target must currently authorize those exact bytes. A newer signed target | |
| 401 | or targets-key revocation can make the retained known-good image unavailable for | |
| 402 | rollback. Metadata/release advancement survives that rejection. Missing, corrupt, | |
| 403 | expired or below-floor images cannot silently repair themselves or reuse old health. | |
| 404 | ||
| 405 | After an interrupted trial, run `updates:recover` to resolve metadata first. Inspect | |
| 406 | the retained trial with `updates:slots`. A `selected` or `running` trial then needs | |
| 407 | deliberate abandonment by its exact ID: | |
| 408 | ||
| 409 | ```sh | |
| 410 | ./buddy updates:abandon /private/runtime-store \ | |
| 411 | --enrollment /private/runtime-enrollment --enrollment-sha256 "$ENROLLMENT_SHA256" \ | |
| 412 | --store-sha256 "$STORE_SHA256" --trial "$TRIAL_ID" | |
| 413 | ``` | |
| 414 | ||
| 415 | Abandonment retains files, inputs and known-good state. Metadata replay cannot | |
| 416 | promote an unfinished trial. A confirmation transaction that actually completed | |
| 417 | before interruption survives replay, including when its enclosing attempt had | |
| 418 | not yet returned. History is bounded to 128 slots, 256 inputs per trial and the | |
| 419 | existing global database/attempt limits. Exhaustion requires supported maintenance, | |
| 420 | never deletion of trust history or reset of a security floor. | |
| 421 | ||
| 422 | Host tests cut actual processes after selection, image readback, running-state | |
| 423 | commit, mid-session input commit, final trusted-clock commit and health confirmation. Each cut checks a real | |
| 424 | competing writer, restart behavior and fresh rollback. These are process-crash and | |
| 425 | host-file observations. Board boot, electrical power-loss durability, syscall/storage | |
| 426 | fault campaigns, secure boot and hardware anti-rollback require separate evidence. | |
| 427 | See the [host slot decision](adr/0029-host-slot-health.md). | |
| 337 | 428 | |
| 338 | 429 | ## Repository and signed image contract |
| 339 | 430 | |
| @@ -400,8 +491,8 @@ pre-delivery replay marker and completed domain-policy rejection. | ||
| 400 | 491 | The seam alone does not persist a protected prefix or make SDK cache writes |
| 401 | 492 | transactional. Its implementation and tests are a prerequisite for the protected |
| 402 | 493 | store and recovery coordinator in issue #42. The bounded metadata protocol above |
| 403 | uses that seam; neither completes the full coordinator or | |
| 404 | permit flashing, boot selection or physical deployment. | |
| 494 | uses that seam. Flashing, physical boot selection and deployment remain disabled | |
| 495 | pending the full coordinator. | |
| 405 | 496 | |
| 406 | 497 | The selected target must have SHA-256, a nonzero length of at most 16 MiB and |
| 407 | 498 | exactly these fields in its signed `custom.drone` object: |
| @@ -422,8 +513,10 @@ paths use bounded ASCII names without traversal, escapes or symlinks. Metadata | ||
| 422 | 513 | is limited to 512 KiB per file, 16 root rotations, 16 delegations, 64 requests and |
| 423 | 514 | 32 MiB of distinct fetched bytes. FIFO, device, symlink and oversized inputs fail. |
| 424 | 515 | The neutral profile is a **signer's declaration**, not an inspection of machine |
| 425 | code or proof of neutral outputs. This module authenticates bytes and declared | |
| 426 | compatibility; it does not parse UF2/ELF or execute an image. | |
| 516 | code or proof of neutral outputs. Standalone staging and inspection authenticate | |
| 517 | bytes and declared compatibility without parsing UF2/ELF or executing an image. | |
| 518 | The host trial above additionally requires equality with the actual trusted local | |
| 519 | neutral build and observes its process before confirming a known-good host slot. | |
| 427 | 520 | |
| 428 | 521 | ## Candidate integrity and interruption |
| 429 | 522 | |
| @@ -451,24 +544,40 @@ treat marker existence alone as permission to install or boot it. | ||
| 451 | 544 | The separate image-stage/inspection path still starts from its supplied root. |
| 452 | 545 | Its `trustStatePersisted: false` result means it does not use the metadata store's |
| 453 | 546 | cross-invocation floors. The caller must supply its trusted release floor each |
| 454 | time. Neither path is a complete anti-rollback coordinator. Trusted clock | |
| 455 | acquisition and protection against privileged clock/storage rollback remain | |
| 456 | open. Control-loop freshness still uses its separate monotonic clock. | |
| 547 | time. The host coordinator enforces its own retained floors. Trusted clock | |
| 548 | acquisition and protection against privileged clock/storage rollback remain open. | |
| 549 | Control-loop freshness still uses its separate monotonic clock. | |
| 457 | 550 | |
| 458 | 551 | 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 | |
| 462 | confirmation and rollback without lowering security floors. Then add | |
| 463 | the board bootloader/install protocol and measured power-loss, full-medium, | |
| 552 | signed-image preparation, owned candidates and observed host slot health are | |
| 553 | available above. Complete authenticated key maintenance/repair and the full | |
| 554 | revocation/storage-fault campaigns. Add the board image/build attestation, | |
| 555 | bootloader/install protocol and measured power-loss, full-medium, | |
| 464 | 556 | storage-latency and recovery campaigns. Local privileged filesystem tampering |
| 465 | 557 | is outside this host module's protection; it is not secure boot or hardware |
| 466 | 558 | anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.github.io/specification/latest/). |
| 467 | 559 | |
| 468 | Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes. | |
| 469 | They cover real role signatures/rotation, expiration, compatibility, supplied | |
| 560 | Tests use generated throwaway Ed25519 keys. Staging cases use clearly synthetic | |
| 561 | payload bytes; host trials use actual built neutral kernels and a deliberately | |
| 562 | compiled wrong-session stop variant. Cases cover real role signatures/rotation, | |
| 563 | expiration, compatibility, supplied | |
| 470 | 564 | release floors, tampering, bounded inputs, incomplete candidates, enrollment |
| 471 | 565 | readback, competing enrollment processes, an actual host write-limit failure and |
| 472 | 566 | native/portable CLI behavior. They establish host behavior, not actual firmware |
| 473 | 567 | installation, board boot recovery or commissioning. The diagnostic supervisor's |
| 474 | 568 | update/rollback check remains blocked and `commissioningReady` stays false. |
| 569 | ||
| 570 | The copied host-slot CLI campaign uses actual signed kernel bytes and retains | |
| 571 | 17 maintenance operations, an interrupted trial and six additional process-cut | |
| 572 | states. Exact-ID abandonment follows metadata recovery; fresh rollback uses a | |
| 573 | different owned boot/session. A missing retained image is rejected without | |
| 574 | reconstructing it from the candidate archive. An older format-2 binary rejects | |
| 575 | format 3 before changing either database. Separate source-policy cases reject a | |
| 576 | wrong signed revision and dirty firmware before runtime input, retaining the | |
| 577 | accepted release floor through recovery. Process cuts and host filesystem tests | |
| 578 | do not establish electrical power-loss behavior. | |
| 579 | ||
| 580 | An actual CMake output-overflow case also stops before runtime start, retains | |
| 581 | release floor 9 through recovery, then observes a fresh healthy trial after the | |
| 582 | private build fault is removed. Build tools use native bounded process capture | |
| 583 | with the existing 120-second command deadline and 2 MB per-stream limits. | |
| @@ -20,9 +20,14 @@ sensors, estimation, controls and authority work in the linked roadmap. | ||
| 20 | 20 | |
| 21 | 21 | ## Run the native host harness |
| 22 | 22 | |
| 23 | Requires Bun, CMake >= 3.20, a C++17 compiler and CTest on macOS or Linux. There are no fetched SDK | |
| 23 | Requires Bun, CMake >= 3.20, a C++17 compiler, CTest and the POSIX `mkfifo` utility on macOS or Linux. There are no fetched SDK | |
| 24 | 24 | inputs for host builds. From the checkout: |
| 25 | 25 | |
| 26 | Each build/tool command has a 120-second deadline and a 2 MB limit per output | |
| 27 | stream. Native Stacks process capture drains stdout and stderr concurrently and | |
| 28 | cleans up the owned process group on POSIX before reporting failure. This also | |
| 29 | applies to SDK inspection and Pico 2 builds; it does not install firmware. | |
| 30 | ||
| 26 | 31 | ```sh |
| 27 | 32 | ./buddy runtime:build |
| 28 | 33 | ./buddy runtime:bench --ticks 1000 |
| @@ -42,10 +47,12 @@ The same fixed-buffer UART state machine used by Pico is also exercised through | ||
| 42 | 47 | for fragmentation, resynchronization, corruption, RX/TX budgets and pending transmit buffers. |
| 43 | 48 | |
| 44 | 49 | 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). | |
| 50 | `bootId` and `sessionId`, and the harness checks boot identity on every immutable response. | |
| 51 | The report's `cleanProcessExit` requires an actual clean process exit. Fault experiments | |
| 52 | can finish with a clean OS exit and retain their diagnostic failure. The update coordinator | |
| 53 | adds [current signed-image checks and a fixed healthy host trial](runtime-updates.md#owned-host-slots-and-observed-health). | |
| 54 | Board boot entropy and physical timing still require measurement. See the | |
| 55 | [host startup contract](runtime-protocol.md#host-hal-test-channel). | |
| 49 | 56 | |
| 50 | 57 | Build a standalone CLI for the current platform: |
| 51 | 58 | |
| @@ -0,0 +1,18 @@ | ||
| 1 | # Explicit Stacks CLI source bridge | |
| 2 | ||
| 3 | `process.ts` is a byte-for-byte copy from Stacks commit | |
| 4 | [3c52a79013df2e8026e4b364dfb9589eaf11375c](https://github.com/stacksjs/stacks/blob/3c52a79013df2e8026e4b364dfb9589eaf11375c/storage/framework/core/cli/src/process.ts), | |
| 5 | under the upstream [MIT licence](https://github.com/stacksjs/stacks/blob/3c52a79013df2e8026e4b364dfb9589eaf11375c/LICENSE.md). | |
| 6 | SHA-256: `33112ca2c6f9e576ec758d94108473ac522e17d9958067de0a9a932b5e5d4174`. | |
| 7 | ||
| 8 | Drone's installed `@stacksjs/cli` 0.75.221 does not expose this module. No | |
| 9 | installed package, dependency range or lockfile is changed. Replace this explicit | |
| 10 | bridge with `@stacksjs/cli/process` after a packaged release is qualified here. | |
| 11 | The source contribution is tracked in Stacks PRs #2970 and #2972; their main and | |
| 12 | CI states are retained separately in the implementation evidence. | |
| 13 | ||
| 14 | The app facade retains the existing `runBoundedHostProcess` name. Git inspection, | |
| 15 | parity tooling, portable qualification and local build commands use the native | |
| 16 | finite process implementation. Callers retain their own trust, path, output and | |
| 17 | time policies. Process cleanup does not establish signed-image authority, board | |
| 18 | health, hardware installation or physical commissioning. | |
| @@ -1,4 +1,4 @@ | ||
| 1 | export interface ParityProcessOptions { | |
| 1 | export interface BoundedProcessOptions { | |
| 2 | 2 | cwd?: string |
| 3 | 3 | env?: Record<string, string | undefined> |
| 4 | 4 | timeoutMs: number |
| @@ -7,27 +7,38 @@ export interface ParityProcessOptions { | ||
| 7 | 7 | label: string |
| 8 | 8 | } |
| 9 | 9 | |
| 10 | export interface BoundedProcessResult { | |
| 11 | exitCode: number | |
| 12 | stdout: string | |
| 13 | stderr: string | |
| 14 | } | |
| 15 | ||
| 10 | 16 | /** Owns a finite command, its output readers and, on POSIX, its process group. */ |
| 11 | export async function runParityProcess(command: string[], options: ParityProcessOptions) { | |
| 12 | if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 60000) throw new Error('Invalid parity process timeout') | |
| 17 | export async function runBoundedProcess(command: string[], options: BoundedProcessOptions): Promise<BoundedProcessResult> { | |
| 18 | if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 120000) throw new Error('Invalid bounded process timeout') | |
| 13 | 19 | for (const limit of [options.stdoutBytes, options.stderrBytes]) { |
| 14 | if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid parity process output limit') | |
| 20 | if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid bounded process output limit') | |
| 15 | 21 | } |
| 16 | 22 | const ownsGroup = process.platform !== 'win32' |
| 17 | 23 | const child = Bun.spawn(command, { cwd: options.cwd, env: options.env ?? process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: ownsGroup }) |
| 18 | 24 | const readers = [child.stdout.getReader(), child.stderr.getReader()] |
| 19 | 25 | const cancellations: Promise<unknown>[] = [] |
| 20 | let failure: Error | undefined, stopped = false | |
| 26 | let failure: unknown, failed = false, stopped = false | |
| 27 | let failureReason = 'failed' | |
| 28 | const cleanupFailures: unknown[] = [] | |
| 29 | function retainCleanup(error: unknown) { | |
| 30 | if ((error as NodeJS.ErrnoException)?.code !== 'ESRCH') cleanupFailures.push(error) | |
| 31 | } | |
| 21 | 32 | function terminate() { |
| 22 | if (ownsGroup) { try { process.kill(-child.pid, 'SIGKILL') } catch { /* The group may already be gone. */ } } | |
| 23 | try { child.kill('SIGKILL') } catch { /* The direct child may already have exited. */ } | |
| 33 | if (ownsGroup) { try { process.kill(-child.pid, 'SIGKILL') } catch (error) { retainCleanup(error) } } | |
| 34 | if (child.exitCode === null) { try { child.kill('SIGKILL') } catch (error) { retainCleanup(error) } } | |
| 24 | 35 | } |
| 25 | function stop(error: Error) { | |
| 26 | failure ??= error | |
| 36 | function stop(error: unknown, reason = 'failed') { | |
| 37 | if (!failed) { failure = error; failed = true; failureReason = reason } | |
| 27 | 38 | if (stopped) return |
| 28 | 39 | stopped = true |
| 29 | 40 | terminate() |
| 30 | for (const reader of readers) cancellations.push(reader.cancel().catch(() => {})) | |
| 41 | for (const reader of readers) cancellations.push(reader.cancel().catch(error => { cleanupFailures.push(error) })) | |
| 31 | 42 | } |
| 32 | 43 | async function consume(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number, label: string) { |
| 33 | 44 | const chunks: Uint8Array[] = [] |
| @@ -36,25 +47,34 @@ export async function runParityProcess(command: string[], options: ParityProcess | ||
| 36 | 47 | while (true) { |
| 37 | 48 | const { done, value } = await reader.read() |
| 38 | 49 | if (done) return Buffer.concat(chunks, bytes).toString('utf8') |
| 39 | if (value.byteLength > limit - bytes) throw new Error(`${options.label} ${label} exceeds byte limit`) | |
| 50 | if (value.byteLength > limit - bytes) { | |
| 51 | const error = new Error(`${options.label} ${label} exceeds byte limit`) | |
| 52 | stop(error, `${label} exceeds byte limit`) | |
| 53 | throw error | |
| 54 | } | |
| 40 | 55 | bytes += value.byteLength |
| 41 | 56 | chunks.push(value) |
| 42 | 57 | } |
| 43 | 58 | } |
| 44 | catch (error) { stop(error instanceof Error ? error : new Error(String(error))); throw error } | |
| 59 | catch (error) { stop(error, `${label} read failed`); throw error } | |
| 45 | 60 | } |
| 46 | const timer = setTimeout(() => stop(new Error(`${options.label} timed out`)), options.timeoutMs) | |
| 61 | const timer = setTimeout(() => stop(new Error(`${options.label} timed out`), 'timed out'), options.timeoutMs) | |
| 47 | 62 | const stdout = consume(readers[0]!, options.stdoutBytes, 'stdout'), stderr = consume(readers[1]!, options.stderrBytes, 'stderr') |
| 48 | 63 | try { |
| 49 | 64 | const [exitCode, output, diagnostic] = await Promise.all([child.exited, stdout, stderr]) |
| 50 | if (failure) throw failure | |
| 65 | if (failed) throw failure | |
| 51 | 66 | return { exitCode, stdout: output, stderr: diagnostic } |
| 52 | 67 | } |
| 68 | catch (error) { stop(error); throw error } | |
| 53 | 69 | finally { |
| 54 | 70 | clearTimeout(timer) |
| 55 | 71 | terminate() |
| 56 | 72 | if (child.exitCode === null) stop(new Error(`${options.label} interrupted`)) |
| 57 | 73 | await Promise.allSettled([child.exited, stdout, stderr, ...cancellations]) |
| 58 | for (const reader of readers) reader.releaseLock() | |
| 74 | for (const reader of readers) { | |
| 75 | try { reader.releaseLock() } | |
| 76 | catch (error) { cleanupFailures.push(error) } | |
| 77 | } | |
| 78 | if (cleanupFailures.length) throw new AggregateError(failed ? [failure, ...cleanupFailures] : cleanupFailures, `${options.label} ${failed ? `${failureReason}; ` : ''}process cleanup failed`) | |
| 59 | 79 | } |
| 60 | 80 | } |
| @@ -64,6 +64,7 @@ export async function runRuntimeBench(options: { project?: string, ticks?: numbe | ||
| 64 | 64 | return { |
| 65 | 65 | evidence: 'host-harness', boardCandidate: 'pico2', protocolVersion: 1, fault, |
| 66 | 66 | bootId: String(harness.bootId), |
| 67 | sessionId: String(status.sessionId), cleanProcessExit: true, | |
| 67 | 68 | ticks: status.ticks, virtualElapsedUs: time, hostElapsedMs: Math.round((performance.now() - started) * 1000) / 1000, |
| 68 | 69 | stateBytes: status.stateBytes, maxExecutionUs: status.maxExecutionUs, deadlineMisses: status.deadlineMisses, |
| 69 | 70 | neutralOnly: true, leaseActive: false, physicalFlightEnabled: false, |
| @@ -1,16 +1,12 @@ | ||
| 1 | 1 | import { resolve, join } from 'node:path' |
| 2 | import { runBoundedHostProcess } from '../../host/process' | |
| 2 | 3 | |
| 3 | 4 | export const picoSdkRevision = '079c6f39023649b154152db30f1d781e884879bc' |
| 4 | 5 | export interface RuntimeBuildOptions { project?: string, target?: 'host' | 'pico2', sdk?: string, toolchain?: string, watchdogTest?: boolean, sensors?: boolean } |
| 5 | 6 | async function run(args: string[], cwd: string, env = process.env) { |
| 6 | const child = Bun.spawn(args, { cwd, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 7 | const timeout = setTimeout(() => child.kill('SIGKILL'), 120000) | |
| 8 | try { | |
| 9 | const [output, errors, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]) | |
| 10 | if (code !== 0) throw new Error(`${args[0]} exited ${code}:\n${(output + errors).slice(-8000)}`) | |
| 11 | return output.trim() | |
| 12 | } | |
| 13 | finally { clearTimeout(timeout) } | |
| 7 | const result = await runBoundedHostProcess(args, { cwd, env, timeoutMs: 120000, stdoutBytes: 2_000_000, stderrBytes: 2_000_000, label: `Runtime build ${args[0]}` }) | |
| 8 | if (result.exitCode !== 0) throw new Error(`${args[0]} exited ${result.exitCode}:\n${(result.stdout + result.stderr).slice(-8000)}`, { cause: result }) | |
| 9 | return result.stdout.trim() | |
| 14 | 10 | } |
| 15 | 11 | /** Local tools only; no shell interpolation, SDK download, firmware flashing or device access. */ |
| 16 | 12 | export async function buildRuntime(options: RuntimeBuildOptions = {}) { |
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | import { decodeRuntimeFrame } from './protocol' |
| 2 | 2 | import { randomBytes } from 'node:crypto' |
| 3 | import { spawnSync } from 'node:child_process' | |
| 4 | import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, rmSync, writeSync } from 'node:fs' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | import { join } from 'node:path' | |
| 3 | 7 | |
| 4 | 8 | function freshBootId() { |
| 5 | 9 | let value = 0n |
| @@ -8,23 +12,130 @@ function freshBootId() { | ||
| 8 | 12 | } |
| 9 | 13 | export interface RuntimeHarnessExit { exitCode: number, signal: string | number | null } |
| 10 | 14 | |
| 15 | /** Own both descriptors before unlinking the private FIFO. No buffered async writer survives a deadline. */ | |
| 16 | function runtimeInputPipe() { | |
| 17 | const root = mkdtempSync(join(tmpdir(), 'drone-runtime-input-')), path = join(root, 'stdin') | |
| 18 | let staged = -1, reader = -1, writer = -1 | |
| 19 | try { | |
| 20 | const result = spawnSync('mkfifo', ['-m', '600', path], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 1000 }) | |
| 21 | if (result.error) throw result.error | |
| 22 | if (result.status !== 0) throw new Error('Runtime input pipe creation failed', { cause: { exitCode: result.status, signal: result.signal, stderr: result.stderr } }) | |
| 23 | const before = lstatSync(path) | |
| 24 | if (!before.isFIFO() || before.isSymbolicLink() || before.uid !== process.getuid?.() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600) throw new Error('Invalid runtime input pipe') | |
| 25 | staged = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) | |
| 26 | writer = openSync(path, constants.O_WRONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) | |
| 27 | reader = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW) | |
| 28 | for (const fd of [staged, writer, reader]) { | |
| 29 | const stat = fstatSync(fd) | |
| 30 | if (!stat.isFIFO() || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Runtime input pipe changed while opening') | |
| 31 | } | |
| 32 | closeSync(staged); staged = -1 | |
| 33 | rmSync(root, { recursive: true }) | |
| 34 | return { reader, writer } | |
| 35 | } | |
| 36 | catch (error) { | |
| 37 | const failures = [error] | |
| 38 | for (const fd of [staged, reader, writer]) if (fd >= 0) { | |
| 39 | try { closeSync(fd) } | |
| 40 | catch (cleanup) { failures.push(cleanup) } | |
| 41 | } | |
| 42 | try { rmSync(root, { recursive: true, force: true }) } | |
| 43 | catch (cleanup) { failures.push(cleanup) } | |
| 44 | if (failures.length > 1) throw new AggregateError(failures, 'Runtime input pipe creation and cleanup failed') | |
| 45 | throw error | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 11 | 49 | /** ASCII stepping exists only in the host HAL. Board UART carries raw 64-byte frames. */ |
| 12 | 50 | export function openRuntimeHarness(artifact: string) { |
| 13 | 51 | const bootId = freshBootId() |
| 14 | const child = Bun.spawn([artifact, '--stdio', '--boot-id', bootId.toString(16).padStart(16, '0')], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }) | |
| 52 | const input = runtimeInputPipe() | |
| 53 | let child: Bun.Subprocess<number, 'pipe', 'pipe'> | |
| 54 | try { child = Bun.spawn([artifact, '--stdio', '--boot-id', bootId.toString(16).padStart(16, '0')], { stdin: input.reader, stdout: 'pipe', stderr: 'pipe' }) } | |
| 55 | catch (error) { | |
| 56 | const failures = [error] | |
| 57 | for (const fd of [input.reader, input.writer]) { | |
| 58 | try { closeSync(fd) } | |
| 59 | catch (cleanup) { failures.push(cleanup) } | |
| 60 | } | |
| 61 | if (failures.length > 1) throw new AggregateError(failures, 'Native harness spawn and input cleanup failed') | |
| 62 | throw error | |
| 63 | } | |
| 64 | try { closeSync(input.reader) } | |
| 65 | catch (error) { | |
| 66 | child.kill('SIGKILL') | |
| 67 | try { closeSync(input.writer) } | |
| 68 | catch (cleanup) { throw new AggregateError([error, cleanup], 'Native harness reader close and input cleanup failed') } | |
| 69 | throw error | |
| 70 | } | |
| 71 | let inputFd = input.writer | |
| 15 | 72 | const reader = child.stdout.getReader() |
| 73 | const errors = child.stderr.getReader() | |
| 16 | 74 | let pending = '' |
| 17 | 75 | let active = false |
| 18 | 76 | let closed = false |
| 19 | 77 | let failed = false |
| 20 | 78 | let closing: Promise<Readonly<RuntimeHarnessExit>> | undefined |
| 79 | let activeCompletion = Promise.resolve() | |
| 80 | let stderrFinished = false | |
| 81 | const inputFailures: unknown[] = [] | |
| 82 | function stopInput() { | |
| 83 | if (inputFd < 0) return | |
| 84 | const fd = inputFd; inputFd = -1 | |
| 85 | try { closeSync(fd) } | |
| 86 | catch (error) { inputFailures.push(error) } | |
| 87 | } | |
| 88 | const exited = child.exited.then(exitCode => ({ ok: true, value: Object.freeze({ exitCode, signal: child.signalCode ?? null }) } as const), error => ({ ok: false, error } as const)) | |
| 89 | const stderrCompletion = (async () => { | |
| 90 | try { | |
| 91 | while (true) { | |
| 92 | const { value, done } = await errors.read() | |
| 93 | if (done) return | |
| 94 | if (value.length) throw new Error('Native harness produced stderr') | |
| 95 | } | |
| 96 | } | |
| 97 | finally { | |
| 98 | try { await errors.cancel() } | |
| 99 | finally { errors.releaseLock() } | |
| 100 | } | |
| 101 | })().then(() => { stderrFinished = true; return { ok: true } as const }, error => { | |
| 102 | stderrFinished = true | |
| 103 | failed = true | |
| 104 | child.kill('SIGKILL') | |
| 105 | return { ok: false, error } as const | |
| 106 | }) | |
| 21 | 107 | async function exchange(line: string) { |
| 22 | 108 | if (closed || failed || active || line.length > 150 || line.includes('\n')) throw new Error('Invalid, failed or concurrent harness request') |
| 23 | 109 | active = true |
| 24 | const timeout = setTimeout(() => child.kill('SIGKILL'), 2000) | |
| 25 | try { | |
| 26 | child.stdin.write(`${line}\n`) | |
| 27 | await child.stdin.flush() | |
| 110 | let finish!: () => void | |
| 111 | activeCompletion = new Promise<void>(resolve => { finish = resolve }) | |
| 112 | let timeout: ReturnType<typeof setTimeout>, deadlineHit = false | |
| 113 | let responseCancellation: Promise<{ ok: true } | { ok: false, error: unknown }> | undefined | |
| 114 | const deadlineError = new Error('Native harness response deadline exceeded') | |
| 115 | const deadline = new Promise<{ ok: false, error: Error }>(resolve => { | |
| 116 | timeout = setTimeout(() => { | |
| 117 | deadlineHit = true; failed = true | |
| 118 | child.kill('SIGKILL'); stopInput() | |
| 119 | resolve({ ok: false, error: deadlineError }) | |
| 120 | responseCancellation = reader.cancel().then(() => ({ ok: true } as const), error => ({ ok: false, error } as const)) | |
| 121 | }, 2000) | |
| 122 | }) | |
| 123 | const completion = (async () => { | |
| 124 | const bytes = Buffer.from(`${line}\n`) | |
| 125 | let offset = 0 | |
| 126 | while (offset < bytes.length) { | |
| 127 | if (inputFd < 0 || failed) throw new Error('Native harness input closed before delivery') | |
| 128 | try { | |
| 129 | const written = writeSync(inputFd, bytes, offset, bytes.length - offset) | |
| 130 | if (written <= 0) throw new Error('Native harness input made no progress') | |
| 131 | offset += written | |
| 132 | } | |
| 133 | catch (error) { | |
| 134 | const code = (error as NodeJS.ErrnoException).code | |
| 135 | if (code !== 'EAGAIN' && code !== 'EWOULDBLOCK') throw error | |
| 136 | await Bun.sleep(1) | |
| 137 | } | |
| 138 | } | |
| 28 | 139 | while (!pending.includes('\n')) { |
| 29 | 140 | const { value, done } = await reader.read() |
| 30 | 141 | if (done) throw new Error('Native harness exited without a response') |
| @@ -38,9 +149,27 @@ export function openRuntimeHarness(artifact: string) { | ||
| 38 | 149 | const frame = decodeRuntimeFrame(Uint8Array.fromHex(hex)) |
| 39 | 150 | if (frame.kind !== 'status' || frame.bootId !== bootId) throw new Error('Native harness boot identity rejected') |
| 40 | 151 | return Object.freeze(frame) |
| 152 | })().then(value => ({ ok: true, value } as const), error => ({ ok: false, error } as const)) | |
| 153 | try { | |
| 154 | const outcome = await Promise.race([completion, deadline]) | |
| 155 | if (deadlineHit) { | |
| 156 | const stopped = await completion | |
| 157 | const cancellation = await responseCancellation | |
| 158 | const failures: unknown[] = [deadlineError] | |
| 159 | if (!stopped.ok && stopped.error !== deadlineError) failures.push(stopped.error) | |
| 160 | if (cancellation && !cancellation.ok) failures.push(cancellation.error) | |
| 161 | if (failures.length > 1) throw new AggregateError(failures, 'Native harness response deadline exceeded') | |
| 162 | throw deadlineError | |
| 163 | } | |
| 164 | if (!outcome.ok) throw outcome.error | |
| 165 | return outcome.value | |
| 166 | } | |
| 167 | catch (error) { failed = true; child.kill('SIGKILL'); stopInput(); throw error } | |
| 168 | finally { | |
| 169 | active = false; clearTimeout(timeout!) | |
| 170 | if (responseCancellation) await responseCancellation | |
| 171 | finish() | |
| 41 | 172 | } |
| 42 | catch (error) { failed = true; child.kill('SIGKILL'); throw error } | |
| 43 | finally { active = false; clearTimeout(timeout) } | |
| 44 | 173 | } |
| 45 | 174 | return { |
| 46 | 175 | get bootId() { return bootId }, |
| @@ -50,20 +179,60 @@ export function openRuntimeHarness(artifact: string) { | ||
| 50 | 179 | closed = true |
| 51 | 180 | if (active) child.kill('SIGKILL') |
| 52 | 181 | 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) | |
| 182 | const failures: unknown[] = [] | |
| 183 | const retain = (error: unknown) => { if (!failures.includes(error)) failures.push(error) } | |
| 184 | let timeout: ReturnType<typeof setTimeout>, deadlineHit = false | |
| 185 | const deadline = new Promise<never>((_, reject) => { | |
| 186 | timeout = setTimeout(() => { | |
| 187 | failed = true | |
| 188 | deadlineHit = true | |
| 189 | child.kill('SIGKILL'); stopInput() | |
| 190 | reject(new Error('Native harness close deadline exceeded')) | |
| 191 | }, 1000) | |
| 192 | }) | |
| 193 | const completion = (async () => { | |
| 194 | await activeCompletion | |
| 195 | stopInput() | |
| 196 | const exit = await exited | |
| 197 | if (!exit.ok) throw exit.error | |
| 198 | if (inputFailures.length) throw inputFailures[0] | |
| 199 | const stderr = await stderrCompletion | |
| 200 | if (!stderr.ok) throw stderr.error | |
| 201 | const tail = await reader.read() | |
| 202 | if (pending.length || !tail.done) throw new Error('Native harness produced unexpected output while closing') | |
| 203 | return exit.value | |
| 204 | })().then(value => ({ ok: true, value } as const), error => ({ ok: false, error } as const)) | |
| 205 | let result: Readonly<RuntimeHarnessExit> | undefined | |
| 57 | 206 | try { |
| 58 | const exitCode = await child.exited | |
| 59 | if (inputFailed) throw inputError | |
| 60 | return Object.freeze({ exitCode, signal: child.signalCode ?? null }) | |
| 207 | const outcome = await Promise.race([completion, deadline]) | |
| 208 | if (outcome.ok) result = outcome.value | |
| 209 | else retain(outcome.error) | |
| 61 | 210 | } |
| 211 | catch (error) { retain(error) } | |
| 62 | 212 | finally { |
| 63 | clearTimeout(timeout) | |
| 213 | clearTimeout(timeout!) | |
| 214 | if (failures.length) { failed = true; child.kill('SIGKILL'); stopInput() } | |
| 64 | 215 | try { await reader.cancel() } |
| 65 | finally { reader.releaseLock() } | |
| 216 | catch (error) { retain(error) } | |
| 217 | if (!stderrFinished) { | |
| 218 | try { await errors.cancel() } | |
| 219 | catch (error) { retain(error) } | |
| 220 | } | |
| 221 | await activeCompletion | |
| 222 | const outcome = await completion | |
| 223 | if (!outcome.ok) retain(outcome.error) | |
| 224 | const stderr = await stderrCompletion | |
| 225 | if (!stderr.ok) retain(stderr.error) | |
| 226 | const exit = await exited | |
| 227 | if (!exit.ok) retain(exit.error) | |
| 228 | for (const error of inputFailures) retain(error) | |
| 229 | try { reader.releaseLock() } | |
| 230 | catch (error) { retain(error) } | |
| 66 | 231 | } |
| 232 | if (failures.length > 1) throw new AggregateError(failures, deadlineHit ? 'Native harness close deadline exceeded; cleanup failed' : 'Native harness close and cleanup failed') | |
| 233 | if (failures.length) throw failures[0] | |
| 234 | if (!result) throw new Error('Native harness close result unavailable') | |
| 235 | return result | |
| 67 | 236 | })() |
| 68 | 237 | return closing |
| 69 | 238 | }, |
| @@ -19,6 +19,12 @@ export const storeEnrollmentFlags = { | ||
| 19 | 19 | '--enrollment-sha256 <hex>': 'Independently retained enrollment record SHA-256', |
| 20 | 20 | } as const |
| 21 | 21 | export const storeReferenceFlags = { ...storeEnrollmentFlags, '--store-sha256 <hex>': 'Independently retained update-store identity SHA-256' } as const |
| 22 | export const hostTrialFlags = { | |
| 23 | ...storeReferenceFlags, | |
| 24 | '--repository <path>': 'Bounded offline signed-image repository', | |
| 25 | '--trusted-system-clock-source <name>': 'Audit label for the independently managed system clock, not an attestation', | |
| 26 | '--project <path>': 'Explicit trusted clean local firmware source checkout matching the signed revision', | |
| 27 | } as const | |
| 22 | 28 | |
| 23 | 29 | export function storeCommandEnrollment(options: { enrollment?: unknown, enrollmentSha256?: unknown }) { |
| 24 | 30 | if (typeof options.enrollment !== 'string' || typeof options.enrollmentSha256 !== 'string') throw new Error('Explicit enrollment and independent enrollment pin required') |
| @@ -28,6 +34,10 @@ export function storeCommandReference(options: { enrollment?: unknown, enrollmen | ||
| 28 | 34 | if (typeof options.storeSha256 !== 'string') throw new Error('Explicit independent store pin required') |
| 29 | 35 | return { ...storeCommandEnrollment(options), directory, storeSha256: options.storeSha256 } |
| 30 | 36 | } |
| 37 | export function hostTrialCommandOptions(options: { enrollment?: unknown, enrollmentSha256?: unknown, storeSha256?: unknown, repository?: unknown, trustedSystemClockSource?: unknown, project?: unknown }, directory: string) { | |
| 38 | if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string' || typeof options.project !== 'string') throw new Error('Explicit repository, independent system-clock source and trusted project required') | |
| 39 | return { ...storeCommandReference(options, directory), repository: options.repository, project: options.project, clock: { source: options.trustedSystemClockSource, read: () => new Date() } } | |
| 40 | } | |
| 31 | 41 | export async function storeCommandFailure() { |
| 32 | 42 | process.exitCode = 1 |
| 33 | 43 | try { await printErrorAsync('Runtime update store unavailable: invalid trust, clock, state or storage failure') } |
| @@ -0,0 +1,38 @@ | ||
| 1 | import { commandFrame, decodeRuntimeFrame, encodeRuntimeFrame } from '../runtime/protocol' | |
| 2 | ||
| 3 | interface HostEvidenceInput { kind: 'tx' | 'rx', bytes: Uint8Array } | |
| 4 | ||
| 5 | /** Validate retained host-only protocol evidence. This cannot confirm a new slot or attest a process exit. */ | |
| 6 | export function verifyRuntimeHostTranscript(inputs: ReadonlyArray<HostEvidenceInput>, identity: { bootId: string, sessionId: string }) { | |
| 7 | const boot = BigInt(identity.bootId), session = BigInt(identity.sessionId) | |
| 8 | let index = 0, ticks = 1, time = 1000, expiry = 0, commandSequence = 1, stateBytes = 0 | |
| 9 | function pair(request: string, boundSession: bigint) { | |
| 10 | const tx = inputs[index++], rx = inputs[index++] | |
| 11 | if (!tx || !rx || tx.kind !== 'tx' || rx.kind !== 'rx' || !Buffer.from(tx.bytes).equals(Buffer.from(request))) throw new Error('Host health transcript request rejected') | |
| 12 | let status | |
| 13 | try { status = decodeRuntimeFrame(rx.bytes) } | |
| 14 | catch (cause) { throw new Error('Host health transcript status rejected', { cause }) } | |
| 15 | const lease = Math.max(expiry - time, 0) | |
| 16 | if (!stateBytes) stateBytes = status.stateBytes | |
| 17 | if (status.kind !== 'status' || status.sequence !== index / 2 || status.bootId !== boot || status.sessionId !== boundSession | |
| 18 | || status.ticks !== ticks || status.sampleTimeUs !== BigInt(time) || status.sample !== ticks - 1000 | |
| 19 | || status.leaseUs !== lease || status.flags !== (67 | (lease ? 4 : 0)) || status.deadlineMisses !== 0 | |
| 20 | || status.maxExecutionUs !== 0 || stateBytes < 1 || stateBytes > 128 || status.stateBytes !== stateBytes) throw new Error('Host health transcript identity, progress or health rejected') | |
| 21 | } | |
| 22 | function frame(kind: 'hello' | 'lease' | 'stop') { | |
| 23 | if (kind === 'lease') expiry = time + 100000 | |
| 24 | if (kind === 'stop') expiry = 0 | |
| 25 | const command = commandFrame(kind, commandSequence++, kind === 'hello' ? 0n : boot, session, kind === 'hello' ? 0n : BigInt(time), kind === 'lease' ? 100000 : 0) | |
| 26 | pair(`frame ${Buffer.from(encodeRuntimeFrame(command)).toString('hex')}`, session) | |
| 27 | } | |
| 28 | pair('tick 1000', 0n) | |
| 29 | frame('hello') | |
| 30 | frame('lease') | |
| 31 | for (let step = 1; step < 100; step++) { | |
| 32 | ticks++; time += 1000 | |
| 33 | pair(`tick ${time}`, session) | |
| 34 | if (step % 20 === 0) frame('lease') | |
| 35 | } | |
| 36 | frame('stop') | |
| 37 | if (index !== inputs.length || index !== 214) throw new Error('Host health transcript length rejected') | |
| 38 | } | |
| @@ -0,0 +1,95 @@ | ||
| 1 | import type { RuntimeFrame } from '../runtime/protocol' | |
| 2 | import type { RuntimeFirmwareStage } from './staging' | |
| 3 | import { realpathSync } from 'node:fs' | |
| 4 | import { join, relative, resolve, sep } from 'node:path' | |
| 5 | import { runRuntimeBench } from '../runtime/bench' | |
| 6 | import { decodeRuntimeFrame } from '../runtime/protocol' | |
| 7 | import { directory, imageLimit, regularFile, sha256, syncDirectory, writeExclusive } from './files' | |
| 8 | import { runBoundedHostProcess } from '../../host/process' | |
| 9 | ||
| 10 | /** Read-only preflight, including build paths whose final directories do not exist yet. */ | |
| 11 | export function runtimeHostBuildProject(project: string, excluded: readonly string[]) { | |
| 12 | const root = realpathSync(resolve(project)) | |
| 13 | directory(root) | |
| 14 | const parts = ['storage', 'runtime-build', 'host'] | |
| 15 | let output = root | |
| 16 | for (let index = 0; index < parts.length; index++) { | |
| 17 | const next = join(output, parts[index]!) | |
| 18 | try { directory(next); output = realpathSync(next) } | |
| 19 | catch (error) { | |
| 20 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error | |
| 21 | output = join(output, ...parts.slice(index)); break | |
| 22 | } | |
| 23 | } | |
| 24 | for (const reference of excluded) { | |
| 25 | const protectedRoot = realpathSync(reference) | |
| 26 | const child = relative(protectedRoot, output), ancestor = relative(output, protectedRoot) | |
| 27 | if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!ancestor.startsWith(`..${sep}`) && ancestor !== '..')) throw new Error('Host build output must be separate from repository and protected trust state') | |
| 28 | } | |
| 29 | return root | |
| 30 | } | |
| 31 | ||
| 32 | async function sourceCommand(project: string, args: string[]) { | |
| 33 | const result = await runBoundedHostProcess(['git', '-C', project, ...args], { cwd: project, timeoutMs: 10000, stdoutBytes: 65536, stderrBytes: 65536, label: 'Host trial source inspection' }) | |
| 34 | if (result.exitCode !== 0 || result.stderr) throw new Error('Host trial source inspection failed', { cause: { exitCode: result.exitCode, stderr: result.stderr } }) | |
| 35 | return result.stdout.trim() | |
| 36 | } | |
| 37 | ||
| 38 | /** Internal coordinator operation: execute only bytes matching the actual owned neutral host build. */ | |
| 39 | export async function observeRuntimeHostImage(options: { | |
| 40 | project: string | |
| 41 | description: Readonly<RuntimeFirmwareStage> | |
| 42 | candidateImage: string | |
| 43 | slotDirectory: string | |
| 44 | protectedDirectories: readonly string[] | |
| 45 | onReady: () => Promise<void> | |
| 46 | onRequest: (line: string) => Promise<void> | |
| 47 | onStatus: (status: Readonly<RuntimeFrame>) => Promise<void> | |
| 48 | }) { | |
| 49 | const project = runtimeHostBuildProject(options.project, options.protectedDirectories), description = options.description | |
| 50 | if (description.platform !== 'host-neutral-bench-v1') throw new Error('Only owned neutral host images support host trials') | |
| 51 | const image = regularFile(options.candidateImage, imageLimit) | |
| 52 | if (image.length !== description.length || sha256(image) !== description.sha256) throw new Error('Host trial candidate image changed') | |
| 53 | async function checkSource() { | |
| 54 | if (await sourceCommand(project, ['rev-parse', '--show-toplevel']) !== project | |
| 55 | || await sourceCommand(project, ['rev-parse', 'HEAD']) !== description.sourceRevision | |
| 56 | || await sourceCommand(project, ['status', '--porcelain=v1', '--untracked-files=all', '--', 'firmware/runtime'])) throw new Error('Host trial requires the signed clean firmware source revision') | |
| 57 | } | |
| 58 | await checkSource() | |
| 59 | let artifact: string | undefined | |
| 60 | let started = 0 | |
| 61 | let stop: RuntimeFrame | undefined, last: Readonly<RuntimeFrame> | undefined | |
| 62 | const report = await runRuntimeBench({ project, ticks: 100, fault: 'none', | |
| 63 | async onBuild(path) { | |
| 64 | await checkSource() | |
| 65 | const built = regularFile(path, imageLimit) | |
| 66 | if (!built.equals(image)) throw new Error('Signed host image does not match the actual owned neutral build') | |
| 67 | artifact = path | |
| 68 | directory(options.slotDirectory) | |
| 69 | writeExclusive(join(options.slotDirectory, 'image.bin'), image) | |
| 70 | syncDirectory(options.slotDirectory) | |
| 71 | if (!regularFile(join(options.slotDirectory, 'image.bin'), imageLimit).equals(built)) throw new Error('Host slot image readback failed') | |
| 72 | await options.onReady() | |
| 73 | if (!regularFile(path, imageLimit).equals(image)) throw new Error('Owned host executable changed before launch') | |
| 74 | started = performance.now() | |
| 75 | }, | |
| 76 | async onRequest(line) { | |
| 77 | await options.onRequest(line) | |
| 78 | if (line.startsWith('frame ')) { | |
| 79 | const command = decodeRuntimeFrame(Uint8Array.fromHex(line.slice(6))) | |
| 80 | if (command.kind === 'stop') stop = command | |
| 81 | } | |
| 82 | }, | |
| 83 | async onStatus(status) { | |
| 84 | await options.onStatus(status) | |
| 85 | if (last && status.sequence <= last.sequence) throw new Error('Host trial status sequence rejected') | |
| 86 | last = status | |
| 87 | }, | |
| 88 | }) | |
| 89 | if (!artifact || performance.now() - started > 30000 || !regularFile(artifact, imageLimit).equals(image) | |
| 90 | || !regularFile(join(options.slotDirectory, 'image.bin'), imageLimit).equals(image)) throw new Error('Host trial image or duration rejected after execution') | |
| 91 | if (!report.cleanProcessExit || !stop || !last || last.bootId !== stop.bootId || last.sessionId !== stop.sessionId || last.sessionId === 0n | |
| 92 | || last.flags !== 67 || last.leaseUs !== 0 || last.deadlineMisses !== 0 || last.ticks !== 100 || last.sampleTimeUs !== 100000n | |
| 93 | || last.stateBytes < 1 || last.stateBytes > 128 || last.maxExecutionUs > 500) throw new Error('Host trial stop response rejected') | |
| 94 | return report | |
| 95 | } | |
| @@ -13,6 +13,9 @@ import type { RuntimeTrustSummary } from './enrollment' | ||
| 13 | 13 | import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files' |
| 14 | 14 | import { object, validateMetadata } from './metadata' |
| 15 | 15 | import { admitSignedRuntime, RuntimeManifestRejected } from './manifest' |
| 16 | import { observeRuntimeHostImage, runtimeHostBuildProject } from './host-runtime' | |
| 17 | import { verifyRuntimeHostTranscript } from './host-evidence' | |
| 18 | import { encodeRuntimeFrame } from '../runtime/protocol' | |
| 16 | 19 | |
| 17 | 20 | const applicationId = 0x44525453 |
| 18 | 21 | const maximumGeneration = 10000 |
| @@ -29,6 +32,12 @@ const candidateSchema = [ | ||
| 29 | 32 | '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 | 33 | "CREATE UNIQUE INDEX candidate_active ON candidates ((1)) WHERE phase IN ('reserved', 'prepared')", |
| 31 | 34 | ] |
| 35 | const slotSchema = [ | |
| 36 | "CREATE TABLE host_slots (id TEXT PRIMARY KEY CHECK(length(id) = 32), candidate_id TEXT NOT NULL, attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN ('selected', 'running', 'confirmed', 'failed', 'abandoned')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), checked_ms INTEGER CHECK(checked_ms BETWEEN 1 AND 253402300799000), completed_ms INTEGER CHECK(completed_ms BETWEEN 1 AND 253402300799000), health BLOB CHECK(length(health) BETWEEN 1 AND 16384), FOREIGN KEY(candidate_id) REFERENCES candidates(id), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation), CHECK((phase = 'confirmed') = (completed_ms IS NOT NULL AND health IS NOT NULL)), CHECK(phase != 'confirmed' OR (checked_ms IS NOT NULL AND completed_ms = checked_ms))) STRICT", | |
| 37 | "CREATE UNIQUE INDEX host_trial_active ON host_slots ((1)) WHERE phase IN ('selected', 'running')", | |
| 38 | 'CREATE TABLE host_roles (id INTEGER PRIMARY KEY CHECK(id = 1), installed_id TEXT, good_id TEXT, FOREIGN KEY(installed_id) REFERENCES host_slots(id), FOREIGN KEY(good_id) REFERENCES host_slots(id)) STRICT', | |
| 39 | "CREATE TABLE host_inputs (slot_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 255), kind TEXT NOT NULL CHECK(kind IN ('tx', 'rx')), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND 160), PRIMARY KEY(slot_id, ordinal), FOREIGN KEY(slot_id) REFERENCES host_slots(id)) STRICT", | |
| 40 | ] | |
| 32 | 41 | |
| 33 | 42 | interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string } |
| 34 | 43 | interface StoreReference extends TrustReference { directory: string, storeSha256: string } |
| @@ -77,7 +86,8 @@ function openDatabase(path: string, expectedSchema: string[], allowCandidates = | ||
| 77 | 86 | // Authenticate the protocol format before any configuration that could |
| 78 | 87 | // rewrite a foreign database's journal/header format. |
| 79 | 88 | const version = databaseVersion(db) |
| 80 | checkSchema(db, allowCandidates && version === 2 ? [...expectedSchema, ...candidateSchema] : expectedSchema, allowCandidates && version === 2 ? 2 : 1) | |
| 89 | const additions = allowCandidates && version === 3 ? [...candidateSchema, ...slotSchema] : allowCandidates && version === 2 ? candidateSchema : [] | |
| 90 | checkSchema(db, [...expectedSchema, ...additions], allowCandidates && (version === 2 || version === 3) ? version : 1) | |
| 81 | 91 | if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format') |
| 82 | 92 | configure(db) |
| 83 | 93 | return db |
| @@ -133,7 +143,8 @@ function summary(db: Database, binding: Binding, storeSha256: string): Readonly< | ||
| 133 | 143 | for (const row of [anchor, ...metadata]) checkedMetadataBytes(row) |
| 134 | 144 | const counts = db.query<{ attempts: number, metadata: number, inputs: number }, []>('SELECT (SELECT count(*) FROM attempts) AS attempts, (SELECT count(*) FROM metadata) AS metadata, (SELECT count(*) FROM inputs) AS inputs').get()! |
| 135 | 145 | if (counts.attempts > 256 || counts.metadata !== current.generation + 1 || counts.metadata > maximumGeneration + 1 || counts.inputs > 16384) throw new Error('Update-store history budget exceeded or metadata history is incomplete') |
| 136 | const floors = db.query<{ clock: number, release: number, generation: number }, []>('SELECT coalesce((SELECT max(started_ms) FROM attempts), 0) AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation').get()! | |
| 146 | const clockQuery = databaseVersion(db) === 3 ? 'max(coalesce((SELECT max(started_ms) FROM attempts), 0), coalesce((SELECT max(checked_ms) FROM host_slots), 0))' : 'coalesce((SELECT max(started_ms) FROM attempts), 0)' | |
| 147 | const floors = db.query<{ clock: number, release: number, generation: number }, []>(`SELECT ${clockQuery} AS clock, coalesce((SELECT max(accepted_release) FROM attempts), 0) AS release, (SELECT max(generation) FROM metadata) AS generation`).get()! | |
| 137 | 148 | if (current.clock_floor_ms !== floors.clock || current.release_floor < floors.release || current.generation !== floors.generation || db.query<{ count: number }, []>("SELECT count(*) AS count FROM attempts WHERE phase IN ('pending', 'maintenance')").get()!.count > 1) throw new Error('Inconsistent update-store floors or attempts') |
| 138 | 149 | const attempt = db.query<Attempt, []>('SELECT * FROM attempts ORDER BY rowid DESC LIMIT 1').get() |
| 139 | 150 | return Object.freeze({ schema: 'drone-runtime-store-status-v1', storeId: binding.storeId, storeSha256, enrollmentSha256: binding.enrollmentSha256, generation: current.generation, epoch: metadata.find(row => row.role === 'root')!.epoch, releaseFloor: current.release_floor, clockFloorMs: current.clock_floor_ms, retainedMetadataRecords: counts.metadata, metadata: Object.freeze(metadata.map(row => Object.freeze({ role: row.role, version: row.version, sha256: row.sha256 }))), attempt: attempt ? Object.freeze({ id: attempt.id, phase: attempt.phase, clockSource: attempt.clock_source, startedMs: attempt.started_ms }) : null, physicalDeploymentAllowed: false }) |
| @@ -153,7 +164,7 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b | ||
| 153 | 164 | let writer: Database | undefined, db: Database | undefined |
| 154 | 165 | try { |
| 155 | 166 | for (const name of readdirSync(path)) { |
| 156 | if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates'].includes(name)) throw new Error('Unexpected update-store entry') | |
| 167 | if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates', 'host-slots'].includes(name)) throw new Error('Unexpected update-store entry') | |
| 157 | 168 | if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true) |
| 158 | 169 | } |
| 159 | 170 | writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema]) |
| @@ -162,13 +173,18 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b | ||
| 162 | 173 | if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity') |
| 163 | 174 | db = openDatabase(join(path, 'state.sqlite'), schema, true) |
| 164 | 175 | if (readdirSync(path).includes('candidates')) { |
| 165 | if (databaseVersion(db) !== 2) throw new Error('Candidate files require an owned format2 store') | |
| 176 | if (databaseVersion(db) < 2) throw new Error('Candidate files require an owned format2 or later store') | |
| 166 | 177 | protectedDirectory(join(path, 'candidates')) |
| 167 | 178 | } |
| 168 | 179 | const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get() |
| 169 | 180 | 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') |
| 170 | 181 | summary(db, binding, options.storeSha256) |
| 171 | 182 | for (const row of candidateRows(db)) ownedDescription(db, row, trusted) |
| 183 | if (readdirSync(path).includes('host-slots')) { | |
| 184 | if (databaseVersion(db) !== 3) throw new Error('Host slot files require an owned format3 store') | |
| 185 | protectedDirectory(join(path, 'host-slots')) | |
| 186 | } | |
| 187 | if (databaseVersion(db) === 3) slotsSummary(db, binding, trusted, path, options.storeSha256) | |
| 172 | 188 | return await operation(db, binding, trusted, path) |
| 173 | 189 | } |
| 174 | 190 | finally { |
| @@ -217,7 +233,7 @@ export interface RuntimeUpdateClock { | ||
| 217 | 233 | } |
| 218 | 234 | |
| 219 | 235 | export interface RuntimeUpdateProgress { |
| 220 | phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired' | |
| 236 | phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired' | 'slot-selected' | 'slot-image-written' | 'runtime-started' | 'runtime-input-committed' | 'final-clock-committed' | 'runtime-confirmed' | |
| 221 | 237 | ordinal: number |
| 222 | 238 | } |
| 223 | 239 | type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void> |
| @@ -411,7 +427,7 @@ export interface RuntimeFirmwareCandidate { | ||
| 411 | 427 | } |
| 412 | 428 | export interface RuntimeFirmwareCandidates { |
| 413 | 429 | schema: 'drone-runtime-candidates-v1' |
| 414 | stateFormat: 1 | 2 | |
| 430 | stateFormat: 1 | 2 | 3 | |
| 415 | 431 | store: Readonly<RuntimeTrustStoreSummary> |
| 416 | 432 | retainedCandidates: number |
| 417 | 433 | /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */ |
| @@ -495,7 +511,7 @@ function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<Run | ||
| 495 | 511 | candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false }) |
| 496 | 512 | } |
| 497 | 513 | } |
| 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 }) | |
| 514 | return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false }) | |
| 499 | 515 | } |
| 500 | 516 | |
| 501 | 517 | /** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */ |
| @@ -548,6 +564,231 @@ export async function reserveRuntimeFirmwareCandidate(options: RefreshOptions): | ||
| 548 | 564 | return Object.freeze({ ...result.value, store: result.store }) |
| 549 | 565 | } |
| 550 | 566 | |
| 567 | interface HostSlotHealth { bootId: string, sessionId: string, ticks: number, cleanProcessExit: true, transcriptItems: number, transcriptSha256: string } | |
| 568 | interface HostSlotRow { id: string, candidate_id: string, attempt_id: string, generation: number, phase: 'selected' | 'running' | 'confirmed' | 'failed' | 'abandoned', description: Uint8Array, sha256: string, checked_ms: number | null, completed_ms: number | null, health: Uint8Array | null } | |
| 569 | export interface RuntimeHostSlot { | |
| 570 | id: string | |
| 571 | candidateId: string | |
| 572 | phase: HostSlotRow['phase'] | |
| 573 | clockCheckedMs: number | null | |
| 574 | confirmedClockMs: number | null | |
| 575 | description: Readonly<RuntimeFirmwareStage> | |
| 576 | health: Readonly<HostSlotHealth> | null | |
| 577 | integrity: 'recorded-bytes-match' | 'mismatch' | 'unavailable' | |
| 578 | currentTrustValidated: false | |
| 579 | } | |
| 580 | export interface RuntimeFirmwareSlots { | |
| 581 | schema: 'drone-runtime-host-slots-v1' | |
| 582 | stateFormat: 1 | 2 | 3 | |
| 583 | store: Readonly<RuntimeTrustStoreSummary> | |
| 584 | installed: Readonly<RuntimeHostSlot> | null | |
| 585 | knownGood: Readonly<RuntimeHostSlot> | null | |
| 586 | trial: Readonly<RuntimeHostSlot> | null | |
| 587 | retainedSlots: number | |
| 588 | physicalDeploymentAllowed: false | |
| 589 | } | |
| 590 | function hostRows(db: Database) { | |
| 591 | if (databaseVersion(db) < 3) return [] | |
| 592 | const rows = db.query<HostSlotRow, []>('SELECT * FROM host_slots ORDER BY rowid LIMIT 129').all() | |
| 593 | if (rows.length > 128) throw new Error('Host slot history budget exceeded') | |
| 594 | return rows | |
| 595 | } | |
| 596 | function hostTranscript(db: Database, id: string) { | |
| 597 | const inputs = db.query<{ ordinal: number, kind: 'tx' | 'rx', bytes: Uint8Array }, [string]>('SELECT ordinal, kind, bytes FROM host_inputs WHERE slot_id = ? ORDER BY ordinal LIMIT 257').all(id) | |
| 598 | if (inputs.length > 256 || inputs.some((item, index) => item.ordinal !== index || item.kind !== (index % 2 ? 'rx' : 'tx') || (item.kind === 'rx' && item.bytes.length !== 64))) throw new Error('Invalid host health input history') | |
| 599 | return { inputs, digest: sha256(Buffer.from(JSON.stringify(inputs.map(item => ({ kind: item.kind, hex: Buffer.from(item.bytes).toString('hex') }))))) } | |
| 600 | } | |
| 601 | function slotHealth(db: Database, row: HostSlotRow): Readonly<HostSlotHealth> | null { | |
| 602 | const attempt = db.query<Attempt, [string]>('SELECT * FROM attempts WHERE id = ?').get(row.attempt_id) | |
| 603 | if (!attempt || (row.checked_ms !== null && row.checked_ms < attempt.started_ms)) throw new Error('Invalid retained host clock observation') | |
| 604 | if (row.phase !== 'confirmed') { | |
| 605 | if (row.health !== null || row.completed_ms !== null) throw new Error('Incomplete host slot has a health confirmation') | |
| 606 | hostTranscript(db, row.id) | |
| 607 | return null | |
| 608 | } | |
| 609 | if (!row.health || row.completed_ms === null || row.checked_ms !== row.completed_ms) throw new Error('Confirmed host slot lacks health or clock evidence') | |
| 610 | const value = object(JSON.parse(Buffer.from(row.health).toString('utf8'))) | |
| 611 | const keys = ['bootId', 'sessionId', 'ticks', 'cleanProcessExit', 'transcriptItems', 'transcriptSha256'] | |
| 612 | const transcript = hostTranscript(db, row.id) | |
| 613 | if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key)) | |
| 614 | || ![value.bootId, value.sessionId].every(id => typeof id === 'string' && /^[1-9][0-9]{0,19}$/.test(id) && BigInt(id) <= 0xFFFFFFFFFFFFFFFFn) | |
| 615 | || value.ticks !== 100 || value.cleanProcessExit !== true || value.transcriptItems !== 214 || transcript.inputs.length !== value.transcriptItems || value.transcriptSha256 !== transcript.digest) throw new Error('Invalid retained host health confirmation') | |
| 616 | verifyRuntimeHostTranscript(transcript.inputs, { bootId: value.bootId as string, sessionId: value.sessionId as string }) | |
| 617 | return Object.freeze(value as unknown as HostSlotHealth) | |
| 618 | } | |
| 619 | function slotsSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string, storeSha256: string): Readonly<RuntimeFirmwareSlots> { | |
| 620 | const rows = hostRows(db), slots = new Map<string, Readonly<RuntimeHostSlot>>() | |
| 621 | for (const row of rows) { | |
| 622 | const description = ownedDescription(db, { ...row, phase: 'prepared' }, trusted) | |
| 623 | const candidate = candidateRows(db).find(item => item.id === row.candidate_id) | |
| 624 | if (!candidate) throw new Error('Host slot candidate is missing') | |
| 625 | const original = ownedDescription(db, candidate, trusted) | |
| 626 | if (description.platform !== 'host-neutral-bench-v1' || original.sha256 !== description.sha256 || original.length !== description.length || original.sourceRevision !== description.sourceRevision || original.releaseSequence !== description.releaseSequence) throw new Error('Host slot does not match its owned candidate') | |
| 627 | let integrity: RuntimeHostSlot['integrity'] = 'unavailable' | |
| 628 | try { | |
| 629 | const directory = join(path, 'host-slots', row.id), file = join(directory, 'image.bin') | |
| 630 | protectedDirectory(directory) | |
| 631 | if (readdirSync(directory).length !== 1) integrity = 'mismatch' | |
| 632 | else { | |
| 633 | protectedFile(file, imageLimit) | |
| 634 | const image = regularFile(file, imageLimit) | |
| 635 | integrity = image.length === description.length && sha256(image) === description.sha256 ? 'recorded-bytes-match' : 'mismatch' | |
| 636 | } | |
| 637 | } | |
| 638 | catch { integrity = 'unavailable' } | |
| 639 | slots.set(row.id, Object.freeze({ id: row.id, candidateId: row.candidate_id, phase: row.phase, clockCheckedMs: row.checked_ms, confirmedClockMs: row.completed_ms, description, health: slotHealth(db, row), integrity, currentTrustValidated: false })) | |
| 640 | } | |
| 641 | let installed: Readonly<RuntimeHostSlot> | null = null, knownGood: Readonly<RuntimeHostSlot> | null = null | |
| 642 | if (databaseVersion(db) === 3) { | |
| 643 | const roles = db.query<{ id: number, installed_id: string | null, good_id: string | null }, []>('SELECT * FROM host_roles').all() | |
| 644 | if (roles.length !== 1 || roles[0]!.id !== 1) throw new Error('Invalid host slot roles') | |
| 645 | const role = roles[0]! | |
| 646 | installed = role.installed_id ? slots.get(role.installed_id) ?? null : null | |
| 647 | knownGood = role.good_id ? slots.get(role.good_id) ?? null : null | |
| 648 | if ((role.installed_id && !installed) || (role.good_id && (!knownGood || knownGood.phase !== 'confirmed')) || installed?.phase === 'selected') throw new Error('Invalid installed or known-good host role') | |
| 649 | } | |
| 650 | return Object.freeze({ schema: 'drone-runtime-host-slots-v1', stateFormat: databaseVersion(db) as 1 | 2 | 3, store: summary(db, binding, storeSha256), installed, knownGood, trial: rows.length ? slots.get(rows.at(-1)!.id)! : null, retainedSlots: rows.length, physicalDeploymentAllowed: false }) | |
| 651 | } | |
| 652 | ||
| 653 | /** Historical host role/evidence inspection. This never authorizes use under current trust. */ | |
| 654 | export async function inspectRuntimeFirmwareSlots(options: StoreReference): Promise<Readonly<RuntimeFirmwareSlots>> { | |
| 655 | return withStore(options, (db, binding, trusted, path) => slotsSummary(db, binding, trusted, path, options.storeSha256)) | |
| 656 | } | |
| 657 | ||
| 658 | /** Abandon only the exact interrupted host trial after metadata recovery. Retain every artifact and known-good role. */ | |
| 659 | export async function abandonRuntimeFirmwareTrial(options: StoreReference & { trialId: string }): Promise<Readonly<RuntimeFirmwareSlots>> { | |
| 660 | options = { ...options } | |
| 661 | if (typeof options.trialId !== 'string' || !/^[a-f0-9]{32}$/.test(options.trialId)) throw new Error('Exact interrupted host trial identity required') | |
| 662 | return withStore(options, (db, binding, trusted, path) => { | |
| 663 | const current = summary(db, binding, options.storeSha256) | |
| 664 | if (current.attempt?.phase === 'pending' || current.attempt?.phase === 'maintenance') throw new Error('Resolve metadata before abandoning a host trial') | |
| 665 | const row = hostRows(db).find(item => item.id === options.trialId && (item.phase === 'selected' || item.phase === 'running')) | |
| 666 | if (!row) throw new Error('Exact interrupted host trial identity required') | |
| 667 | const changed = db.query("UPDATE host_slots SET phase = 'abandoned' WHERE id = ? AND phase IN ('selected', 'running')").run(row.id) | |
| 668 | if (changed.changes !== 1) throw new Error('Host trial abandonment did not commit') | |
| 669 | return slotsSummary(db, binding, trusted, path, options.storeSha256) | |
| 670 | }) | |
| 671 | } | |
| 672 | ||
| 673 | /** Fresh signed selection and actual owned host health. No caller health receipt is accepted. */ | |
| 674 | export async function trialRuntimeFirmwareCandidate(options: RefreshOptions & { candidateId: string, project: string }): Promise<Readonly<RuntimeFirmwareSlots>> { | |
| 675 | options = { ...options } | |
| 676 | if (typeof options.candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(options.candidateId)) throw new Error('Exact owned candidate identity required') | |
| 677 | return runHostTrial(options, options.candidateId) | |
| 678 | } | |
| 679 | ||
| 680 | /** Rollback re-verifies current policy and observes a new actual health session for the retained good image. */ | |
| 681 | export async function rollbackRuntimeFirmwareSlot(options: RefreshOptions & { project: string }): Promise<Readonly<RuntimeFirmwareSlots>> { | |
| 682 | return runHostTrial(options) | |
| 683 | } | |
| 684 | ||
| 685 | async function runHostTrial(options: RefreshOptions & { project: string }, candidateId?: string): Promise<Readonly<RuntimeFirmwareSlots>> { | |
| 686 | options = { ...options } | |
| 687 | let rollbackSlotId: string | undefined | |
| 688 | if (typeof options.project !== 'string' || !options.project) throw new Error('Trusted local source project required') | |
| 689 | options = { ...options, project: runtimeHostBuildProject(options.project, [options.directory, options.enrollmentDirectory, options.repository]) } | |
| 690 | if (!options.clock || typeof options.clock.read !== 'function') throw new Error('An independently trusted update clock is required') | |
| 691 | const declaredClock = options.clock, readClock = declaredClock.read | |
| 692 | options = { ...options, clock: { source: declaredClock.source, read: () => readClock.call(declaredClock) } } | |
| 693 | const result = await refreshAndComplete(options, async selection => { | |
| 694 | const { db, binding, trusted, path, attempt } = selection | |
| 695 | if (rollbackSlotId) { | |
| 696 | const retained = slotsSummary(db, binding, trusted, path, options.storeSha256).knownGood | |
| 697 | if (!retained || retained.id !== rollbackSlotId || retained.integrity !== 'recorded-bytes-match') throw new Error('Retained known-good integrity failed before rollback') | |
| 698 | } | |
| 699 | const row = candidateRows(db).find(item => item.id === candidateId) | |
| 700 | if (!row || row.phase === 'reserved') throw new Error('A complete owned candidate is required for host trial') | |
| 701 | const description = candidateDescription(selection), original = ownedDescription(db, row, trusted) | |
| 702 | if (description.platform !== 'host-neutral-bench-v1' || original.sha256 !== description.sha256 || original.length !== description.length || original.sourceRevision !== description.sourceRevision || original.releaseSequence !== description.releaseSequence) throw new Error('Owned candidate is not the currently authorized host image') | |
| 703 | const candidatePath = join(path, 'candidates', row.id) | |
| 704 | if (candidateIntegrity(db, row.generation, candidatePath, original) !== 'recorded-bytes-match') throw new Error('Host trial candidate integrity failed') | |
| 705 | const image = regularFile(join(candidatePath, 'image.bin'), imageLimit) | |
| 706 | await selection.info.verify(Readable.from(image)) | |
| 707 | if (databaseVersion(db) === 2) { | |
| 708 | db.transaction(() => { | |
| 709 | for (const sql of slotSchema) db.exec(sql) | |
| 710 | db.exec('INSERT INTO host_roles VALUES(1, NULL, NULL); PRAGMA user_version=3') | |
| 711 | })() | |
| 712 | checkSchema(db, [...schema, ...candidateSchema, ...slotSchema], 3) | |
| 713 | } | |
| 714 | const id = randomBytes(16).toString('hex'), bytes = Buffer.from(`${JSON.stringify(description)}\n`) | |
| 715 | db.query("INSERT INTO host_slots VALUES(?, ?, ?, ?, 'selected', ?, ?, NULL, NULL, NULL)").run(id, row.id, attempt, state(db, binding).generation, bytes, sha256(bytes)) | |
| 716 | try { | |
| 717 | await options.onProgress?.(Object.freeze({ phase: 'slot-selected', ordinal: 0 })) | |
| 718 | const parent = join(path, 'host-slots') | |
| 719 | if (!readdirSync(path).includes('host-slots')) { mkdirSync(parent, { mode: 0o700 }); syncDirectory(path) } | |
| 720 | protectedDirectory(parent) | |
| 721 | const slotDirectory = join(parent, id) | |
| 722 | mkdirSync(slotDirectory, { mode: 0o700 }); syncDirectory(parent) | |
| 723 | let ordinal = 0 | |
| 724 | const append = (kind: 'tx' | 'rx', bytes: Buffer) => { | |
| 725 | if (ordinal >= 256 || bytes.length < 1 || bytes.length > 160) throw new Error('Host trial input budget exceeded') | |
| 726 | const committed = ordinal | |
| 727 | db.query('INSERT INTO host_inputs VALUES(?, ?, ?, ?)').run(id, ordinal, kind, bytes) | |
| 728 | ordinal++ | |
| 729 | return committed | |
| 730 | } | |
| 731 | const report = await observeRuntimeHostImage({ project: options.project, description, candidateImage: join(candidatePath, 'image.bin'), slotDirectory, protectedDirectories: [path, options.enrollmentDirectory, options.repository], | |
| 732 | async onReady() { | |
| 733 | await options.onProgress?.(Object.freeze({ phase: 'slot-image-written', ordinal: 0 })) | |
| 734 | db.transaction(() => { | |
| 735 | const changed = db.query("UPDATE host_slots SET phase = 'running' WHERE id = ? AND phase = 'selected'").run(id) | |
| 736 | if (changed.changes !== 1) throw new Error('Selected host slot changed before runtime launch') | |
| 737 | db.query('UPDATE host_roles SET installed_id = ? WHERE id = 1').run(id) | |
| 738 | })() | |
| 739 | await options.onProgress?.(Object.freeze({ phase: 'runtime-started', ordinal: 0 })) | |
| 740 | }, | |
| 741 | async onRequest(line) { const ordinal = append('tx', Buffer.from(line)); await options.onProgress?.(Object.freeze({ phase: 'runtime-input-committed', ordinal })) }, | |
| 742 | async onStatus(status) { const ordinal = append('rx', Buffer.from(encodeRuntimeFrame(status))); await options.onProgress?.(Object.freeze({ phase: 'runtime-input-committed', ordinal })) }, | |
| 743 | }) | |
| 744 | const clockStarted = performance.now(), completed = clockReading(options.clock, state(db, binding).clock_floor_ms) | |
| 745 | db.transaction(() => { | |
| 746 | const changed = db.query('UPDATE host_slots SET checked_ms = ? WHERE id = ?').run(completed.time.getTime(), id) | |
| 747 | if (changed.changes !== 1) throw new Error('Host clock observation lost its owned slot') | |
| 748 | db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(completed.time.getTime()) | |
| 749 | })() | |
| 750 | await options.onProgress?.(Object.freeze({ phase: 'final-clock-committed', ordinal: 0 })) | |
| 751 | const elapsed = performance.now() - clockStarted | |
| 752 | if (elapsed > 1000) throw new Error('Host confirmation clock sample became stale') | |
| 753 | for (const metadata of activeMetadata(db, state(db, binding).generation)) { | |
| 754 | const expires = object(object(JSON.parse(checkedMetadataBytes(metadata).toString('utf8'))).signed).expires | |
| 755 | if (typeof expires !== 'string' || !Number.isFinite(Date.parse(expires)) || completed.time.getTime() + Math.ceil(elapsed) >= Date.parse(expires)) throw new Error('Host trial metadata expired before confirmation') | |
| 756 | } | |
| 757 | const transcript = hostTranscript(db, id) | |
| 758 | const health: HostSlotHealth = { bootId: report.bootId, sessionId: report.sessionId, ticks: report.ticks, cleanProcessExit: true, transcriptItems: transcript.inputs.length, transcriptSha256: transcript.digest } | |
| 759 | if (health.transcriptItems !== 214) throw new Error('Host trial transcript is incomplete') | |
| 760 | verifyRuntimeHostTranscript(transcript.inputs, health) | |
| 761 | db.transaction(() => { | |
| 762 | const confirmed = db.query("UPDATE host_slots SET phase = 'confirmed', completed_ms = ?, health = ? WHERE id = ? AND phase = 'running'").run(completed.time.getTime(), Buffer.from(JSON.stringify(health)), id) | |
| 763 | if (confirmed.changes !== 1) throw new Error('Running host slot changed before confirmation') | |
| 764 | db.query('UPDATE host_roles SET good_id = ? WHERE id = 1').run(id) | |
| 765 | db.query('UPDATE store_state SET clock_floor_ms = ? WHERE id = 1').run(completed.time.getTime()) | |
| 766 | const retired = db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase = 'prepared'").run(row.id) | |
| 767 | if (retired.changes !== (row.phase === 'prepared' ? 1 : 0)) throw new Error('Owned candidate reservation changed before confirmation') | |
| 768 | })() | |
| 769 | await options.onProgress?.(Object.freeze({ phase: 'runtime-confirmed', ordinal: 0 })) | |
| 770 | return slotsSummary(db, binding, trusted, path, options.storeSha256) | |
| 771 | } | |
| 772 | catch (error) { | |
| 773 | try { db.query("UPDATE host_slots SET phase = 'failed' WHERE id = ? AND phase IN ('selected', 'running')").run(id) } | |
| 774 | catch (persistence) { throw new AggregateError([error, persistence], 'Host trial failed and its failure state could not be recorded') } | |
| 775 | throw error | |
| 776 | } | |
| 777 | }, db => { | |
| 778 | if (hostRows(db).some(row => row.phase === 'selected' || row.phase === 'running')) throw new Error('Interrupted host trial requires explicit recovery') | |
| 779 | if (hostRows(db).length >= 128) throw new Error('Host slot history budget reached') | |
| 780 | if (!candidateId) { | |
| 781 | if (databaseVersion(db) !== 3) throw new Error('No retained known-good host image for rollback') | |
| 782 | const role = db.query<{ good_id: string | null }, []>('SELECT good_id FROM host_roles WHERE id = 1').get() | |
| 783 | const previous = hostRows(db).find(row => row.id === role?.good_id && row.phase === 'confirmed') | |
| 784 | if (!previous) throw new Error('No retained known-good host image for rollback') | |
| 785 | candidateId = previous.candidate_id | |
| 786 | rollbackSlotId = previous.id | |
| 787 | } | |
| 788 | }) | |
| 789 | return Object.freeze({ ...result.value, store: result.store }) | |
| 790 | } | |
| 791 | ||
| 551 | 792 | function causedBy(error: unknown, marker: Error) { |
| 552 | 793 | const seen = new Set<unknown>() |
| 553 | 794 | for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) { |
| @@ -1,60 +1,2 @@ | ||
| 1 | export interface ParityProcessOptions { | |
| 2 | cwd?: string | |
| 3 | env?: Record<string, string | undefined> | |
| 4 | timeoutMs: number | |
| 5 | stdoutBytes: number | |
| 6 | stderrBytes: number | |
| 7 | label: string | |
| 8 | } | |
| 9 | ||
| 10 | /** Owns a finite command, its output readers and, on POSIX, its process group. */ | |
| 11 | export async function runParityProcess(command: string[], options: ParityProcessOptions) { | |
| 12 | if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 60000) throw new Error('Invalid parity process timeout') | |
| 13 | for (const limit of [options.stdoutBytes, options.stderrBytes]) { | |
| 14 | if (!Number.isSafeInteger(limit) || limit < 1 || limit > 2_000_000) throw new Error('Invalid parity process output limit') | |
| 15 | } | |
| 16 | const ownsGroup = process.platform !== 'win32' | |
| 17 | const child = Bun.spawn(command, { cwd: options.cwd, env: options.env ?? process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: ownsGroup }) | |
| 18 | const readers = [child.stdout.getReader(), child.stderr.getReader()] | |
| 19 | const cancellations: Promise<unknown>[] = [] | |
| 20 | let failure: Error | undefined, stopped = false | |
| 21 | function terminate() { | |
| 22 | if (ownsGroup) { try { process.kill(-child.pid, 'SIGKILL') } catch { /* The group may already be gone. */ } } | |
| 23 | try { child.kill('SIGKILL') } catch { /* The direct child may already have exited. */ } | |
| 24 | } | |
| 25 | function stop(error: Error) { | |
| 26 | failure ??= error | |
| 27 | if (stopped) return | |
| 28 | stopped = true | |
| 29 | terminate() | |
| 30 | for (const reader of readers) cancellations.push(reader.cancel().catch(() => {})) | |
| 31 | } | |
| 32 | async function consume(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number, label: string) { | |
| 33 | const chunks: Uint8Array[] = [] | |
| 34 | let bytes = 0 | |
| 35 | try { | |
| 36 | while (true) { | |
| 37 | const { done, value } = await reader.read() | |
| 38 | if (done) return Buffer.concat(chunks, bytes).toString('utf8') | |
| 39 | if (value.byteLength > limit - bytes) throw new Error(`${options.label} ${label} exceeds byte limit`) | |
| 40 | bytes += value.byteLength | |
| 41 | chunks.push(value) | |
| 42 | } | |
| 43 | } | |
| 44 | catch (error) { stop(error instanceof Error ? error : new Error(String(error))); throw error } | |
| 45 | } | |
| 46 | const timer = setTimeout(() => stop(new Error(`${options.label} timed out`)), options.timeoutMs) | |
| 47 | const stdout = consume(readers[0]!, options.stdoutBytes, 'stdout'), stderr = consume(readers[1]!, options.stderrBytes, 'stderr') | |
| 48 | try { | |
| 49 | const [exitCode, output, diagnostic] = await Promise.all([child.exited, stdout, stderr]) | |
| 50 | if (failure) throw failure | |
| 51 | return { exitCode, stdout: output, stderr: diagnostic } | |
| 52 | } | |
| 53 | finally { | |
| 54 | clearTimeout(timer) | |
| 55 | terminate() | |
| 56 | if (child.exitCode === null) stop(new Error(`${options.label} interrupted`)) | |
| 57 | await Promise.allSettled([child.exited, stdout, stderr, ...cancellations]) | |
| 58 | for (const reader of readers) reader.releaseLock() | |
| 59 | } | |
| 60 | } | |
| 1 | export { runBoundedHostProcess as runParityProcess } from '../resources/functions/host/process' | |
| 2 | export type { BoundedHostProcessOptions as ParityProcessOptions } from '../resources/functions/host/process' | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import assert from 'node:assert/strict' |
| 2 | import { setSystemTime } from 'bun:test' | |
| 2 | 3 | import { randomUUID } from 'node:crypto' |
| 3 | import { actor, as, seed, database, records, managed } from './managed-context' | |
| 4 | import { actor, as, seed, database, records, managed, status } from './managed-context' | |
| 4 | 5 | const purge = await import('../../../resources/functions/drone/public-safety/purge/service') |
| 5 | 6 | const { purgeAbilities, purgePolicy, purgeCoverage, purgeExclusions } = await import('../../../resources/functions/drone/public-safety/purge/types') |
| 6 | 7 | const { publicSafetyPurgeGate } = await import('../../../resources/functions/drone/public-safety/purge/policy') |
| @@ -11,17 +12,52 @@ const coordinator = await actor('purge-history-coordinator') | ||
| 11 | 12 | try { |
| 12 | 13 | process.env.PUBLIC_SAFETY_PURGE_ENABLED = 'true' |
| 13 | 14 | await as(coordinator.token.plainTextToken, async () => { |
| 14 | const key = 'purge-history', fixture = await seed(key, 60000) | |
| 15 | const capture = fixture.run.survey.captures[0]!, support = fixture.run.captures[0]!.support.findIndex(value => value === 1) | |
| 16 | const observation = await records.appendPublicSafetyObservation(key, { expectedVersion: 3, sourceDigest: fixture.source.digest, captureId: capture.id, modality: 'visible', pixel: { x: support % capture.width, y: Math.floor(support / capture.width) }, note: 'private-history-observation-fragment', uncertaintyNote: 'private-history-uncertainty-fragment' }) | |
| 17 | let version = 4, previousReviewId: number | null = null | |
| 18 | for (let count = 0; count < 137; count++) { | |
| 19 | const appended = await records.appendPublicSafetyReview(key, observation.observation.id, { expectedVersion: version, sourceDigest: fixture.source.digest, previousReviewId, disposition: 'unresolved', note: `private-history-review-fragment-${count}` }) | |
| 20 | version = appended.incident.version; previousReviewId = appended.review.id | |
| 21 | } | |
| 22 | await managed.createPublicSafetyManagedDataset(key, { ...fixture.body, expectedVersion: version }) | |
| 15 | const key = 'purge-history' | |
| 16 | // Controlled preparation removes an unrelated host-throughput precondition. | |
| 17 | // The installed Stacks testing root registers afterEach, so this standalone | |
| 18 | // child uses its underlying Bun clock API. Real timers remain unchanged. | |
| 19 | const fixture = await (async () => { | |
| 20 | const preparationAt = Date.now() | |
| 21 | const realNow = () => { | |
| 22 | setSystemTime() | |
| 23 | try { return Date.now() } | |
| 24 | finally { setSystemTime(preparationAt) } | |
| 25 | } | |
| 26 | setSystemTime(preparationAt) | |
| 27 | try { | |
| 28 | const prepared = await seed(key, 60000) | |
| 29 | const capture = prepared.run.survey.captures[0]!, support = prepared.run.captures[0]!.support.findIndex(value => value === 1) | |
| 30 | const observation = await records.appendPublicSafetyObservation(key, { expectedVersion: 3, sourceDigest: prepared.source.digest, captureId: capture.id, modality: 'visible', pixel: { x: support % capture.width, y: Math.floor(support / capture.width) }, note: 'private-history-observation-fragment', uncertaintyNote: 'private-history-uncertainty-fragment' }) | |
| 31 | let version = 4, previousReviewId: number | null = null | |
| 32 | for (let count = 0; count < 137; count++) { | |
| 33 | if (count === 136) { | |
| 34 | // Exercise the reproduced precondition even on a fast host. This | |
| 35 | // spends the existing expiry wait before the last setup write. | |
| 36 | const expires = Date.parse(prepared.config.retention.expiresAt) | |
| 37 | const remaining = expires - realNow() + 20 | |
| 38 | if (remaining > 0) await Bun.sleep(remaining) | |
| 39 | assert(realNow() >= expires, 'The final controlled setup review must cross real retention expiry') | |
| 40 | } | |
| 41 | const appended = await records.appendPublicSafetyReview(key, observation.observation.id, { expectedVersion: version, sourceDigest: prepared.source.digest, previousReviewId, disposition: 'unresolved', note: `private-history-review-fragment-${count}` }) | |
| 42 | version = appended.incident.version; previousReviewId = appended.review.id | |
| 43 | } | |
| 44 | await managed.createPublicSafetyManagedDataset(key, { ...prepared.body, expectedVersion: version }) | |
| 45 | const beforeReviews = await database.db.primary.selectFrom('public_safety_reviews').select('id').execute() | |
| 46 | const beforeAudits = await database.db.primary.selectFrom('public_safety_audits').select('id').execute() | |
| 47 | const incident = (await database.db.primary.selectFrom('public_safety_incidents').select('version').where('incident_key', '=', key).executeTakeFirst())! | |
| 48 | assert.equal(beforeReviews.length, 137) | |
| 49 | setSystemTime(Date.parse(prepared.config.retention.expiresAt)) | |
| 50 | await assert.rejects(() => records.appendPublicSafetyReview(key, observation.observation.id, { expectedVersion: incident.version, sourceDigest: prepared.source.digest, previousReviewId, disposition: 'unresolved', note: 'refused-at-exact-retention-boundary' }), status(404)) | |
| 51 | assert.deepEqual(await database.db.primary.selectFrom('public_safety_reviews').select('id').execute(), beforeReviews) | |
| 52 | assert.deepEqual(await database.db.primary.selectFrom('public_safety_audits').select('id').execute(), beforeAudits) | |
| 53 | return prepared | |
| 54 | } | |
| 55 | finally { setSystemTime() } | |
| 56 | })() | |
| 23 | 57 | const wait = Date.parse(fixture.config.retention.expiresAt) - Date.now() + 20 |
| 24 | 58 | if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait)) |
| 59 | assert(Date.now() > Date.parse(fixture.config.retention.expiresAt), 'Purge acceptance must use genuinely elapsed wall time after restoring the clock') | |
| 60 | console.log('PASS controlled setup crosses real60000ms expiry before its last review and refuses exact-expiry review before genuinely due purge') | |
| 25 | 61 | const preview = await purge.previewPublicSafetyPurge(key) |
| 26 | 62 | assert.equal(preview.counts.reviews, 137); assert(preview.counts.audits > 100) |
| 27 | 63 | const accepted = await purge.acceptPublicSafetyPurge(key, { expectedVersion: preview.incidentVersion, expectedRetentionVersion: preview.retentionVersion, policyVersion: purgePolicy, coverage: purgeCoverage, inventoryDigest: preview.inventoryDigest, exclusions: purgeExclusions, receiptRetentionDays: 2, requestId: randomUUID() }) |
| @@ -0,0 +1,37 @@ | ||
| 1 | // Linux-only host transport fixture. Observe a full FIFO without consuming input. | |
| 2 | #ifndef _GNU_SOURCE | |
| 3 | #define _GNU_SOURCE | |
| 4 | #endif | |
| 5 | #include "protocol.hpp" | |
| 6 | #include <cstdio> | |
| 7 | #include <cstdlib> | |
| 8 | #include <fcntl.h> | |
| 9 | #include <sys/ioctl.h> | |
| 10 | #include <unistd.h> | |
| 11 | ||
| 12 | int main(int argc, char** argv) { | |
| 13 | const auto page = sysconf(_SC_PAGESIZE); | |
| 14 | if (argc != 4 || page < 4096 || page > 65536) return 2; | |
| 15 | const int capacity = fcntl(STDIN_FILENO, F_SETPIPE_SZ, int(page)); | |
| 16 | if (capacity != page) return 2; | |
| 17 | drone::Frame status; | |
| 18 | status.kind = drone::Kind::status; | |
| 19 | status.boot = std::strtoull(argv[3], nullptr, 16); | |
| 20 | status.sequence = uint32_t(getpid()); | |
| 21 | status.state_bytes = uint32_t(capacity); | |
| 22 | int previous = 0; | |
| 23 | for (;;) { | |
| 24 | int queued = 0; | |
| 25 | if (ioctl(STDIN_FILENO, FIONREAD, &queued) != 0) return 3; | |
| 26 | if (queued > 0 && queued != previous) { | |
| 27 | previous = queued; | |
| 28 | status.sample = queued; | |
| 29 | uint8_t bytes[drone::frame_size]; | |
| 30 | drone::encode(status, bytes); | |
| 31 | for (auto byte : bytes) std::printf("%02x", byte); | |
| 32 | std::puts(""); | |
| 33 | std::fflush(stdout); | |
| 34 | } | |
| 35 | usleep(1000); | |
| 36 | } | |
| 37 | } | |
| @@ -0,0 +1,34 @@ | ||
| 1 | import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 2 | import { join } from 'node:path' | |
| 3 | ||
| 4 | /** Actual disposable source repo with explicit stop-reply or configure-output faults. */ | |
| 5 | export async function createHostSourceProject(root: string, options: { fault?: 'stop-session' | 'configure-output' } = {}) { | |
| 6 | const project = join(root, 'project') | |
| 7 | await mkdir(project, { mode: 0o700 }) | |
| 8 | await mkdir(join(project, 'firmware'), { mode: 0o700 }) | |
| 9 | await cp(join(import.meta.dir, '../../../firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true }) | |
| 10 | if (options.fault === 'stop-session') { | |
| 11 | const path = join(project, 'firmware/runtime/src/host.cpp'), source = await readFile(path, 'utf8') | |
| 12 | const original = 'drone::encode(runtime.status(now), bytes);' | |
| 13 | if (!source.includes(original)) throw new Error('Owned host source no longer matches the deliberate stop-session fixture') | |
| 14 | await writeFile(path, source.replace(original, 'auto status = runtime.status(now); if (std::strncmp(line, "frame 44520103", 14) == 0) ++status.session; drone::encode(status, bytes);')) | |
| 15 | } | |
| 16 | if (options.fault === 'configure-output') { | |
| 17 | const path = join(project, 'firmware/runtime/CMakeLists.txt') | |
| 18 | await writeFile(path, `${await readFile(path, 'utf8')}\nif(EXISTS "\${CMAKE_BINARY_DIR}/drone-output-overflow-fixture")\n string(REPEAT "fixture-only-output " 180000 DRONE_OVERFLOW_FIXTURE)\n message(STATUS "\${DRONE_OVERFLOW_FIXTURE}")\nendif()\n`) | |
| 19 | } | |
| 20 | async function git(args: string[]) { | |
| 21 | const child = Bun.spawn(['git', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'commit.gpgsign=false', '-c', 'user.name=Chris Breuer', '-c', 'user.email=chris@stacksjs.com', '-C', project, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 22 | const timer = setTimeout(() => child.kill('SIGKILL'), 10000) | |
| 23 | try { | |
| 24 | const [code, output, errors] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) | |
| 25 | if (code) throw new Error('Isolated host fixture Git command failed', { cause: { code, errors } }) | |
| 26 | return output.trim() | |
| 27 | } | |
| 28 | finally { clearTimeout(timer) } | |
| 29 | } | |
| 30 | await git(['init', '-b', 'codex/host-health-fixture']) | |
| 31 | await git(['add', 'firmware']) | |
| 32 | await git(['commit', '-m', 'test: prepare host health fixture']) | |
| 33 | return { project, sourceRevision: await git(['rev-parse', 'HEAD']) } | |
| 34 | } | |
| @@ -0,0 +1,101 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { spawnSync } from 'node:child_process' | |
| 3 | import { mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { buildRuntime } from '../../../resources/functions/drone/runtime/build' | |
| 6 | import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment' | |
| 7 | import { abandonRuntimeFirmwareTrial, createRuntimeTrustStore, inspectRuntimeFirmwareSlots, inspectRuntimeTrustStore, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, rollbackRuntimeFirmwareSlot, trialRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store' | |
| 8 | import { repository, target } from './repository' | |
| 9 | ||
| 10 | type Reference = Parameters<typeof inspectRuntimeTrustStore>[0] | |
| 11 | const clock = { source: 'independent-fixture-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 12 | interface Input { reference: Reference, source: string, project: string, candidateId: string, cut: string, systemClock?: boolean } | |
| 13 | async function worker(mode: string, file: string) { | |
| 14 | const input = JSON.parse(await readFile(file, 'utf8')) as Input | |
| 15 | if (mode === 'contend') { | |
| 16 | try { await inspectRuntimeTrustStore(input.reference); process.exitCode = 18 } | |
| 17 | catch (error) { process.exitCode = (error as NodeJS.ErrnoException).code === 'SQLITE_BUSY' ? 17 : 19 } | |
| 18 | return | |
| 19 | } | |
| 20 | if (mode !== 'hold') throw new Error('Unknown host trial worker mode') | |
| 21 | await trialRuntimeFirmwareCandidate({ ...input.reference, repository: input.source, project: input.project, candidateId: input.candidateId, clock: input.systemClock ? { source: 'fixture-system-clock', read: () => new Date() } : clock, async onProgress(event) { | |
| 22 | if (event.phase !== input.cut || event.ordinal !== (input.cut === 'runtime-input-committed' ? 51 : 0)) return | |
| 23 | console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal })) | |
| 24 | await Bun.stdin.text() | |
| 25 | } }) | |
| 26 | throw new Error('Host trial interruption worker unexpectedly completed') | |
| 27 | } | |
| 28 | ||
| 29 | /** Actual process cuts and exclusive writers. Retains generated repositories/state when the caller retains directory. */ | |
| 30 | export async function qualifyHostSlotInterruptions(directory: string, project: string) { | |
| 31 | await mkdir(directory, { mode: 0o700 }) | |
| 32 | const build = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer()) | |
| 33 | const git = spawnSync('git', ['-C', project, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10000 }) | |
| 34 | assert.equal(git.status, 0) | |
| 35 | const sourceRevision = git.stdout.trim(), results = [] | |
| 36 | for (const cut of ['slot-selected', 'slot-image-written', 'runtime-started', 'runtime-input-committed', 'final-clock-committed', 'runtime-confirmed']) { | |
| 37 | const root = join(directory, cut); await mkdir(root, { mode: 0o700 }) | |
| 38 | const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 39 | const enrollmentDirectory = join(root, 'enrollment') | |
| 40 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 41 | const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') }) | |
| 42 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 } | |
| 43 | const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 44 | const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id }) | |
| 45 | const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }), file = join(root, 'worker.json') | |
| 46 | await writeFile(file, JSON.stringify({ reference, source, project, cut, candidateId: next.candidate!.id } satisfies Input), { mode: 0o600 }) | |
| 47 | // These are library-process workers, not Buddy boot tests. Keep application preloads out. | |
| 48 | const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { cwd: root, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }) | |
| 49 | const timer = setTimeout(() => child.kill('SIGKILL'), 15000), reader = child.stdout.getReader() | |
| 50 | let stdout = '' | |
| 51 | try { | |
| 52 | while (!stdout.includes('\n')) { | |
| 53 | const chunk = await reader.read() | |
| 54 | if (chunk.done) throw new Error('Host trial worker exited before readiness') | |
| 55 | stdout += new TextDecoder().decode(chunk.value) | |
| 56 | if (stdout.length > 4096) throw new Error('Host trial worker output exceeded bounds') | |
| 57 | } | |
| 58 | const ready = JSON.parse(stdout.trim()); assert.equal(ready.ready, true); assert.equal(ready.phase, cut) | |
| 59 | const contender = spawnSync(process.execPath, [import.meta.path, 'contend', file], { cwd: root, encoding: 'utf8', timeout: 10000 }) | |
| 60 | await writeFile(join(root, 'contender.stdout'), contender.stdout ?? '') | |
| 61 | await writeFile(join(root, 'contender.stderr'), contender.stderr ?? '') | |
| 62 | await writeFile(join(root, 'contender.json'), JSON.stringify({ status: contender.status, signal: contender.signal, error: contender.error?.message })) | |
| 63 | assert.equal(contender.status, 17) | |
| 64 | child.kill('SIGKILL'); const exit = await child.exited | |
| 65 | assert.equal(child.signalCode, 'SIGKILL'); assert.notEqual(exit, 0) | |
| 66 | const stopped = await inspectRuntimeFirmwareSlots(reference), confirmed = cut === 'runtime-confirmed' | |
| 67 | assert.equal(stopped.store.attempt!.phase, 'pending') | |
| 68 | assert.equal(stopped.knownGood!.id, confirmed ? stopped.trial!.id : good.knownGood!.id) | |
| 69 | assert.equal(stopped.store.releaseFloor, 7) | |
| 70 | assert.deepEqual(await readFile(join(reference.directory, 'host-slots', good.knownGood!.id, 'image.bin')), Buffer.from(image)) | |
| 71 | await recoverRuntimeTrustStore(reference) | |
| 72 | const recovered = await inspectRuntimeFirmwareSlots(reference) | |
| 73 | assert.deepEqual(recovered.knownGood, stopped.knownGood) | |
| 74 | assert.equal(recovered.trial!.phase, stopped.trial!.phase) | |
| 75 | if (!confirmed) { | |
| 76 | await assert.rejects(rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project }), /explicit recovery/) | |
| 77 | await assert.rejects(abandonRuntimeFirmwareTrial({ ...reference, trialId: '0'.repeat(32) }), /Exact interrupted/) | |
| 78 | const abandoned = await abandonRuntimeFirmwareTrial({ ...reference, trialId: stopped.trial!.id }) | |
| 79 | assert.equal(abandoned.trial!.phase, 'abandoned') | |
| 80 | assert.deepEqual(abandoned.knownGood, good.knownGood) | |
| 81 | } | |
| 82 | const rollback = await rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project }) | |
| 83 | assert.equal(rollback.knownGood!.candidateId, stopped.knownGood!.candidateId) | |
| 84 | assert.notEqual(rollback.knownGood!.health!.bootId, stopped.knownGood!.health!.bootId) | |
| 85 | assert.notEqual(rollback.knownGood!.health!.sessionId, stopped.knownGood!.health!.sessionId) | |
| 86 | assert.equal(rollback.installed!.id, rollback.knownGood!.id) | |
| 87 | assert.equal(rollback.physicalDeploymentAllowed, false) | |
| 88 | results.push({ cut, exit, signal: child.signalCode, contender: contender.status, stoppedPhase: stopped.trial!.phase, confirmedBeforeCut: confirmed, recoveredWithoutPromotion: true, freshRollback: true }) | |
| 89 | } | |
| 90 | finally { | |
| 91 | clearTimeout(timer); child.kill('SIGKILL'); await child.exited | |
| 92 | await writeFile(join(root, 'worker.stdout'), stdout) | |
| 93 | await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text()) | |
| 94 | await reader.cancel(); reader.releaseLock() | |
| 95 | } | |
| 96 | } | |
| 97 | await writeFile(join(directory, 'results.json'), JSON.stringify(results, null, 2)) | |
| 98 | return results | |
| 99 | } | |
| 100 | ||
| 101 | if (import.meta.main) await worker(Bun.argv[2]!, Bun.argv[3]!) | |
| @@ -0,0 +1,114 @@ | ||
| 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, rename, writeFile } from 'node:fs/promises' | |
| 5 | import { join, resolve } from 'node:path' | |
| 6 | import { buildRuntime } from '../../../resources/functions/drone/runtime/build' | |
| 7 | import { runBoundedHostProcess } from '../../../resources/functions/host/process' | |
| 8 | import { qualifyHostSlotInterruptions } from './host-slot-interruptions' | |
| 9 | import { repository, target } from './repository' | |
| 10 | ||
| 11 | async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') } | |
| 12 | ||
| 13 | /** Copied CLI, actual signed host bytes, actual process interruption and retained six-cut campaign. */ | |
| 14 | export async function qualifyPortableHostSlots(directory: string, source: string, project: string, olderBinary?: string) { | |
| 15 | await mkdir(directory, { mode: 0o700 }) | |
| 16 | const binary = join(directory, 'drone'), sourceSha256 = await hash(source) | |
| 17 | await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700) | |
| 18 | assert.equal(await hash(binary), sourceSha256) | |
| 19 | const olderSha256 = olderBinary ? await hash(olderBinary) : null | |
| 20 | const built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer()) | |
| 21 | const revision = await runBoundedHostProcess(['git', '-C', project, 'rev-parse', 'HEAD'], { timeoutMs: 10000, stdoutBytes: 1024, stderrBytes: 4096, label: 'Qualification source revision' }) | |
| 22 | assert.equal(revision.exitCode, 0) | |
| 23 | const sourceRevision = revision.stdout.trim(), repo = join(directory, 'repository') | |
| 24 | const signed = await repository(repo, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 25 | const enrollment = join(directory, 'enrollment'), store = join(directory, 'store') | |
| 26 | const results: { command: string, binarySha256: string, exitCode: number, stdout: string, stderr: string }[] = [] | |
| 27 | async function run(args: string[], expected = 0, executable = binary) { | |
| 28 | const result = await runBoundedHostProcess([executable, ...args], { cwd: directory, timeoutMs: 30000, stdoutBytes: 2_000_000, stderrBytes: 65536, label: 'Portable host slot command' }) | |
| 29 | results.push({ command: args[0]!, binarySha256: executable === binary ? sourceSha256 : olderSha256!, ...result }) | |
| 30 | assert.equal(result.exitCode, expected) | |
| 31 | if (expected) { assert.equal(result.stdout, ''); assert.equal(result.stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') } | |
| 32 | else assert.equal(result.stderr, '') | |
| 33 | return result.stdout ? JSON.parse(result.stdout) : undefined | |
| 34 | } | |
| 35 | try { | |
| 36 | const enrolled = await run(['updates:enroll', '--trusted-root', signed.rootPath, '--output', enrollment, '--target', target, '--platform', 'host-neutral-bench-v1', '--root-sha256', signed.rootSha256, '--minimum-release-sequence', '7']) | |
| 37 | const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256] | |
| 38 | const created = await run(['updates:store-init', ...flags, '--output', store]) | |
| 39 | const pinned = [...flags, '--store-sha256', created.storeSha256] | |
| 40 | const current = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock'] | |
| 41 | const prepared = await run(['updates:reserve', store, ...pinned, ...current]) | |
| 42 | const first = await run(['updates:trial', store, ...pinned, ...current, '--project', project, '--candidate', prepared.candidate.id]) | |
| 43 | assert.equal(first.stateFormat, 3); assert.equal(first.knownGood.health.cleanProcessExit, true) | |
| 44 | assert.equal(first.knownGood.description.sha256, signed.imageHash); assert.equal(first.knownGood.currentTrustValidated, false) | |
| 45 | assert.equal(first.installed.id, first.knownGood.id); assert.equal(first.physicalDeploymentAllowed, false) | |
| 46 | assert.deepEqual(await run(['updates:slots', store, ...pinned]), first) | |
| 47 | if (olderBinary) { | |
| 48 | const before = [await hash(join(store, 'state.sqlite')), await hash(join(store, 'writer.sqlite'))] | |
| 49 | await run(['updates:store', store, ...pinned], 1, olderBinary) | |
| 50 | assert.deepEqual([await hash(join(store, 'state.sqlite')), await hash(join(store, 'writer.sqlite'))], before) | |
| 51 | assert.equal(await hash(olderBinary), olderSha256) | |
| 52 | } | |
| 53 | const rollback = await run(['updates:rollback', store, ...pinned, ...current, '--project', project]) | |
| 54 | assert.notEqual(rollback.knownGood.health.bootId, first.knownGood.health.bootId) | |
| 55 | assert.notEqual(rollback.knownGood.health.sessionId, first.knownGood.health.sessionId) | |
| 56 | assert.equal(rollback.knownGood.candidateId, first.knownGood.candidateId) | |
| 57 | const retainedImage = join(store, 'host-slots', rollback.knownGood.id, 'image.bin'), savedImage = join(directory, 'retained-good-image.bin') | |
| 58 | await rename(retainedImage, savedImage) | |
| 59 | await run(['updates:rollback', store, ...pinned, ...current, '--project', project], 1) | |
| 60 | const missing = await run(['updates:slots', store, ...pinned]) | |
| 61 | assert.equal(missing.knownGood.id, rollback.knownGood.id); assert.equal(missing.knownGood.integrity, 'mismatch') | |
| 62 | assert.equal(missing.store.releaseFloor, 7); assert.equal(missing.store.attempt.phase, 'maintenance') | |
| 63 | await run(['updates:recover', store, ...pinned]) | |
| 64 | await rename(savedImage, retainedImage) | |
| 65 | const next = await run(['updates:reserve', store, ...pinned, ...current]) | |
| 66 | const input = join(directory, 'interruption.json') | |
| 67 | await writeFile(input, JSON.stringify({ reference: { directory: store, enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, storeSha256: created.storeSha256 }, source: repo, project, candidateId: next.candidate.id, cut: 'slot-selected', systemClock: true }), { mode: 0o600 }) | |
| 68 | // Source-side worker owns the process cut. Every maintenance decision below goes through the copied CLI. | |
| 69 | const worker = Bun.spawn([process.execPath, join(import.meta.dir, 'host-slot-interruptions.ts'), 'hold', input], { cwd: directory, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', detached: true }) | |
| 70 | const reader = worker.stdout.getReader(), timer = setTimeout(() => worker.kill('SIGKILL'), 15000) | |
| 71 | let output = '', ready = false | |
| 72 | try { | |
| 73 | while (!output.includes('\n')) { | |
| 74 | const chunk = await reader.read() | |
| 75 | assert.equal(chunk.done, false) | |
| 76 | output += new TextDecoder().decode(chunk.value) | |
| 77 | assert.ok(output.length <= 4096) | |
| 78 | } | |
| 79 | const event = JSON.parse(output.trim()); assert.equal(event.ready, true); assert.equal(event.phase, 'slot-selected'); ready = true | |
| 80 | process.kill(-worker.pid, 'SIGKILL'); assert.notEqual(await worker.exited, 0); assert.equal(worker.signalCode, 'SIGKILL') | |
| 81 | } | |
| 82 | finally { | |
| 83 | clearTimeout(timer) | |
| 84 | try { process.kill(-worker.pid, 'SIGKILL') } | |
| 85 | catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error } | |
| 86 | await worker.exited; await reader.cancel(); reader.releaseLock() | |
| 87 | await writeFile(join(directory, 'worker.stdout'), output) | |
| 88 | await writeFile(join(directory, 'worker.stderr'), await new Response(worker.stderr).text()) | |
| 89 | await writeFile(join(directory, 'worker.json'), JSON.stringify({ ready, exitCode: worker.exitCode, signal: worker.signalCode })) | |
| 90 | } | |
| 91 | const interrupted = await run(['updates:slots', store, ...pinned]) | |
| 92 | assert.equal(interrupted.trial.phase, 'selected'); assert.equal(interrupted.store.attempt.phase, 'pending') | |
| 93 | assert.equal(interrupted.knownGood.id, rollback.knownGood.id) | |
| 94 | await run(['updates:abandon', store, ...pinned, '--trial', interrupted.trial.id], 1) | |
| 95 | await run(['updates:recover', store, ...pinned]) | |
| 96 | await run(['updates:abandon', store, ...pinned, '--trial', '0'.repeat(32)], 1) | |
| 97 | const abandoned = await run(['updates:abandon', store, ...pinned, '--trial', interrupted.trial.id]) | |
| 98 | assert.equal(abandoned.trial.phase, 'abandoned'); assert.deepEqual(abandoned.knownGood, rollback.knownGood) | |
| 99 | const fresh = await run(['updates:trial', store, ...pinned, ...current, '--project', project, '--candidate', next.candidate.id]) | |
| 100 | assert.equal(fresh.knownGood.candidateId, next.candidate.id); assert.notEqual(fresh.knownGood.id, abandoned.trial.id) | |
| 101 | assert.notEqual(fresh.knownGood.health.bootId, rollback.knownGood.health.bootId) | |
| 102 | assert.notEqual(fresh.knownGood.health.sessionId, rollback.knownGood.health.sessionId) | |
| 103 | assert.deepEqual(await readFile(retainedImage), Buffer.from(image)) | |
| 104 | const cuts = await qualifyHostSlotInterruptions(join(directory, 'six-cuts'), project) | |
| 105 | assert.equal(cuts.length, 6) | |
| 106 | assert.equal(await hash(source), sourceSha256); assert.equal(await hash(binary), sourceSha256) | |
| 107 | return { evidence: 'copied-standalone-host-slots-v1', sourceRevision, sourceSha256, olderSha256, operations: results.length, actualProcessCuts: 7, physicalDeploymentAllowed: false } | |
| 108 | } | |
| 109 | finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-host-slots-v1', sourceRevision, sourceSha256, olderSha256, results, physicalDeploymentAllowed: false }, null, 2)) } | |
| 110 | } | |
| 111 | if (import.meta.main) { | |
| 112 | if (process.argv.length !== 5 && process.argv.length !== 6) throw new Error('Use qualify-host-slots.ts NEW_DIRECTORY COMPILED_BINARY SOURCE_PROJECT [OLDER_BINARY]') | |
| 113 | console.log(JSON.stringify(await qualifyPortableHostSlots(resolve(process.argv[2]!), resolve(process.argv[3]!), resolve(process.argv[4]!), process.argv[5] ? resolve(process.argv[5]) : undefined))) | |
| 114 | } | |
| @@ -18,8 +18,8 @@ export function signer(nonCanonical = false) { | ||
| 18 | 18 | return { key, sign: (data: Buffer) => new Signature({ keyID: key.keyID, sig: sign(null, data, pair.privateKey).toString('hex') }) } |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | /** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */ | |
| 22 | export async function repository(directory: string, options: { custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, consistentSnapshot?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) { | |
| 21 | /** Throwaway keys stay in memory. The default text payload must never be executed or flashed. */ | |
| 22 | export async function repository(directory: string, options: { image?: Uint8Array, custom?: Record<string, string | number | boolean>, expires?: string, metadataFields?: Partial<Record<'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets', { expires?: string, version?: number }>>, rotate?: boolean, consistentSnapshot?: boolean, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) { | |
| 23 | 23 | await mkdir(join(directory, 'metadata'), { recursive: true }) |
| 24 | 24 | await mkdir(join(directory, 'targets/runtime'), { recursive: true }) |
| 25 | 25 | const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() } |
| @@ -38,7 +38,7 @@ export async function repository(directory: string, options: { custom?: Record<s | ||
| 38 | 38 | rotated.sign(nextKey.sign) |
| 39 | 39 | await writeFile(join(directory, 'metadata/2.root.json'), JSON.stringify(rotated.toJSON())) |
| 40 | 40 | } |
| 41 | const image = Buffer.from('test payload only, never execute or flash\n') | |
| 41 | const image = options.image ? Buffer.from(options.image) : Buffer.from('test payload only, never execute or flash\n') | |
| 42 | 42 | const imageHash = digest(image) |
| 43 | 43 | const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence: 7, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench', ...options.custom } |
| 44 | 44 | const targets = new Metadata(new Targets({ ...fields, ...options.metadataFields?.targets, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: imageHash }, unrecognizedFields: { custom: { drone: custom } } }) } })) |
| @@ -58,5 +58,32 @@ export async function repository(directory: string, options: { custom?: Record<s | ||
| 58 | 58 | await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/targets.json' : 'metadata/1.targets.json'), targetsBytes) |
| 59 | 59 | const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${imageHash}.pico2-bench.bin`) |
| 60 | 60 | await writeFile(imagePath, image) |
| 61 | return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath } | |
| 61 | let publishedTargetsSigner = keys.targets, publishedRootVersion = root.version | |
| 62 | async function publish(update: { image: Uint8Array, version: number, custom?: Record<string, string | number | boolean>, rotateTargets?: boolean }) { | |
| 63 | if (update.rotateTargets) { | |
| 64 | if (options.rotate) throw new Error('Fixture target rotation requires its original root signer') | |
| 65 | publishedTargetsSigner = signer() | |
| 66 | const nextRoot = new Root({ ...fields, version: ++publishedRootVersion, consistentSnapshot: options.consistentSnapshot ?? true }) | |
| 67 | for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) nextRoot.addKey(role === 'targets' ? publishedTargetsSigner.key : keys[role].key, role) | |
| 68 | const metadata = new Metadata(nextRoot) | |
| 69 | metadata.sign(keys.root.sign) | |
| 70 | await writeFile(join(directory, `metadata/${publishedRootVersion}.root.json`), JSON.stringify(metadata.toJSON())) | |
| 71 | } | |
| 72 | const bytes = Buffer.from(update.image), hash = digest(bytes), revision = { ...fields, version: update.version } | |
| 73 | const nextTargets = new Metadata(new Targets({ ...revision, targets: { [target]: new TargetFile({ path: target, length: bytes.length, hashes: { sha256: hash }, unrecognizedFields: { custom: { drone: { ...custom, ...update.custom } } } }) } })) | |
| 74 | nextTargets.sign(publishedTargetsSigner.sign) | |
| 75 | const targetsBytes = Buffer.from(JSON.stringify(nextTargets.toJSON())) | |
| 76 | const nextSnapshot = new Metadata(new Snapshot({ ...revision, meta: { 'targets.json': new MetaFile({ version: update.version, length: targetsBytes.length, hashes: { sha256: digest(targetsBytes) } }) } })) | |
| 77 | nextSnapshot.sign(keys.snapshot.sign) | |
| 78 | const snapshotBytes = Buffer.from(JSON.stringify(nextSnapshot.toJSON())) | |
| 79 | const nextTimestamp = new Metadata(new Timestamp({ ...revision, snapshotMeta: new MetaFile({ version: update.version, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) })) | |
| 80 | nextTimestamp.sign(keys.timestamp.sign) | |
| 81 | const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${hash}.pico2-bench.bin`) | |
| 82 | await writeFile(imagePath, bytes) | |
| 83 | await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/targets.json' : `metadata/${update.version}.targets.json`), targetsBytes) | |
| 84 | await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/snapshot.json' : `metadata/${update.version}.snapshot.json`), snapshotBytes) | |
| 85 | await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(nextTimestamp.toJSON())) | |
| 86 | return { image: bytes, imageHash: hash, imagePath } | |
| 87 | } | |
| 88 | return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath, publish } | |
| 62 | 89 | } |
| @@ -71,6 +71,7 @@ test('accepted purge removes more than one hundred review leaves and audits in b | ||
| 71 | 71 | try { [code, stdout, stderr] = await Promise.all([running.exited, new Response(running.stdout).text(), new Response(running.stderr).text()]) } |
| 72 | 72 | finally { clearTimeout(timer) } |
| 73 | 73 | expect(code, `${stdout}\n${stderr}`).toBe(0); expect(stdout).toContain('PASS actual137review FK leaves') |
| 74 | expect(stdout).toContain('PASS controlled setup crosses real60000ms expiry before its last review and refuses exact-expiry review before genuinely due purge') | |
| 74 | 75 | } |
| 75 | 76 | finally { if (child && child.exitCode === null) { child.kill('SIGKILL'); await child.exited }; await rm(root, { recursive: true, force: true }) } |
| 76 | 77 | }, 180000) |
| @@ -9,8 +9,10 @@ import { buildRuntime } from '../../resources/functions/drone/runtime/build' | ||
| 9 | 9 | import { openRuntimeHarness } from '../../resources/functions/drone/runtime/harness' |
| 10 | 10 | import { runRuntimeBench, runtimeBenchFaults } from '../../resources/functions/drone/runtime/bench' |
| 11 | 11 | import { inspectRuntimeCapture } from '../../resources/functions/drone/runtime/capture' |
| 12 | import { runBoundedHostProcess } from '../../resources/functions/host/process' | |
| 12 | 13 | |
| 13 | 14 | const statusFrame = (overrides: Partial<RuntimeFrame> = {}): RuntimeFrame => ({ ...commandFrame('hello', 1, 12n, 77n), kind: 'status', sampleTimeUs: 1000n, ticks: 1, sample: -999, flags: 67, stateBytes: 88, ...overrides }) |
| 15 | const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'` | |
| 14 | 16 | |
| 15 | 17 | describe('owned runtime wire boundary', () => { |
| 16 | 18 | test('uses independent Python/zlib little-endian fixtures and the standard CRC32 vector', () => { |
| @@ -87,6 +89,13 @@ async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>) | ||
| 87 | 89 | try { await run(harness) } |
| 88 | 90 | finally { await harness.close() } |
| 89 | 91 | } |
| 92 | async function withNativeTrailer(trailer: string, run: (harness: ReturnType<typeof openRuntimeHarness>) => Promise<void>) { | |
| 93 | const root = await mkdtemp(join(tmpdir(), 'drone-host-tail-')), launcher = join(root, 'launch') | |
| 94 | await writeFile(launcher, `#!/bin/sh\n${shellQuote(artifact)} "$@"\n${trailer}\n`, { mode: 0o700 }) | |
| 95 | const harness = openRuntimeHarness(launcher) | |
| 96 | try { await run(harness) } | |
| 97 | finally { await Promise.allSettled([harness.close()]); await rm(root, { recursive: true, force: true }) } | |
| 98 | } | |
| 90 | 99 | |
| 91 | 100 | describe('actual native kernel process', () => { |
| 92 | 101 | test('separate supervised boots have fresh owned identities and a clean idempotent process exit', async () => { |
| @@ -122,8 +131,7 @@ describe('actual native kernel process', () => { | ||
| 122 | 131 | const protocol = join(import.meta.dir, '../../resources/functions/drone/runtime/protocol.ts') |
| 123 | 132 | 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 | 133 | // 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 }) | |
| 134 | await writeFile(launcher, `#!/bin/sh\ncd ${shellQuote(root)} || exit 1\nexec ${shellQuote(process.execPath)} ${shellQuote(script)} "$@"\n`, { mode: 0o700 }) | |
| 127 | 135 | const harness = openRuntimeHarness(launcher) |
| 128 | 136 | try { |
| 129 | 137 | await expect(harness.exchange('tick 1000')).rejects.toThrow('boot identity') |
| @@ -147,6 +155,55 @@ describe('actual native kernel process', () => { | ||
| 147 | 155 | expect(lease.leaseUs).toBe(100000) |
| 148 | 156 | expect((await send(link.stop(1))).leaseUs).toBe(0) |
| 149 | 157 | })) |
| 158 | test('unsolicited stdout after a valid final status cannot count as a clean process close', async () => withNativeTrailer("printf '\\n'", async harness => { | |
| 159 | expect((await harness.exchange('tick 1000')).bootId).toBe(harness.bootId) | |
| 160 | await expect(harness.close()).rejects.toThrow('unexpected output') | |
| 161 | })) | |
| 162 | test('stderr after a valid final status cannot count as a clean process close', async () => withNativeTrailer("printf 'owned-fixture-warning\\n' >&2", async harness => { | |
| 163 | expect((await harness.exchange('tick 1000')).bootId).toBe(harness.bootId) | |
| 164 | await expect(harness.close()).rejects.toThrow('stderr') | |
| 165 | })) | |
| 166 | test('an exited parent with inherited pipes cannot hide an incomplete close', async () => withNativeTrailer('sleep 3 &', async harness => { | |
| 167 | expect((await harness.exchange('tick 1000')).bootId).toBe(harness.bootId) | |
| 168 | await expect(harness.close()).rejects.toThrow('close deadline') | |
| 169 | })) | |
| 170 | test('the response deadline rejects even when an exited parent leaves input and output pipes inherited', async () => { | |
| 171 | const root = await mkdtemp(join(tmpdir(), 'drone-host-response-')), launcher = join(root, 'launch') | |
| 172 | await writeFile(launcher, '#!/bin/sh\nexec 3<&0\nsleep 3 <&3 3<&- &\n', { mode: 0o700 }) | |
| 173 | const harness = openRuntimeHarness(launcher) | |
| 174 | try { await expect(harness.exchange('tick 1000')).rejects.toThrow('response deadline') } | |
| 175 | finally { await Promise.allSettled([harness.close()]); await rm(root, { recursive: true, force: true }) } | |
| 176 | }) | |
| 177 | test.skipIf(process.platform !== 'linux')('a kernel-confirmed full input FIFO cannot keep a write alive beyond the response deadline', async () => { | |
| 178 | const root = await mkdtemp(join(tmpdir(), 'drone-host-full-input-')), executable = join(root, 'full-input') | |
| 179 | let harness: ReturnType<typeof openRuntimeHarness> | undefined | |
| 180 | try { | |
| 181 | const firmware = join(import.meta.dir, '../../firmware/runtime') | |
| 182 | const compiled = await runBoundedHostProcess(['c++', '-std=c++17', '-I', join(firmware, 'include'), join(import.meta.dir, '../fixtures/runtime/full-input.cpp'), join(firmware, 'src/protocol.cpp'), '-o', executable], { timeoutMs: 10000, stdoutBytes: 65536, stderrBytes: 65536, label: 'Native full-input fixture' }) | |
| 183 | expect(compiled.exitCode).toBe(0) | |
| 184 | harness = openRuntimeHarness(executable) | |
| 185 | const first = await harness.exchange('x'.repeat(127)), capacity = first.stateBytes, pid = first.sequence | |
| 186 | expect(capacity).toBeGreaterThanOrEqual(4096) | |
| 187 | expect(capacity).toBeLessThanOrEqual(65536) | |
| 188 | expect(capacity % 128).toBe(0) | |
| 189 | expect(first.sample).toBe(128) | |
| 190 | let queued = first.sample | |
| 191 | for (let count = 1; count < capacity / 128; count++) { | |
| 192 | const status = await harness.exchange('x'.repeat(127)) | |
| 193 | queued = status.sample | |
| 194 | expect(queued).toBe((count + 1) * 128) | |
| 195 | } | |
| 196 | expect(queued).toBe(capacity) | |
| 197 | await expect(harness.exchange('x'.repeat(127))).rejects.toThrow('response deadline') | |
| 198 | await expect(harness.exchange('tick 1000')).rejects.toThrow('failed') | |
| 199 | const exit = await harness.close() | |
| 200 | expect(exit.exitCode).not.toBe(0) | |
| 201 | expect(exit.signal).toBe('SIGKILL') | |
| 202 | expect(() => process.kill(pid, 0)).toThrow() | |
| 203 | expect(await harness.close()).toEqual(exit) | |
| 204 | } | |
| 205 | finally { if (harness) await harness.close(); await rm(root, { recursive: true, force: true }) } | |
| 206 | }, 20000) | |
| 150 | 207 | test('a newer sequence cannot renew authority from an old board timestamp', async () => withNative(async harness => { |
| 151 | 208 | await harness.exchange('tick 1000') |
| 152 | 209 | const link = createRuntimeSession({ nonce: () => 45n }) |
| @@ -0,0 +1,237 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { Database } from 'bun:sqlite' | |
| 3 | import { mkdir, mkdtemp, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { buildRuntime } from '../../resources/functions/drone/runtime/build' | |
| 7 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 8 | import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, rollbackRuntimeFirmwareSlot, trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 9 | import { repository, target } from '../fixtures/updates/repository' | |
| 10 | import { createHostSourceProject } from '../fixtures/updates/host-project' | |
| 11 | ||
| 12 | test('replacement policy rejects old rollback bytes and a signed neutral label cannot execute a mismatched build', async () => { | |
| 13 | const root = await mkdtemp(join(tmpdir(), 'drone-host-policy-')) | |
| 14 | try { | |
| 15 | const project = process.cwd(), build = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer()) | |
| 16 | const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 17 | const sourceRevision = (await new Response(git.stdout).text()).trim() | |
| 18 | expect(await git.exited).toBe(0) | |
| 19 | const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 20 | const enrollmentDirectory = join(root, 'enrollment') | |
| 21 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 22 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 23 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 24 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 25 | const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 26 | const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id }) | |
| 27 | const replacement = await signed.publish({ image: Buffer.from('signed text only, never execute\n'), version: 2, custom: { releaseSequence: 9 }, rotateTargets: true }) | |
| 28 | await expect(rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project })).rejects.toThrow('currently authorized') | |
| 29 | const denied = await inspectRuntimeFirmwareSlots(reference) | |
| 30 | expect(denied.knownGood).toEqual(good.knownGood) | |
| 31 | expect(denied.store.releaseFloor).toBe(9) | |
| 32 | expect(denied.store.metadata.find(item => item.role === 'root')?.version).toBe(2) | |
| 33 | expect(denied.store.attempt?.phase).toBe('maintenance') | |
| 34 | expect(await readFile(join(directory, 'host-slots', good.knownGood!.id, 'image.bin'))).toEqual(Buffer.from(image)) | |
| 35 | await recoverRuntimeTrustStore(reference) | |
| 36 | const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 37 | expect(next.candidate!.description.sha256).toBe(replacement.imageHash) | |
| 38 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: next.candidate!.id })).rejects.toThrow('does not match the actual owned neutral build') | |
| 39 | const failed = await inspectRuntimeFirmwareSlots(reference) | |
| 40 | expect(failed.knownGood).toEqual(good.knownGood) | |
| 41 | expect(failed.trial?.phase).toBe('failed') | |
| 42 | expect(failed.trial?.health).toBeNull() | |
| 43 | expect(failed.installed?.id).toBe(good.installed!.id) | |
| 44 | expect(failed.store.releaseFloor).toBe(9) | |
| 45 | expect(failed.physicalDeploymentAllowed).toBe(false) | |
| 46 | } | |
| 47 | finally { await rm(root, { recursive: true, force: true }) } | |
| 48 | }, 30000) | |
| 49 | ||
| 50 | test('signed revision mismatch and dirty firmware cannot start a host trial or discard accepted policy', async () => { | |
| 51 | const root = await mkdtemp(join(tmpdir(), 'drone-host-source-policy-')) | |
| 52 | let completed = false | |
| 53 | try { | |
| 54 | for (const mode of ['revision', 'dirty'] as const) { | |
| 55 | const scope = join(root, mode); await mkdir(scope, { mode: 0o700 }) | |
| 56 | const { project, sourceRevision } = await createHostSourceProject(scope) | |
| 57 | const built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer()) | |
| 58 | const source = join(scope, 'repository') | |
| 59 | const signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision: mode === 'revision' ? 'f'.repeat(40) : sourceRevision, releaseSequence: 9 } }) | |
| 60 | if (mode === 'dirty') { | |
| 61 | const path = join(project, 'firmware/runtime/src/host.cpp') | |
| 62 | await writeFile(path, `${await readFile(path, 'utf8')}\n// Deliberate uncommitted fixture change.\n`) | |
| 63 | } | |
| 64 | const enrollmentDirectory = join(scope, 'enrollment') | |
| 65 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 66 | const directory = join(scope, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 67 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 68 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 69 | const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 70 | const phases: string[] = [] | |
| 71 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: prepared.candidate!.id, onProgress(event) { phases.push(event.phase) } })).rejects.toThrow('signed clean firmware source revision') | |
| 72 | expect(phases).not.toContain('runtime-started'); expect(phases).not.toContain('runtime-input-committed') | |
| 73 | const failed = await inspectRuntimeFirmwareSlots(reference) | |
| 74 | expect(failed.installed).toBeNull(); expect(failed.knownGood).toBeNull() | |
| 75 | expect(failed.trial?.phase).toBe('failed'); expect(failed.trial?.health).toBeNull() | |
| 76 | expect(failed.store.releaseFloor).toBe(9) | |
| 77 | await recoverRuntimeTrustStore(reference) | |
| 78 | const recovered = await inspectRuntimeFirmwareSlots(reference) | |
| 79 | expect(recovered.trial).toEqual(failed.trial) | |
| 80 | expect(recovered.knownGood).toBeNull(); expect(recovered.store.releaseFloor).toBe(9) | |
| 81 | } | |
| 82 | completed = true | |
| 83 | } | |
| 84 | finally { | |
| 85 | if (completed) await rm(root, { recursive: true, force: true }) | |
| 86 | else console.error(`Failed host source-policy fixture retained at ${root}`) | |
| 87 | } | |
| 88 | }, 40000) | |
| 89 | ||
| 90 | test('actual CMake output overflow retains accepted policy and needs fresh execution after recovery', async () => { | |
| 91 | const root = await mkdtemp(join(tmpdir(), 'drone-host-build-output-')) | |
| 92 | let completed = false | |
| 93 | try { | |
| 94 | const { project, sourceRevision } = await createHostSourceProject(root, { fault: 'configure-output' }) | |
| 95 | const built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer()) | |
| 96 | const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision, releaseSequence: 9 } }) | |
| 97 | const enrollmentDirectory = join(root, 'enrollment') | |
| 98 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 99 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 100 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 101 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 102 | const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 103 | const flag = join(built.directory, 'drone-output-overflow-fixture'); await writeFile(flag, 'explicit private output fault\n') | |
| 104 | const phases: string[] = [] | |
| 105 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: candidate.candidate!.id, onProgress(event) { phases.push(event.phase) } })).rejects.toThrow('stdout exceeds byte limit') | |
| 106 | expect(phases).not.toContain('runtime-started') | |
| 107 | const failed = await inspectRuntimeFirmwareSlots(reference) | |
| 108 | expect(failed.knownGood).toBeNull(); expect(failed.installed).toBeNull() | |
| 109 | expect(failed.trial?.phase).toBe('failed'); expect(failed.trial?.health).toBeNull() | |
| 110 | expect(failed.store.releaseFloor).toBe(9) | |
| 111 | await recoverRuntimeTrustStore(reference) | |
| 112 | expect((await inspectRuntimeFirmwareSlots(reference)).store.releaseFloor).toBe(9) | |
| 113 | await unlink(flag) | |
| 114 | const fresh = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: candidate.candidate!.id }) | |
| 115 | expect(fresh.knownGood?.candidateId).toBe(candidate.candidate!.id) | |
| 116 | expect(fresh.knownGood?.id).not.toBe(failed.trial?.id) | |
| 117 | expect(fresh.knownGood?.health?.cleanProcessExit).toBe(true) | |
| 118 | completed = true | |
| 119 | } | |
| 120 | finally { | |
| 121 | if (completed) await rm(root, { recursive: true, force: true }) | |
| 122 | else console.error(`Failed bounded-build fixture retained at ${root}`) | |
| 123 | } | |
| 124 | }, 40000) | |
| 125 | ||
| 126 | test('phase and reservation changes cannot promote a slot even after actual healthy execution', async () => { | |
| 127 | const root = await mkdtemp(join(tmpdir(), 'drone-host-transition-policy-')) | |
| 128 | let completed = false | |
| 129 | try { | |
| 130 | const project = process.cwd(), built = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer()) | |
| 131 | const revision = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 132 | const sourceRevision = (await new Response(revision.stdout).text()).trim() | |
| 133 | expect(await revision.exited).toBe(0) | |
| 134 | const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 135 | const enrollmentDirectory = join(root, 'enrollment') | |
| 136 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 137 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 138 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 139 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 140 | const first = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 141 | const good = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: first.candidate!.id }) | |
| 142 | await signed.publish({ image, version: 2, custom: { releaseSequence: 9 } }) | |
| 143 | const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 144 | for (const mode of ['selected', 'running', 'reservation'] as const) { | |
| 145 | const phases: string[] = [] | |
| 146 | let changed = false | |
| 147 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, clock, candidateId: next.candidate!.id, onProgress(event) { | |
| 148 | phases.push(event.phase) | |
| 149 | if (event.phase !== (mode === 'selected' ? 'slot-image-written' : 'final-clock-committed')) return | |
| 150 | // Actual private SQLite fault between committed phases, not a mocked transition result. | |
| 151 | const db = new Database(join(directory, 'state.sqlite'), { readwrite: true, create: false }) | |
| 152 | try { | |
| 153 | const result = mode === 'reservation' | |
| 154 | ? db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase = 'prepared'").run(next.candidate!.id) | |
| 155 | : db.query("UPDATE host_slots SET phase = 'failed' WHERE phase = ?").run(mode) | |
| 156 | expect(result.changes).toBe(1); changed = true | |
| 157 | } | |
| 158 | finally { db.close() } | |
| 159 | } })).rejects.toThrow(mode === 'selected' ? 'Selected host slot changed' : mode === 'running' ? 'Running host slot changed' : 'Owned candidate reservation changed') | |
| 160 | expect(changed).toBe(true) | |
| 161 | if (mode === 'selected') expect(phases).not.toContain('runtime-input-committed') | |
| 162 | else expect(phases).toContain('final-clock-committed') | |
| 163 | const failed = await inspectRuntimeFirmwareSlots(reference) | |
| 164 | expect(failed.knownGood).toEqual(good.knownGood) | |
| 165 | expect(failed.trial?.phase).toBe('failed'); expect(failed.trial?.health).toBeNull() | |
| 166 | expect(failed.store.releaseFloor).toBe(9) | |
| 167 | await recoverRuntimeTrustStore(reference) | |
| 168 | const recovered = await inspectRuntimeFirmwareSlots(reference) | |
| 169 | expect(recovered.knownGood).toEqual(good.knownGood) | |
| 170 | expect(recovered.trial).toEqual(failed.trial); expect(recovered.store.releaseFloor).toBe(9) | |
| 171 | } | |
| 172 | completed = true | |
| 173 | } | |
| 174 | finally { | |
| 175 | if (completed) await rm(root, { recursive: true, force: true }) | |
| 176 | else console.error(`Failed host transition fixture retained at ${root}`) | |
| 177 | } | |
| 178 | }, 40000) | |
| 179 | ||
| 180 | test('a final trusted clock advance survives rejected confirmation and historical recovery', async () => { | |
| 181 | const root = await mkdtemp(join(tmpdir(), 'drone-host-clock-floor-')) | |
| 182 | try { | |
| 183 | const project = process.cwd(), build = await buildRuntime({ project }), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer()) | |
| 184 | const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 185 | const sourceRevision = (await new Response(git.stdout).text()).trim() | |
| 186 | expect(await git.exited).toBe(0) | |
| 187 | const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 188 | const enrollmentDirectory = join(root, 'enrollment') | |
| 189 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 190 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 191 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 192 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 193 | const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 194 | let readings = 0 | |
| 195 | const advancing = { source: clock.source, read: () => new Date(++readings === 1 ? '2030-01-01T00:00:00Z' : '2036-01-01T00:00:00Z') } | |
| 196 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, candidateId: prepared.candidate!.id, clock: advancing })).rejects.toThrow('expired before confirmation') | |
| 197 | const failed = await inspectRuntimeFirmwareSlots(reference) | |
| 198 | expect(failed.knownGood).toBeNull() | |
| 199 | expect(failed.store.clockFloorMs).toBe(Date.parse('2036-01-01T00:00:00Z')) | |
| 200 | expect(failed.trial?.clockCheckedMs).toBe(failed.store.clockFloorMs) | |
| 201 | expect(failed.trial?.confirmedClockMs).toBeNull() | |
| 202 | expect(failed.trial?.phase).toBe('failed') | |
| 203 | await recoverRuntimeTrustStore(reference) | |
| 204 | expect((await inspectRuntimeFirmwareSlots(reference)).store.clockFloorMs).toBe(failed.store.clockFloorMs) | |
| 205 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, project, candidateId: prepared.candidate!.id, clock })).rejects.toThrow('reversed update clock') | |
| 206 | } | |
| 207 | finally { await rm(root, { recursive: true, force: true }) } | |
| 208 | }, 30000) | |
| 209 | ||
| 210 | test('a trusted build output cannot overlap protected state or change it before rejection', async () => { | |
| 211 | const root = await mkdtemp(join(tmpdir(), 'drone-host-build-collision-')) | |
| 212 | let completed = false | |
| 213 | try { | |
| 214 | const built = await buildRuntime(), image = new Uint8Array(await Bun.file(built.artifact).arrayBuffer()) | |
| 215 | const { project, sourceRevision } = await createHostSourceProject(root) | |
| 216 | const source = join(root, 'repository'), signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 217 | const enrollmentDirectory = join(root, 'enrollment') | |
| 218 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 219 | const buildParent = join(project, 'storage/runtime-build') | |
| 220 | await mkdir(buildParent, { recursive: true, mode: 0o700 }) | |
| 221 | const directory = join(buildParent, 'host'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 222 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 223 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 224 | const candidate = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 225 | const before = await readFile(join(directory, 'state.sqlite')), writer = await readFile(join(directory, 'writer.sqlite')), names = await readdir(directory) | |
| 226 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, project, candidateId: candidate.candidate!.id })).rejects.toThrow('build output') | |
| 227 | expect(await readFile(join(directory, 'state.sqlite'))).toEqual(before) | |
| 228 | expect(await readFile(join(directory, 'writer.sqlite'))).toEqual(writer) | |
| 229 | expect(await readdir(directory)).toEqual(names) | |
| 230 | expect(await Bun.file(join(directory, 'CMakeCache.txt')).exists()).toBe(false) | |
| 231 | completed = true | |
| 232 | } | |
| 233 | finally { | |
| 234 | if (completed) await rm(root, { recursive: true, force: true }) | |
| 235 | else console.error(`Failed build collision fixture retained at ${root}`) | |
| 236 | } | |
| 237 | }, 30000) | |
| @@ -0,0 +1,51 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { buildRuntime } from '../../resources/functions/drone/runtime/build' | |
| 7 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 8 | import { createRuntimeTrustStore, reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 9 | import { repository, target } from '../fixtures/updates/repository' | |
| 10 | ||
| 11 | test('native host slot commands share actual health, fresh rollback and fixed rejection semantics', async () => { | |
| 12 | const root = await mkdtemp(join(tmpdir(), 'drone-host-slot-cli-')) | |
| 13 | try { | |
| 14 | const project = process.cwd(), build = await buildRuntime({ project }) | |
| 15 | const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 16 | const sourceRevision = (await new Response(git.stdout).text()).trim() | |
| 17 | expect(await git.exited).toBe(0) | |
| 18 | const source = join(root, 'repository'), image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer()) | |
| 19 | const signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 20 | const enrollment = join(root, 'enrollment') | |
| 21 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollment }) | |
| 22 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 23 | const reference = { enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 24 | const reserved = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: { source: 'fixture-system-clock', read: () => new Date() } }) | |
| 25 | const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256] | |
| 26 | const current = ['--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock', '--project', project] | |
| 27 | const trial = await command(['updates:trial', directory, ...flags, ...current, '--candidate', reserved.candidate!.id]).withTimeout(20000) | |
| 28 | expect(trial.exitCode).toBe(0) | |
| 29 | const first = JSON.parse(trial.stdout) | |
| 30 | expect(first.knownGood.candidateId).toBe(reserved.candidate!.id) | |
| 31 | expect(first.knownGood.health.cleanProcessExit).toBe(true) | |
| 32 | expect(first.physicalDeploymentAllowed).toBe(false) | |
| 33 | const status = await command(['updates:slots', directory, ...flags]).withTimeout(20000) | |
| 34 | expect(status.exitCode).toBe(0) | |
| 35 | expect(JSON.parse(status.stdout)).toEqual(first) | |
| 36 | const missing = await command(['updates:trial', directory, ...flags, '--repository', source, '--candidate', reserved.candidate!.id, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000) | |
| 37 | expect(missing.exitCode).toBe(1) | |
| 38 | expect(missing.stdout).toBe('') | |
| 39 | expect(missing.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n') | |
| 40 | const rollback = await command(['updates:rollback', directory, ...flags, ...current]).withTimeout(20000) | |
| 41 | expect(rollback.exitCode).toBe(0) | |
| 42 | const next = JSON.parse(rollback.stdout) | |
| 43 | expect(next.knownGood.candidateId).toBe(first.knownGood.candidateId) | |
| 44 | expect(next.knownGood.health.bootId).not.toBe(first.knownGood.health.bootId) | |
| 45 | const wrong = await command(['updates:abandon', directory, ...flags, '--trial', next.knownGood.id]).withTimeout(20000) | |
| 46 | expect(wrong.exitCode).toBe(1) | |
| 47 | expect(wrong.stdout).toBe('') | |
| 48 | expect(wrong.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n') | |
| 49 | } | |
| 50 | finally { await rm(root, { recursive: true, force: true }) } | |
| 51 | }, 120000) | |
| @@ -0,0 +1,124 @@ | ||
| 1 | import { beforeAll, 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 { buildRuntime } from '../../resources/functions/drone/runtime/build' | |
| 7 | import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment' | |
| 8 | import { createRuntimeTrustStore, inspectRuntimeFirmwareSlots, recoverRuntimeTrustStore, reserveRuntimeFirmwareCandidate, rollbackRuntimeFirmwareSlot, trialRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store' | |
| 9 | import { repository, target } from '../fixtures/updates/repository' | |
| 10 | import { qualifyHostSlotInterruptions } from '../fixtures/updates/host-slot-interruptions' | |
| 11 | import { createHostSourceProject } from '../fixtures/updates/host-project' | |
| 12 | ||
| 13 | let image: Uint8Array, sourceRevision: string | |
| 14 | beforeAll(async () => { | |
| 15 | const build = await buildRuntime() | |
| 16 | image = new Uint8Array(await Bun.file(build.artifact).arrayBuffer()) | |
| 17 | const git = Bun.spawn(['git', 'rev-parse', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }) | |
| 18 | sourceRevision = (await new Response(git.stdout).text()).trim() | |
| 19 | expect(await git.exited).toBe(0) | |
| 20 | }, 20000) | |
| 21 | ||
| 22 | test('an owned signed host image becomes known-good only through its actual fresh runtime and clean process close', async () => { | |
| 23 | const root = await mkdtemp(join(tmpdir(), 'drone-host-slot-')) | |
| 24 | try { | |
| 25 | const source = join(root, 'repository') | |
| 26 | const signed = await repository(source, { image, custom: { platform: 'host-neutral-bench-v1', sourceRevision } }) | |
| 27 | const enrollmentDirectory = join(root, 'enrollment') | |
| 28 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 29 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 30 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 31 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 32 | const reserved = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 33 | expect((await inspectRuntimeFirmwareSlots(reference)).knownGood).toBeNull() | |
| 34 | const result = await trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, candidateId: reserved.candidate!.id, project: process.cwd() }) | |
| 35 | expect(result.schema).toBe('drone-runtime-host-slots-v1') | |
| 36 | expect(result.stateFormat).toBe(3) | |
| 37 | expect(result.knownGood?.candidateId).toBe(reserved.candidate!.id) | |
| 38 | expect(result.knownGood?.description.sha256).toBe(signed.imageHash) | |
| 39 | expect(result.installed?.id).toBe(result.knownGood?.id) | |
| 40 | expect(result.knownGood?.phase).toBe('confirmed') | |
| 41 | expect(result.knownGood?.health?.bootId).toMatch(/^[1-9][0-9]{0,19}$/) | |
| 42 | expect(result.knownGood?.health?.sessionId).toMatch(/^[1-9][0-9]{0,19}$/) | |
| 43 | expect(result.knownGood?.health?.ticks).toBe(100) | |
| 44 | expect(result.knownGood?.health?.cleanProcessExit).toBe(true) | |
| 45 | expect(result.knownGood?.health?.transcriptItems).toBeGreaterThan(200) | |
| 46 | expect(result.physicalDeploymentAllowed).toBe(false) | |
| 47 | expect(await inspectRuntimeFirmwareSlots(reference)).toEqual(result) | |
| 48 | const next = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 49 | const interruption = new Error('Owned observer stops before the second runtime launch') | |
| 50 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, candidateId: next.candidate!.id, project: process.cwd(), onProgress(event) { | |
| 51 | if (event.phase === 'runtime-started') throw interruption | |
| 52 | } })).rejects.toBe(interruption) | |
| 53 | const failed = await inspectRuntimeFirmwareSlots(reference) | |
| 54 | expect(failed.knownGood).toEqual(result.knownGood) | |
| 55 | expect(failed.installed?.phase).toBe('failed') | |
| 56 | expect(failed.installed?.id).not.toBe(result.knownGood!.id) | |
| 57 | expect(failed.store.releaseFloor).toBe(7) | |
| 58 | expect(failed.store.attempt?.phase).toBe('maintenance') | |
| 59 | expect(await readFile(join(directory, 'host-slots', result.knownGood!.id, 'image.bin'))).toEqual(Buffer.from(image)) | |
| 60 | await recoverRuntimeTrustStore(reference) | |
| 61 | const recovered = await inspectRuntimeFirmwareSlots(reference) | |
| 62 | expect(recovered.knownGood).toEqual(result.knownGood) | |
| 63 | expect(recovered.installed?.phase).toBe('failed') | |
| 64 | expect(recovered.store.attempt?.phase).toBe('recovered') | |
| 65 | const rollback = await rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project: process.cwd() }) | |
| 66 | expect(rollback.knownGood?.candidateId).toBe(result.knownGood!.candidateId) | |
| 67 | expect(rollback.knownGood?.id).not.toBe(result.knownGood!.id) | |
| 68 | expect(rollback.knownGood?.health?.bootId).not.toBe(result.knownGood!.health!.bootId) | |
| 69 | expect(rollback.knownGood?.health?.sessionId).not.toBe(result.knownGood!.health!.sessionId) | |
| 70 | expect(rollback.installed?.id).toBe(rollback.knownGood!.id) | |
| 71 | expect(rollback.retainedSlots).toBe(3) | |
| 72 | expect(rollback.physicalDeploymentAllowed).toBe(false) | |
| 73 | const goodImage = join(directory, 'host-slots', rollback.knownGood!.id, 'image.bin') | |
| 74 | await writeFile(goodImage, Buffer.alloc(image.length, 0xff)) | |
| 75 | await expect(rollbackRuntimeFirmwareSlot({ ...reference, repository: source, clock, project: process.cwd() })).rejects.toThrow('known-good integrity') | |
| 76 | expect((await inspectRuntimeFirmwareSlots(reference)).knownGood!.id).toBe(rollback.knownGood!.id) | |
| 77 | expect(await readFile(goodImage)).toEqual(Buffer.alloc(image.length, 0xff)) | |
| 78 | await writeFile(goodImage, image) | |
| 79 | await recoverRuntimeTrustStore(reference) | |
| 80 | // Deliberately corrupt retained state through the real file, without using it as a success oracle. | |
| 81 | const db = new Database(join(directory, 'state.sqlite'), { create: false, readwrite: true }) | |
| 82 | try { | |
| 83 | const corrupted = { ...rollback.knownGood!.health!, bootId: rollback.knownGood!.health!.bootId === '1' ? '2' : '1' } | |
| 84 | db.query('UPDATE host_slots SET health = ? WHERE id = ?').run(Buffer.from(JSON.stringify(corrupted)), rollback.knownGood!.id) | |
| 85 | } | |
| 86 | finally { db.close() } | |
| 87 | await expect(inspectRuntimeFirmwareSlots(reference)).rejects.toThrow('transcript') | |
| 88 | } | |
| 89 | finally { await rm(root, { recursive: true, force: true }) } | |
| 90 | }, 30000) | |
| 91 | ||
| 92 | test('actual host trial process cuts retain prior good and need explicit recovery without inventing confirmation', async () => { | |
| 93 | const root = await mkdtemp(join(tmpdir(), 'drone-host-slot-cuts-')) | |
| 94 | let completed = false | |
| 95 | try { | |
| 96 | const result = await qualifyHostSlotInterruptions(join(root, 'campaign'), process.cwd()) | |
| 97 | expect(result).toHaveLength(6) | |
| 98 | expect(result.every(item => item.signal === 'SIGKILL' && item.contender === 17 && item.recoveredWithoutPromotion && item.freshRollback)).toBe(true) | |
| 99 | completed = true | |
| 100 | } | |
| 101 | finally { | |
| 102 | if (completed) await rm(root, { recursive: true, force: true }) | |
| 103 | else console.error(`Failed host slot campaign retained at ${root}`) | |
| 104 | } | |
| 105 | }, 90000) | |
| 106 | ||
| 107 | test('a real signed neutral kernel with a wrong-session stop reply cannot confirm a host slot', async () => { | |
| 108 | const root = await mkdtemp(join(tmpdir(), 'drone-host-stop-session-')) | |
| 109 | try { | |
| 110 | const { project, sourceRevision: revision } = await createHostSourceProject(root, { fault: 'stop-session' }) | |
| 111 | const build = await buildRuntime({ project }) | |
| 112 | const source = join(root, 'repository'), bytes = new Uint8Array(await Bun.file(build.artifact).arrayBuffer()) | |
| 113 | const signed = await repository(source, { image: bytes, custom: { platform: 'host-neutral-bench-v1', sourceRevision: revision } }) | |
| 114 | const enrollmentDirectory = join(root, 'enrollment') | |
| 115 | const enrolled = enrollRuntimeTrust({ target, platform: 'host-neutral-bench-v1', trustedRootSha256: signed.rootSha256, minimumReleaseSequence: 7, trustedRoot: signed.rootPath, output: enrollmentDirectory }) | |
| 116 | const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory }) | |
| 117 | const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } | |
| 118 | const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') } | |
| 119 | const reserved = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) | |
| 120 | await expect(trialRuntimeFirmwareCandidate({ ...reference, repository: source, clock, candidateId: reserved.candidate!.id, project })).rejects.toThrow('stop') | |
| 121 | expect((await inspectRuntimeFirmwareSlots(reference)).knownGood).toBeNull() | |
| 122 | } | |
| 123 | finally { await rm(root, { recursive: true, force: true }) } | |
| 124 | }, 30000) | |