ReviewOS

also looking at this

stacks/drone

feat: own runtime firmware candidates

#49
Merged chris wants to merge codex/runtime-candidate-ownership into main
24 files +1245 -92
.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -40,6 +40,10 @@ jobs:
4040 run: bun tests/fixtures/updates/qualify-sdk.ts "$RUNNER_TEMP/runtime-sdk-qualification"
4141 - name: Verify persistent metadata and recovery with a copied standalone CLI
4242 run: bun tests/fixtures/updates/qualify-store.ts "$RUNNER_TEMP/runtime-store-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
43 - name: Verify protected signed-image preparation with a copied standalone CLI
44 run: bun tests/fixtures/updates/qualify-preparation.ts "$RUNNER_TEMP/runtime-preparation-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
45 - name: Verify owned candidate identity and retirement with a copied standalone CLI
46 run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4347 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4448 working-directory: ${{ runner.temp }}
4549 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+8-4
Changes to README.md
@@ -262,12 +262,16 @@ verified crash-prefix recovery, provenance and offline replay through CLI and `/
262262
263263The viewer opens files locally in the browser. The v1 format excludes imagery, location and
264264operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md).
265The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF
265The [signed runtime image workflow](docs/runtime-updates.md) verifies offline TUF
266266candidates through `updates:stage` and `updates:inspect`, with explicit root pins,
267267compatibility and externally supplied release floors. `updates:enroll` and
268`updates:trust` preserve and independently verify an initial root/policy record.
269Physical logging/commissioning, persistent refresh metadata/release floors, image
270installation and known-good rollback remain pending.
268`updates:trust` preserve the initial root/policy record. The independently pinned
269store retains verified metadata, clock/release floors and historical recovery;
270`updates:prepare` authenticates actual image bytes under that protected state.
271`updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate
272identity, recorded-byte status and explicit retirement while retaining files/history.
273Physical logging/commissioning, key revocation/repair, installed/known-good slots
274and image-bound boot/session health remain pending.
271275
272276## Sensor acquisition and replay
273277
app/Commands/UpdatesCandidates.tsadded+14-0
Changes to app/Commands/UpdatesCandidates.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { inspectRuntimeFirmwareCandidates } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:candidates <directory>',
8 description: 'Inspect owned candidate identity and recorded-byte integrity without granting selection authority',
9 options: storeReferenceFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectRuntimeFirmwareCandidates(storeCommandReference(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesPrepare.tsadded+21-0
Changes to app/Commands/UpdatesPrepare.ts
@@ -0,0 +1,21 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { prepareRuntimeFirmwareUpdate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:prepare <directory>',
8 description: 'Authenticate a signed image under protected metadata into a new candidate without installing firmware',
9 options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image repository', '--output <path>': 'New exclusive candidate directory', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.repository !== 'string' || typeof options.output !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository, output and independently trusted system-clock source required')
13 await printAsync(JSON.stringify(await prepareRuntimeFirmwareUpdate({ ...storeCommandReference(options, directory), repository: options.repository, output: options.output, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
14 }
15 catch {
16 process.exitCode = 1
17 try { await printErrorAsync('Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure') }
18 catch { /* Preserve failure when the diagnostic pipe also fails. */ }
19 }
20 },
21})
app/Commands/UpdatesReserve.tsadded+17-0
Changes to app/Commands/UpdatesReserve.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:reserve <directory>',
8 description: 'Reserve an owned signed-image candidate in a pinned store without selecting or installing firmware',
9 options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image repository', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository and independently trusted system-clock source required')
13 await printAsync(JSON.stringify(await reserveRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), repository: options.repository, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
app/Commands/UpdatesRetire.tsadded+17-0
Changes to app/Commands/UpdatesRetire.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:retire <directory>',
8 description: 'Retire the exact active candidate reservation while retaining its history, bytes and trust floors',
9 options: { ...storeReferenceFlags, '--candidate <id>': 'Exact active candidate identity from pinned store status' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.candidate !== 'string') throw new Error('Explicit candidate identity required')
13 await printAsync(JSON.stringify(await retireRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), candidateId: options.candidate })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
bin/drone.tsmodified+8-0
Changes to bin/drone.ts
@@ -27,6 +27,10 @@ import UpdatesStoreInit from '../app/Commands/UpdatesStoreInit'
2727import UpdatesStore from '../app/Commands/UpdatesStore'
2828import UpdatesRefresh from '../app/Commands/UpdatesRefresh'
2929import UpdatesRecover from '../app/Commands/UpdatesRecover'
30import UpdatesPrepare from '../app/Commands/UpdatesPrepare'
31import UpdatesReserve from '../app/Commands/UpdatesReserve'
32import UpdatesCandidates from '../app/Commands/UpdatesCandidates'
33import UpdatesRetire from '../app/Commands/UpdatesRetire'
3034
3135import MissionsSimulate from '../app/Commands/MissionsSimulate'
3236import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -97,6 +101,10 @@ UpdatesStoreInit(app)
97101UpdatesStore(app)
98102UpdatesRefresh(app)
99103UpdatesRecover(app)
104UpdatesPrepare(app)
105UpdatesReserve(app)
106UpdatesCandidates(app)
107UpdatesRetire(app)
100108MissionsSimulate(app)
101109MissionsValidate(app)
102110MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdmodified+22-3
Changes to docs/adr/0028-runtime-trust-store.md
@@ -47,7 +47,26 @@ process-crash evidence is not measured physical power-loss durability. Same-user
4747privileged disk rollback and malicious kernel/filesystem behavior require separate
4848hardware protection and are outside this host protocol's claims.
4949
50Installed, candidate and known-good slots, authenticated image integration and
51health confirmation bound to an exact image and boot/session identity remain part
52of #42. No receipt or healthy boolean can select firmware. Physical installation,
50Protected preparation now uses this metadata operation to authenticate actual
51image bytes before and after writing the existing signed-candidate format. The
52same file writer serves standalone staging. Candidate inspection requires a root
53pin from authenticated store readback when preparation uses the current rotated
54root. A candidate receipt supplies no current trust or selection authority.
55
56The original format1 database journals metadata. Candidate reservation upgrades
57its exact schema to format2 in a single transaction, preserving all earlier
58records, floors and the independently pinned store identity. The writer database
59stays format1. New code reads both formats; older binaries reject format2 before
60rewriting it. Missing/corrupt or unknown schemas are never reinitialized.
61
62Format2 adds bounded owned candidate records and one active-reservation index.
63A record binds the exact attempt/generation and generated signed description,
64is committed before image IO, and changes to prepared only after authenticated
65bytes and full candidate-file readback. The files use exclusive immutable-ID
66directories inside the private store. Explicit retirement preserves the files
67and history. Metadata-only recovery cannot promote a reserved candidate based on
68its receipt. Recorded-byte status is not current-policy authorization.
69
70Installed and known-good slot roles and health confirmation bound to an exact
71image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
5372bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/diagnostics.mdmodified+9-6
Changes to docs/diagnostics.md
@@ -155,12 +155,15 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
155155- Measure board flash/storage latency, loss/rotation policy, logging deadlines and RAM under load.
156156 Exercise ENOSPC/EDQUOT, torn sectors, storage removal, host crash, power loss and reset causes on
157157 the actual Pi/Pico setup. Disabled loads and independent authority remain required.
158- [Offline signed image staging](runtime-updates.md) now authenticates TUF roles,
159 declared target compatibility and a caller-supplied release floor. Protected initial
160 trust enrollment is available; build trust/key lifecycle and persistent
161 metadata/release high-water state, interrupted update
162 recovery and a known-good rollback slot before flashing. Each staging invocation
163 currently starts from the independently supplied root; it is not full anti-rollback.
158- [Signed image staging and protected preparation](runtime-updates.md) authenticate
159 TUF roles, declared compatibility and actual image bytes. Independently pinned
160 enrollment/store records retain metadata and clock/release floors; historical
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.
164167- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
165168 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
166169
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [offline signed image staging and initial trust enrollment](../runtime-updates.md); full mission correlation, hardware evidence, persistent refresh floors and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors, actual image preparation and durable candidate ownership](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, installed/known-good slots and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/roadmap/issues.jsonmodified+11-2
Changes to docs/roadmap/issues.json
@@ -267,13 +267,22 @@
267267 "resources/components/DiagnosticsReplay.stx",
268268 "docs/diagnostics.md",
269269 "public/diagnostics/runtime-deadline.ndjson",
270 "tests/unit/diagnostics.test.ts"
270 "tests/unit/diagnostics.test.ts",
271 "resources/functions/drone/updates/",
272 "app/Commands/UpdatesPrepare.ts",
273 "app/Commands/UpdatesReserve.ts",
274 "app/Commands/UpdatesCandidates.ts",
275 "app/Commands/UpdatesRetire.ts",
276 "docs/runtime-updates.md",
277 "tests/unit/updates-preparation.test.ts",
278 "tests/unit/updates-candidates.test.ts",
279 "tests/unit/updates-candidates-cli.test.ts"
271280 ],
272281 "remaining": [
273282 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
274283 "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence",
275284 "Measured board logging deadlines, memory, rotation and long-mission retention",
276 "Signed firmware updates, trust/key lifecycle, anti-rollback and known-good recovery slot",
285 "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health",
277286 "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence"
278287 ]
279288 },
docs/runtime-updates.mdmodified+166-16
Changes to docs/runtime-updates.md
@@ -2,10 +2,10 @@
22
33Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
44staging, independent candidate verification, protected initial trust enrollment
5and a persistent host metadata protocol. These commands prepare and verify host
6state. Integrating that protocol with authenticated image staging, revocation,
7installed images, boot selection and known-good rollback slots remains part of
8the full coordinator. Physical deployment is disabled.
5and a persistent host metadata protocol with protected image preparation. These
6commands prepare and verify host state and actual candidate image bytes. Key
7revocation/repair, installed images, boot selection and known-good rollback slots
8remain part of the full coordinator. Physical deployment is disabled.
99
1010## Explicit trust and policy
1111
@@ -136,8 +136,9 @@ compatible target's verified signed declaration. Refresh verifies metadata and
136136declared compatibility; it does not download or authenticate the actual image
137137bytes. An attempt marked `verified` means this metadata operation completed. It
138138does not mean an image is staged, installed, booted or confirmed healthy. Existing
139`updates:stage` and `updates:inspect` remain separate and still report
140`trustStatePersisted: false`; integrating them with this store is unfinished.
139`updates:stage` and `updates:inspect` remain standalone and still report
140`trustStatePersisted: false`. The protected `updates:prepare` path below shares
141that candidate file format and actual-image readback verification.
141142
142143Timestamp or snapshot authority rotation, authenticated through sequential root
143144metadata, creates a new active epoch and invalidates both working-cache entries.
@@ -176,11 +177,158 @@ actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a
176177competing process, restart and retain the timestamp before a fresh refresh. These
177178cuts do not cover every write/sync boundary or actual physical power loss.
178179
179The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
180The metadata format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
180181state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per
181182file. Exhaustion fails closed; history is not automatically deleted or compacted.
182183Capacity migration, the full storage-fault campaign, authenticated repair/key
183revocation, image/slot integration and session-bound health remain in #42.
184revocation, durable slot coordination and session-bound health remain in #42.
185
186## Protected signed-image preparation
187
188Use the pinned store and independently trusted current clock to authenticate an
189actual image into a new candidate directory:
190
191```sh
192./buddy updates:prepare /path/to/store --enrollment /path/to/trust \
193 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
194 --repository /path/to/offline-repository --output /path/to/new-candidate \
195 --trusted-system-clock-source operator-managed-utc-clock
196```
197
198The library function is `prepareRuntimeFirmwareUpdate`. Native Buddy and the
199compiled CLI use the same function. One writer lease covers metadata verification,
200release-floor commit, image verification and candidate output. It uses one SDK
201metadata pass with the protected cache and journal. It does not perform a second
202unjournaled refresh through the standalone stager.
203
204The signed release floor is committed before reading the image. A bad hash,
205missing image or later output failure cannot reopen a release downgrade. The SDK
206checks actual payload length and SHA-256 before writing, then checks the actual
207written `image.bin` again. The shared candidate file writer emits the same
208`stage.json` format as standalone staging only after repository files and image
209readback complete. Its file helpers do not grant firmware-selection authority.
210
211The candidate's `trust-root.json` is the currently authenticated protected root,
212which can differ from the immutable enrollment root after rotation. Its metadata
213archive includes protected active roles, including cached roles that this attempt
214did not need to fetch again. Both consistent-snapshot filename modes are supported.
215For independent standalone inspection, obtain the current root hash and release
216floor from independently pinned store readback, never from a candidate receipt.
217The candidate does not need to replay the entire initial root chain: the protected
218store has already authenticated that chain and retains its history.
219
220The result schema is `drone-runtime-prepared-image-v1`, with `store` status,
221`candidate` declaration, `trustStatePersisted: true` and
222`physicalDeploymentAllowed: false`. The nested standalone candidate declaration
223still has `trustStatePersisted: false`: that portable receipt is not the protected
224store or current authorization. A successful preparation authenticates these
225candidate bytes at this operation's trusted time. It does not install, select,
226boot or confirm them, and does not promise future trust/expiry policy will allow
227selection. The owned-candidate operation below adds durable reservation records;
228installed/known-good slots and current-policy revalidation before selection remain
229coordinator work in #42.
230
231Image or candidate-output failure during an attempt leaves accepted metadata and
232release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never
233reconstructs image success or confirms an existing candidate. Preserve partial
234outputs and use a new output directory for a fresh preparation after recovery.
235A complete candidate marker can exist when a later callback or process failure
236prevents the operation returning. Marker existence alone is not authorization.
237Repository, enrollment and protocol-store directories cannot contain the candidate
238output or be contained by it. Existing output directories are not overwritten.
239
240Host tests kill actual competing-writer-excluded processes at `image-written`
241and `candidate-completed` progress points. The first follows image-file sync;
242the second follows the shared file writer's final marker and directory syncs.
243After restart, metadata recovery retains release floor 9 and returns only a store
244summary; a fresh preparation uses another new directory. These measured process
245cuts do not establish every write/sync boundary, electrical power-loss behavior
246or hardware anti-rollback.
247
248## Owned candidate reservations and retirement
249
250Use `updates:reserve` when the pinned store should own the candidate, rather than
251writing a standalone output directory. Repository and enrollment directories must
252remain separate from the store and cannot contain it or be contained by it.
253
254```sh
255./buddy updates:reserve /path/to/store --enrollment /path/to/trust \
256 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
257 --repository /path/to/offline-repository \
258 --trusted-system-clock-source operator-managed-utc-clock
259
260./buddy updates:candidates /path/to/store --enrollment /path/to/trust \
261 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256"
262
263./buddy updates:retire /path/to/store --enrollment /path/to/trust \
264 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
265 --candidate "$EXACT_ACTIVE_CANDIDATE_ID"
266```
267
268The library functions are `reserveRuntimeFirmwareCandidate`,
269`inspectRuntimeFirmwareCandidates` and `retireRuntimeFirmwareCandidate`. The
270native Buddy commands and copied standalone commands share them. A writer lease
271covers metadata verification, reservation, image output and completion. Only one
272candidate can remain active; another reservation fails before starting a metadata
273attempt. A fresh trusted clock and the existing protected SDK operation verify
274the signed selection, then commit its release floor before image acquisition.
275
276Reservation upgrades an exact format1 `state.sqlite` to format2 in one SQLite
277transaction, adding `candidates` and a partial unique index for one active
278reservation. It retains every metadata/attempt record, clock/release floor and
279store identity. `writer.sqlite` stays format1, and the independently retained
280`store.json` hash is unchanged. New code still reads format1 stores without
281migrating them during inspection. Older binaries fail closed on format2; a
282retained older executable was tested before and after upgrade, including unchanged
283state and writer database hashes after its rejection. Do not downgrade or reset a
284store to make an older binary operate on it. Unknown schemas remain rejected
285before configuration that could rewrite their format.
286
287Each candidate record binds a random ID, exact metadata attempt/generation and
288the generated signed-stage description/hash. The record is committed as
289`reserved` before image IO. Files are exclusively created under
290`store/candidates/<id>` with the same actual-image verification and archive writer
291as protected preparation. Only complete file, image and archive readback permits
292the `prepared` record commit. A receipt cannot introduce an owned record.
293
294`updates:candidates` returns `drone-runtime-candidates-v1`, `stateFormat`, protected
295`store` status, the active `candidate`, and bounded declaration `history`. History
296includes retired identities and paths; retired file integrity is not inspected.
297There are at most 128 retained candidate records. Exhaustion fails closed rather
298than deleting history or images. Active candidate `integrity` is:
299
300| Value | Meaning |
301| --- | --- |
302| `incomplete` | The record is still reserved, even if a stage marker exists |
303| `recorded-bytes-match` | Prepared image, receipt, root and archive match the owned record and its historical metadata bytes |
304| `mismatch` | Available file content or layout differs from the owned candidate |
305| `unavailable` | Files cannot be completely read or their protected file/directory requirements fail |
306
307This status is a local byte check. It does not establish fresh TUF expiration,
308revocation or current-policy authorization. `currentTrustValidated` and
309`physicalDeploymentAllowed` remain false. No installed, booted or healthy result
310is invented from a prepared record or matching files.
311
312After an interrupted or failed operation, preserve the whole store and its
313candidate files. Resolve the pending/maintenance metadata attempt with
314`updates:recover` first. Recovery never promotes `reserved` to `prepared`, even
315when a completed stage marker exists. A process can also stop after the prepared
316record commit but before attempt completion; the record survives, while metadata
317recovery still supplies no current selection authority. Missing/corrupt files
318remain actionable integrity failures. Explicit retirement requires the exact
319active ID and a resolved metadata attempt. It changes the record to `retired`,
320retains all files/history/floors, and allows a new ID with a new directory. It does
321not erase or repair the earlier artifact.
322
323Host qualification kills actual processes after reservation, image write, stage
324marker completion, prepared-record commit and retirement commit. Each cut observes
325an actual competing process rejected with SQLITE_BUSY, then restarts, checks
326unchanged candidate phase/floors and prepares a distinct fresh ID after explicit
327recovery/retirement. This does not cover every SQLite/filesystem write or sync,
328ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. Installed
329and known-good slot roles, identity-bound runtime health, current-policy selection
330and rollback, authenticated key maintenance and the full fault campaign remain
331required by #42.
184332
185333## Repository and signed image contract
186334
@@ -188,9 +336,11 @@ Use a standard TUF repository with `metadata/` and `targets/` directories,
188336including consistent-snapshot names when enabled. Verification uses the pinned
189337[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
190338rotation, signature thresholds, expiration, inter-role versions/hashes and
191target length/hashes. A private, randomly addressed loopback server supplies
192bounded local files to the client's public HTTP interface. It is stopped after
193verification. No external network fetch or arbitrary URL option is provided.
339target length/hashes. Standalone staging/inspection use a private, randomly
340addressed loopback server supplying bounded local files to the public HTTP
341interface, then stop it. Protected refresh/preparation use the public Fetcher
342interface with bounded local files and the durable input journal. Neither path
343provides an external network fetch or arbitrary URL option.
194344The host additionally verifies that root and delegation key IDs equal SHA-256
195345of their canonical key objects, using the upstream canonical JSON library.
196346This rejects arbitrary key-ID aliases before role verification.
@@ -300,11 +450,11 @@ time. Neither path is a complete anti-rollback coordinator. Trusted clock
300450acquisition and protection against privileged clock/storage rollback remain
301451open. Control-loop freshness still uses its separate monotonic clock.
302452
303Initial enrollment and the bounded persistent metadata protocol are available
304above. Complete authenticated maintenance/key revocation, the full storage-fault
305campaign and integration with signed image staging, then installed/known-good
306slot integrity, explicit
307health confirmation and rollback without lowering security floors. Then add
453Initial enrollment, the bounded persistent metadata protocol, protected
454signed-image preparation and durable candidate ownership are available above.
455Complete authenticated maintenance/key revocation and the full storage-fault
456campaign, then installed/known-good slot integrity, explicit health
457confirmation and rollback without lowering security floors. Then add
308458the board bootloader/install protocol and measured power-loss, full-medium,
309459storage-latency and recovery campaigns. Local privileged filesystem tampering
310460is outside this host module's protection; it is not secure boot or hardware
resources/functions/drone/updates/candidate-files.tsadded+36-0
Changes to resources/functions/drone/updates/candidate-files.ts
@@ -0,0 +1,36 @@
1import { mkdirSync, realpathSync } from 'node:fs'
2import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3import { Readable } from 'node:stream'
4import type { TargetFile } from 'tuf-js'
5import { directory, imageLimit, regularFile, safePath, syncDirectory, syncTree, writeExclusive } from './files'
6
7/** Internal candidate file-format helpers. They do not grant firmware selection authority. */
8export function runtimeCandidateOutput(output: string, excluded: readonly string[]) {
9 const requested = resolve(output), parent = realpathSync(dirname(requested))
10 directory(parent)
11 const destination = join(parent, basename(requested))
12 for (const source of excluded) {
13 const root = realpathSync(source)
14 const child = relative(root, destination), ancestor = relative(destination, root)
15 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!ancestor.startsWith(`..${sep}`) && ancestor !== '..')) throw new Error('Candidate output must be separate from repository and protected trust state')
16 }
17 return destination
18}
19
20/** Caller owns SDK verification. Recheck actual image bytes before writing the completion marker. */
21export async function finishRuntimeCandidate(output: string, info: TargetFile, receipt: unknown, files: ReadonlyMap<string, Buffer>) {
22 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
23 let total = 0
24 for (const [path, bytes] of files) {
25 safePath(path)
26 if (!path.startsWith('metadata/') && !path.startsWith('targets/')) throw new Error('Invalid candidate repository entry')
27 total += bytes.length
28 if (total > 32 * 1024 * 1024 || files.size > 64) throw new Error('Candidate archive budget exceeded')
29 const file = join(output, 'repository', path)
30 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
31 writeExclusive(file, bytes)
32 }
33 syncTree(output)
34 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`))
35 syncDirectory(output); syncDirectory(dirname(output))
36}
resources/functions/drone/updates/staging.tsmodified+5-19
Changes to resources/functions/drone/updates/staging.ts
@@ -1,13 +1,14 @@
11import { randomBytes } from 'node:crypto'
22import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
3import { dirname, join, relative, resolve, sep } from 'node:path'
3import { join, resolve } from 'node:path'
44import { tmpdir } from 'node:os'
55import { Readable } from 'node:stream'
66import { isDeepStrictEqual } from 'node:util'
77import { Updater } from 'tuf-js'
8import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files'
8import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, writeExclusive } from './files'
99import { object, validateMetadata } from './metadata'
1010import { admitSignedRuntime } from './manifest'
11import { finishRuntimeCandidate, runtimeCandidateOutput } from './candidate-files'
1112import { admitPolicy } from './policy'
1213import type { RuntimeFirmwarePolicy } from './policy'
1314export type { RuntimeFirmwarePolicy } from './policy'
@@ -85,29 +86,14 @@ async function verifyRepository(repository: string, root: Buffer, work: string,
8586/** Exclusive offline candidate only. This never changes installed firmware, boot slots or trust floors. */
8687export async function stageRuntimeFirmware(options: RuntimeFirmwarePolicy & { repository: string, trustedRoot: string, output: string }): Promise<RuntimeFirmwareStage> {
8788 const policy = admitPolicy(options)
88 const repository = realpathSync(options.repository), output = resolve(options.output)
89 const repository = realpathSync(options.repository), output = runtimeCandidateOutput(options.output, [repository])
8990 directory(repository)
9091 const root = regularFile(options.trustedRoot, metadataLimit)
9192 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
92 // Resolve the existing parent before creating anything, so a symlinked parent cannot hide overlap.
93 const destination = join(realpathSync(dirname(output)), output.slice(dirname(output).length + 1))
94 const child = relative(repository, destination), parent = relative(destination, repository)
95 if (!child || (!child.startsWith(`..${sep}`) && child !== '..') || (!parent.startsWith(`..${sep}`) && parent !== '..')) throw new Error('Candidate and source repository must be separate')
9693 mkdirSync(output, { mode: 0o700 })
9794 writeExclusive(join(output, 'trust-root.json'), root)
9895 const { result, fetched, info } = await verifyRepository(repository, root, join(output, '.verification'), policy, join(output, 'image.bin'))
99 // Re-read the actual staged bytes, rather than trusting a successful copy or receipt.
100 await info.verify(Readable.from(regularFile(join(output, 'image.bin'), imageLimit)))
101 for (const [path, bytes] of fetched) {
102 const file = join(output, 'repository', path)
103 mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
104 writeExclusive(file, bytes)
105 }
106 syncTree(output)
107 // A final marker means logical completeness. It cannot attest to power-loss durability after fsync.
108 writeExclusive(join(output, 'stage.json'), Buffer.from(`${JSON.stringify(result, null, 2)}\n`))
109 syncDirectory(output)
110 syncDirectory(dirname(output))
96 await finishRuntimeCandidate(output, info, result, fetched)
11197 return result
11298}
11399
resources/functions/drone/updates/store.tsmodified+242-14
Changes to resources/functions/drone/updates/store.ts
@@ -4,9 +4,13 @@ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, u
44import { tmpdir } from 'node:os'
55import { dirname, join, resolve } from 'node:path'
66import { DownloadHTTPError, Updater } from 'tuf-js'
7import type { TargetFile } from 'tuf-js'
8import { Readable } from 'node:stream'
9import { finishRuntimeCandidate, runtimeCandidateOutput } from './candidate-files'
10import type { RuntimeFirmwareStage } from './staging'
711import { inspectRuntimeTrust } from './enrollment'
812import type { RuntimeTrustSummary } from './enrollment'
9import { directory, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files'
13import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, writeExclusive } from './files'
1014import { object, validateMetadata } from './metadata'
1115import { admitSignedRuntime, RuntimeManifestRejected } from './manifest'
1216
@@ -21,6 +25,10 @@ const schema = [
2125 `CREATE TABLE inputs (attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), path TEXT NOT NULL CHECK(length(path) BETWEEN 1 AND 256), status INTEGER NOT NULL CHECK(status IN (200, 404)), bytes BLOB, sha256 TEXT, PRIMARY KEY(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id), CHECK((status = 404 AND bytes IS NULL AND sha256 IS NULL) OR (status = 200 AND bytes IS NOT NULL AND sha256 IS NOT NULL AND length(bytes) BETWEEN 1 AND ${metadataLimit} AND length(sha256) = 64))) STRICT`,
2226 `CREATE TABLE metadata (generation INTEGER PRIMARY KEY CHECK(generation BETWEEN 0 AND ${maximumGeneration}), attempt_id TEXT, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), role TEXT NOT NULL CHECK(length(role) BETWEEN 1 AND 180), version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 9007199254740991), epoch INTEGER NOT NULL CHECK(epoch BETWEEN 0 AND generation), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND ${metadataLimit}), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), UNIQUE(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id)) STRICT`,
2327]
28const candidateSchema = [
29 'CREATE TABLE candidates (id TEXT PRIMARY KEY CHECK(length(id) = 32), attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN (\'reserved\', \'prepared\', \'retired\')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT',
30 "CREATE UNIQUE INDEX candidate_active ON candidates ((1)) WHERE phase IN ('reserved', 'prepared')",
31]
2432
2533interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
2634interface StoreReference extends TrustReference { directory: string, storeSha256: string }
@@ -59,7 +67,7 @@ function configure(db: Database) {
5967 if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable')
6068 }
6169}
62function openDatabase(path: string, expectedSchema: string[]) {
70function openDatabase(path: string, expectedSchema: string[], allowCandidates = false) {
6371 const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes)
6472 const db = new Database(path, { create: false, strict: true })
6573 try {
@@ -68,19 +76,20 @@ function openDatabase(path: string, expectedSchema: string[]) {
6876 db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;')
6977 // Authenticate the protocol format before any configuration that could
7078 // rewrite a foreign database's journal/header format.
71 checkSchema(db, expectedSchema)
79 const version = databaseVersion(db)
80 checkSchema(db, allowCandidates && version === 2 ? [...expectedSchema, ...candidateSchema] : expectedSchema, allowCandidates && version === 2 ? 2 : 1)
7281 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
7382 configure(db)
7483 return db
7584 }
7685 catch (error) { db.close(); throw error }
7786}
78function checkSchema(db: Database, expected: string[]) {
87function databaseVersion(db: Database) { return db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version }
88function checkSchema(db: Database, expected: string[], version = 1) {
7989 const definitions = db.query<{ sql: string }, []>("SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").all().map(row => row.sql).sort()
8090 if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema')
8191 const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get()
82 const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()
83 if (values?.application_id !== applicationId || version?.user_version !== 1 || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
92 if (values?.application_id !== applicationId || databaseVersion(db) !== version || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
8493}
8594function createDatabase(path: string, statements: string[]) {
8695 writeExclusive(path, Buffer.alloc(0))
@@ -144,17 +153,22 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b
144153 let writer: Database | undefined, db: Database | undefined
145154 try {
146155 for (const name of readdirSync(path)) {
147 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry')
156 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates'].includes(name)) throw new Error('Unexpected update-store entry')
148157 if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true)
149158 }
150159 writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema])
151160 writer.exec('BEGIN IMMEDIATE')
152161 const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all()
153162 if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity')
154 db = openDatabase(join(path, 'state.sqlite'), schema)
163 db = openDatabase(join(path, 'state.sqlite'), schema, true)
164 if (readdirSync(path).includes('candidates')) {
165 if (databaseVersion(db) !== 2) throw new Error('Candidate files require an owned format2 store')
166 protectedDirectory(join(path, 'candidates'))
167 }
155168 const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
156169 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')
157170 summary(db, binding, options.storeSha256)
171 for (const row of candidateRows(db)) ownedDescription(db, row, trusted)
158172 return await operation(db, binding, trusted, path)
159173 }
160174 finally {
@@ -203,7 +217,7 @@ export interface RuntimeUpdateClock {
203217}
204218
205219export interface RuntimeUpdateProgress {
206 phase: 'input-committed' | 'metadata-committed'
220 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired'
207221 ordinal: number
208222}
209223type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
@@ -258,10 +272,22 @@ function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal
258272 return changed
259273}
260274
261/** Verify bounded offline metadata and retain its accepted prefix. This does not stage, select or install an image. */
262export async function refreshRuntimeTrustStore(options: StoreReference & { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeTrustStoreSummary>> {
275interface RefreshOptions extends StoreReference { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }
276interface VerifiedSelection {
277 db: Database
278 binding: Binding
279 trusted: Readonly<RuntimeTrustSummary>
280 source: string
281 info: TargetFile
282 runtime: ReturnType<typeof admitSignedRuntime>
283 attempt: string
284 path: string
285}
286
287async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
263288 options = { ...options }
264 return withStore(options, async (db, binding, trusted) => {
289 return withStore(options, async (db, binding, trusted, path) => {
290 before?.(db)
265291 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
266292 const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms)
267293 if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached')
@@ -305,11 +331,14 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos
305331 const info = await updater.getTargetInfo(trusted.policy.target)
306332 if (!info) throw new Error('Selected runtime target is absent')
307333 const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor })
334 // The signed release floor survives later image or candidate-output failure.
308335 db.transaction(() => {
309336 db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence)
310 db.query("UPDATE attempts SET phase = 'verified', accepted_release = ? WHERE id = ?").run(runtime.releaseSequence, attempt)
337 db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt)
311338 })()
312 return summary(db, binding, options.storeSha256)
339 const value = await complete({ db, binding, trusted, source, info, runtime, attempt, path })
340 db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt)
341 return { store: summary(db, binding, options.storeSha256), value }
313342 }
314343 catch (error) {
315344 try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) }
@@ -320,6 +349,205 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos
320349 })
321350}
322351
352/** Verify bounded offline metadata and retain its accepted prefix. This never downloads an image. */
353export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise<Readonly<RuntimeTrustStoreSummary>> {
354 return (await refreshAndComplete(options, async () => undefined)).store
355}
356
357export interface PreparedRuntimeFirmware {
358 schema: 'drone-runtime-prepared-image-v1'
359 store: Readonly<RuntimeTrustStoreSummary>
360 candidate: Readonly<RuntimeFirmwareStage>
361 trustStatePersisted: true
362 physicalDeploymentAllowed: false
363}
364
365/** Authenticate an image under current protected metadata. Never installs, selects or confirms firmware. */
366export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> {
367 options = { ...options }
368 const output = runtimeCandidateOutput(options.output, [options.repository, options.directory, options.enrollmentDirectory])
369 const result = await refreshAndComplete(options, selection => writePreparedCandidate(selection, output, options.onProgress))
370 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
371}
372
373function candidateDescription({ db, binding, trusted, info, runtime }: VerifiedSelection): RuntimeFirmwareStage {
374 const root = activeMetadata(db, state(db, binding).generation).find(row => row.role === 'root')!
375 return { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false }
376}
377
378async function writePreparedCandidate(selection: VerifiedSelection, output: string, onProgress?: ProgressObserver) {
379 const { db, binding, source, info } = selection
380 const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')!
381 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
382 const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path
383 const image = repositoryFile(source, `targets/${path}`, imageLimit)
384 await info.verify(Readable.from(image))
385 const files = new Map<string, Buffer>()
386 for (const row of rows) {
387 if (row.role === 'root') continue
388 files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
389 }
390 files.set(`targets/${path}`, image)
391 const candidate = candidateDescription(selection)
392 mkdirSync(output, { mode: 0o700 })
393 writeExclusive(join(output, 'trust-root.json'), rootBytes)
394 writeExclusive(join(output, 'image.bin'), image)
395 await onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 }))
396 await finishRuntimeCandidate(output, info, candidate, files)
397 await onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 }))
398 return Object.freeze(candidate)
399}
400
401interface CandidateRow { id: string, attempt_id: string, generation: number, phase: 'reserved' | 'prepared' | 'retired', description: Uint8Array, sha256: string }
402export interface RuntimeFirmwareCandidate {
403 id: string
404 attemptId: string
405 generation: number
406 phase: CandidateRow['phase']
407 directory: string
408 description: Readonly<RuntimeFirmwareStage>
409 integrity: 'incomplete' | 'recorded-bytes-match' | 'mismatch' | 'unavailable'
410 currentTrustValidated: false
411}
412export interface RuntimeFirmwareCandidates {
413 schema: 'drone-runtime-candidates-v1'
414 stateFormat: 1 | 2
415 store: Readonly<RuntimeTrustStoreSummary>
416 retainedCandidates: number
417 /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */
418 history: ReadonlyArray<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>>
419 candidate: Readonly<RuntimeFirmwareCandidate> | null
420 physicalDeploymentAllowed: false
421}
422
423function candidateRows(db: Database) {
424 if (databaseVersion(db) === 1) return []
425 const rows = db.query<CandidateRow, []>('SELECT * FROM candidates ORDER BY rowid LIMIT 129').all()
426 if (rows.length > 128) throw new Error('Candidate history budget exceeded')
427 return rows
428}
429function ownedDescription(db: Database, row: CandidateRow, trusted: Readonly<RuntimeTrustSummary>): Readonly<RuntimeFirmwareStage> {
430 const bytes = Buffer.from(row.description)
431 if (!/^[a-f0-9]{32}$/.test(row.id) || sha256(bytes) !== row.sha256) throw new Error('Invalid owned candidate record')
432 const value = object(JSON.parse(bytes.toString('utf8')))
433 const keys = ['schema', 'target', 'platform', 'trustedRootSha256', 'rootVersion', 'releaseSequence', 'protocolVersion', 'sourceRevision', 'signerDeclaredProfile', 'length', 'sha256', 'trustStatePersisted', 'physicalDeploymentAllowed']
434 const root = activeMetadata(db, row.generation).find(item => item.role === 'root')!
435 checkedMetadataBytes(root)
436 const attempt = db.query<Attempt, [string]>('SELECT * FROM attempts WHERE id = ?').get(row.attempt_id)
437 if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key)) || value.schema !== 'drone-runtime-stage-v1' || value.target !== trusted.policy.target || value.platform !== trusted.policy.platform || value.trustedRootSha256 !== root.sha256 || value.rootVersion !== root.version || !Number.isSafeInteger(value.releaseSequence) || (value.releaseSequence as number) < trusted.policy.minimumReleaseSequence || value.protocolVersion !== 1 || typeof value.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(value.sourceRevision) || value.signerDeclaredProfile !== 'neutral-bench' || !Number.isSafeInteger(value.length) || (value.length as number) < 1 || (value.length as number) > imageLimit || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || value.trustStatePersisted !== false || value.physicalDeploymentAllowed !== false || !attempt || attempt.accepted_release !== value.releaseSequence || row.generation < attempt.base_generation) throw new Error('Owned candidate does not match protected selection')
438 return Object.freeze(value as unknown as RuntimeFirmwareStage)
439}
440function candidateIntegrity(db: Database, generation: number, path: string, description: Readonly<RuntimeFirmwareStage>): RuntimeFirmwareCandidate['integrity'] {
441 try {
442 protectedDirectory(path)
443 const rows = activeMetadata(db, generation), root = rows.find(row => row.role === 'root')!
444 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
445 const imagePath = consistent ? description.target.replace(/([^/]+)$/, `${description.sha256}.$1`) : description.target
446 const expected = new Map<string, Buffer>([['trust-root.json', rootBytes], ['stage.json', Buffer.from(`${JSON.stringify(description, null, 2)}\n`)]])
447 for (const row of rows) {
448 if (row.role !== 'root') expected.set(`repository/metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
449 }
450 for (const file of ['image.bin', `repository/targets/${imagePath}`]) {
451 const address = join(path, file)
452 protectedFile(address, imageLimit)
453 const bytes = regularFile(address, imageLimit)
454 if (bytes.length !== description.length || sha256(bytes) !== description.sha256) return 'mismatch'
455 expected.set(file, bytes)
456 }
457 const directories = new Set<string>()
458 for (const name of expected.keys()) {
459 for (let parent = dirname(name); parent !== '.'; parent = dirname(parent)) directories.add(parent)
460 }
461 const visit = (relative: string): boolean => {
462 const base = relative ? join(path, relative) : path
463 protectedDirectory(base)
464 const names = readdirSync(base)
465 if (names.length > 64) return false
466 return names.every(name => {
467 const child = relative ? `${relative}/${name}` : name
468 if (directories.has(child)) return visit(child)
469 const bytes = expected.get(child)
470 if (!bytes) return false
471 protectedFile(join(path, child), child.endsWith('image.bin') || child.startsWith('repository/targets/') ? imageLimit : metadataLimit)
472 return regularFile(join(path, child), Math.max(bytes.length, 1)).equals(bytes)
473 })
474 }
475 if (!visit('')) return 'mismatch'
476 for (const [name, bytes] of expected) {
477 protectedFile(join(path, name), Math.max(bytes.length, 1))
478 if (!regularFile(join(path, name), Math.max(bytes.length, 1)).equals(bytes)) return 'mismatch'
479 }
480 return 'recorded-bytes-match'
481 }
482 catch { return 'unavailable' }
483}
484function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string, storeSha256: string): Readonly<RuntimeFirmwareCandidates> {
485 const rows = candidateRows(db), active = rows.filter(row => row.phase !== 'retired')
486 if (active.length > 1) throw new Error('Multiple active candidate records')
487 let candidate: Readonly<RuntimeFirmwareCandidate> | null = null
488 const history: Array<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>> = []
489 for (const row of rows) {
490 const description = ownedDescription(db, row, trusted)
491 const directory = join(path, 'candidates', row.id)
492 const recorded = Object.freeze({ id: row.id, attemptId: row.attempt_id, generation: row.generation, phase: row.phase, directory, description })
493 history.push(recorded)
494 if (row === active[0]) {
495 candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false })
496 }
497 }
498 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
499}
500
501/** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */
502export async function inspectRuntimeFirmwareCandidates(options: StoreReference): Promise<Readonly<RuntimeFirmwareCandidates>> {
503 return withStore(options, (db, binding, trusted, path) => candidatesSummary(db, binding, trusted, path, options.storeSha256))
504}
505
506/** Release only the exact active candidate reservation. Retain its records and bytes; never reset trust floors. */
507export async function retireRuntimeFirmwareCandidate(options: StoreReference & { candidateId: string, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeFirmwareCandidates>> {
508 options = { ...options }
509 if (typeof options.candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(options.candidateId)) throw new Error('Exact active candidate identity required')
510 return withStore(options, async (db, binding, trusted, path) => {
511 const current = candidatesSummary(db, binding, trusted, path, options.storeSha256)
512 if (current.store.attempt?.phase === 'pending' || current.store.attempt?.phase === 'maintenance') throw new Error('Resolve the metadata attempt before candidate retirement')
513 if (!current.candidate || current.candidate.id !== options.candidateId) throw new Error('Exact active candidate identity required')
514 db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase IN ('reserved', 'prepared')").run(options.candidateId)
515 await options.onProgress?.(Object.freeze({ phase: 'candidate-retired', ordinal: 0 }))
516 return candidatesSummary(db, binding, trusted, path, options.storeSha256)
517 })
518}
519
520/** Reserve one owned candidate and prepare signed bytes. No installed slot or health confirmation is changed. */
521export async function reserveRuntimeFirmwareCandidate(options: RefreshOptions): Promise<Readonly<RuntimeFirmwareCandidates>> {
522 options = { ...options }
523 runtimeCandidateOutput(options.directory, [options.repository, options.enrollmentDirectory])
524 const result = await refreshAndComplete(options, async selection => {
525 const { db, binding, trusted, path, attempt } = selection
526 if (databaseVersion(db) === 1) {
527 db.transaction(() => { for (const sql of candidateSchema) db.exec(sql); db.exec('PRAGMA user_version=2') })()
528 checkSchema(db, [...schema, ...candidateSchema], 2)
529 }
530 const id = randomBytes(16).toString('hex'), description = candidateDescription(selection)
531 const bytes = Buffer.from(`${JSON.stringify(description)}\n`), generation = state(db, binding).generation
532 db.query("INSERT INTO candidates VALUES(?, ?, ?, 'reserved', ?, ?)").run(id, attempt, generation, bytes, sha256(bytes))
533 await options.onProgress?.(Object.freeze({ phase: 'candidate-reserved', ordinal: 0 }))
534 const parent = join(path, 'candidates')
535 if (!readdirSync(path).includes('candidates')) { mkdirSync(parent, { mode: 0o700 }); syncDirectory(path) }
536 protectedDirectory(parent)
537 const output = join(parent, id)
538 await writePreparedCandidate(selection, output, options.onProgress)
539 if (candidateIntegrity(db, generation, output, description) !== 'recorded-bytes-match') throw new Error('Owned candidate file readback failed')
540 db.query("UPDATE candidates SET phase = 'prepared' WHERE id = ? AND phase = 'reserved'").run(id)
541 await options.onProgress?.(Object.freeze({ phase: 'candidate-recorded', ordinal: 0 }))
542 return candidatesSummary(db, binding, trusted, path, options.storeSha256)
543 }, db => {
544 const rows = candidateRows(db)
545 if (rows.some(row => row.phase !== 'retired')) throw new Error('An active candidate requires explicit retirement or slot transition')
546 if (rows.length >= 128) throw new Error('Candidate history budget reached')
547 })
548 return Object.freeze({ ...result.value, store: result.store })
549}
550
323551function causedBy(error: unknown, marker: Error) {
324552 const seen = new Set<unknown>()
325553 for (let value = error; value instanceof Error && !seen.has(value) && seen.size < 8; value = Object.getOwnPropertyDescriptor(value, 'cause')?.value) {
tests/fixtures/updates/epochs.tsmodified+4-0
Changes to tests/fixtures/updates/epochs.ts
@@ -20,6 +20,9 @@ export async function epochRepository(directory: string, duplicateSnapshotKey =
2020 async function publish(epoch: 1 | 2, version: number, releaseSequence: number) {
2121 if (epoch === 2) await writeFile(join(directory, 'metadata/2.root.json'), root(2))
2222 const image = Buffer.from('synthetic epoch image, never execute or flash\n')
23 const imageHash = digest(image), imagePath = join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`)
24 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
25 await writeFile(imagePath, image)
2326 const custom = { schema: 'drone-runtime-image-v1', platform, protocolVersion: 1, releaseSequence, sourceRevision: 'a'.repeat(40), profile: 'neutral-bench' }
2427 const targets = new Metadata(new Targets({ ...fields, version, targets: { [target]: new TargetFile({ path: target, length: image.length, hashes: { sha256: digest(image) }, unrecognizedFields: { custom: { drone: custom } } }) } }))
2528 targets.sign(keys.targets.sign)
@@ -32,6 +35,7 @@ export async function epochRepository(directory: string, duplicateSnapshotKey =
3235 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
3336 await writeFile(join(directory, `metadata/${version}.snapshot.json`), snapshotBytes)
3437 await writeFile(join(directory, `metadata/${version}.targets.json`), targetBytes)
38 return { image, imageHash, imagePath }
3539 }
3640 return { rootPath, rootSha256: digest(anchor), publish }
3741}
tests/fixtures/updates/qualify-candidates.tsadded+90-0
Changes to tests/fixtures/updates/qualify-candidates.ts
@@ -0,0 +1,90 @@
1import assert from 'node:assert/strict'
2import { constants } from 'node:fs'
3import { createHash } from 'node:crypto'
4import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
5import { join, resolve } from 'node:path'
6import { platform, repository, target } from './repository'
7
8async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
9
10/** Real copied commands, signed synthetic bytes and optional retained older binary. Never executes a candidate. */
11export async function qualifyPortableCandidates(directory: string, source: string, olderBinary?: string) {
12 await mkdir(directory, { mode: 0o700 })
13 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
14 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
15 assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256)
16 const olderSha256 = olderBinary ? await hash(olderBinary) : null
17 const repo = join(directory, 'repo'), fixture = await repository(repo, { rotate: true, custom: { releaseSequence: 9 } })
18 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store')
19 const results: { command: string, binarySha256: string, exit: number, stdout: string, stderr: string }[] = []
20 async function run(args: string[], expected = 0, executable = binary) {
21 const child = Bun.spawn([executable, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
22 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
23 try {
24 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
25 results.push({ command: args[0]!, binarySha256: executable === binary ? sourceSha256 : olderSha256!, exit, stdout, stderr }); assert.equal(exit, expected)
26 if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') }
27 return stdout ? JSON.parse(stdout) : undefined
28 }
29 finally { clearTimeout(timer) }
30 }
31 try {
32 const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'])
33 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
34 const created = await run(['updates:store-init', ...flags, '--output', store])
35 const pinned = [...flags, '--store-sha256', created.storeSha256]
36 const repositoryFlags = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']
37 const initialHash = await hash(join(store, 'state.sqlite'))
38 const empty = await run(['updates:candidates', store, ...pinned])
39 assert.equal(empty.stateFormat, 1); assert.equal(empty.candidate, null)
40 assert.equal(await hash(join(store, 'state.sqlite')), initialHash)
41 if (olderBinary) assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), created)
42 await run(['updates:reserve', store, ...pinned, '--repository', repo], 1)
43 const prepared = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
44 assert.equal(prepared.stateFormat, 2); assert.equal(prepared.store.releaseFloor, 9)
45 assert.equal(prepared.store.attempt.phase, 'verified'); assert.equal(prepared.store.metadata.find((row: { role: string }) => row.role === 'root').version, 2)
46 assert.equal(prepared.candidate.phase, 'prepared'); assert.equal(prepared.candidate.integrity, 'recorded-bytes-match')
47 assert.equal(prepared.candidate.description.sha256, fixture.imageHash); assert.equal(prepared.candidate.currentTrustValidated, false); assert.equal(prepared.physicalDeploymentAllowed, false)
48 assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared)
49 await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1)
50 await run(['updates:retire', store, ...pinned, '--candidate', '0'.repeat(32)], 1)
51 assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared)
52 if (olderBinary) {
53 const stateHash = await hash(join(store, 'state.sqlite')), writerHash = await hash(join(store, 'writer.sqlite'))
54 await run(['updates:store', store, ...pinned], 1, olderBinary)
55 assert.equal(await hash(join(store, 'state.sqlite')), stateHash); assert.equal(await hash(join(store, 'writer.sqlite')), writerHash)
56 assert.equal(await hash(olderBinary), olderSha256)
57 }
58 await run(['updates:candidates', store, ...flags, '--store-sha256', '0'.repeat(64)], 1)
59 const original = await readFile(join(prepared.candidate.directory, 'stage.json'))
60 await writeFile(join(prepared.candidate.directory, 'image.bin'), Buffer.alloc(fixture.image.length, 0xff))
61 const damaged = await run(['updates:candidates', store, ...pinned])
62 assert.equal(damaged.candidate.integrity, 'mismatch'); assert.equal(damaged.candidate.currentTrustValidated, false)
63 await writeFile(join(prepared.candidate.directory, 'image.bin'), fixture.image)
64 const retired = await run(['updates:retire', store, ...pinned, '--candidate', prepared.candidate.id])
65 assert.equal(retired.candidate, null); assert.equal(retired.retainedCandidates, 1); assert.equal(retired.store.releaseFloor, 9)
66 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original)
67 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
68 await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1)
69 const stopped = await run(['updates:candidates', store, ...pinned])
70 assert.equal(stopped.candidate.phase, 'reserved'); assert.equal(stopped.candidate.integrity, 'incomplete')
71 assert.equal(stopped.store.releaseFloor, 9); assert.equal(stopped.store.attempt.phase, 'maintenance')
72 await run(['updates:recover', store, ...pinned])
73 const recovered = await run(['updates:candidates', store, ...pinned])
74 assert.deepEqual(recovered.candidate, stopped.candidate); assert.equal(recovered.store.releaseFloor, 9)
75 await run(['updates:retire', store, ...pinned, '--candidate', stopped.candidate.id])
76 await writeFile(fixture.imagePath, fixture.image)
77 const fresh = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
78 assert.equal(fresh.retainedCandidates, 3); assert.notEqual(fresh.candidate.id, prepared.candidate.id)
79 assert.equal(fresh.candidate.integrity, 'recorded-bytes-match'); assert.equal(fresh.store.releaseFloor, 9)
80 assert.equal(fresh.candidate.currentTrustValidated, false); assert.equal(fresh.physicalDeploymentAllowed, false)
81 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'image.bin')), fixture.image)
82 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original)
83 }
84 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-owned-candidates-v1', sourceSha256, olderSha256, results, physicalDeploymentAllowed: false }, null, 2)) }
85 return { evidence: 'copied-standalone-owned-candidates-v1', operations: results.length, sourceSha256, olderSha256, physicalDeploymentAllowed: false }
86}
87if (import.meta.main) {
88 if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [OLDER_BINARY]')
89 console.log(JSON.stringify(await qualifyPortableCandidates(resolve(process.argv[2]!), resolve(process.argv[3]!), process.argv[4] ? resolve(process.argv[4]) : undefined)))
90}
tests/fixtures/updates/qualify-preparation.tsadded+61-0
Changes to tests/fixtures/updates/qualify-preparation.ts
@@ -0,0 +1,61 @@
1import assert from 'node:assert/strict'
2import { constants } from 'node:fs'
3import { createHash } from 'node:crypto'
4import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
5import { join, resolve } from 'node:path'
6import { platform, repository, target } from './repository'
7
8async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
9
10/** Exercise actual copied standalone commands. Signed payloads are synthetic and are never executed. */
11export async function qualifyPortablePreparation(directory: string, source: string) {
12 await mkdir(directory, { mode: 0o700 })
13 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
14 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
15 assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256)
16 const repo = join(directory, 'repo'), fixture = await repository(repo, { rotate: true, custom: { releaseSequence: 9 } })
17 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store'), output = join(directory, 'candidate')
18 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
19 async function run(args: string[], expected = 0) {
20 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
21 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
22 try {
23 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
24 results.push({ command: args[0]!, exit, stdout, stderr }); assert.equal(exit, expected)
25 if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure\n') }
26 return stdout ? JSON.parse(stdout) : undefined
27 }
28 finally { clearTimeout(timer) }
29 }
30 try {
31 const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'])
32 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
33 const created = await run(['updates:store-init', ...flags, '--output', store])
34 const pinned = [...flags, '--store-sha256', created.storeSha256]
35 const repositoryFlags = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']
36 await run(['updates:prepare', store, ...pinned, '--repository', repo, '--output', join(directory, 'missing-clock')], 1)
37 const prepared = await run(['updates:prepare', store, ...pinned, ...repositoryFlags, '--output', output])
38 assert.equal(prepared.store.releaseFloor, 9); assert.equal(prepared.store.attempt.phase, 'verified')
39 assert.equal(prepared.candidate.sha256, fixture.imageHash); assert.equal(prepared.trustStatePersisted, true); assert.equal(prepared.physicalDeploymentAllowed, false)
40 const root = prepared.store.metadata.find((row: { role: string }) => row.role === 'root')
41 assert.equal(root.version, 2); assert.equal(prepared.candidate.trustedRootSha256, root.sha256)
42 assert.deepEqual(await run(['updates:inspect', output, '--target', target, '--platform', platform, '--root-sha256', root.sha256, '--minimum-release-sequence', '9']), prepared.candidate)
43 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
44 await run(['updates:prepare', store, ...pinned, ...repositoryFlags, '--output', join(directory, 'bad-image')], 1)
45 const stopped = await run(['updates:store', store, ...pinned])
46 assert.equal(stopped.releaseFloor, 9); assert.equal(stopped.attempt.phase, 'maintenance')
47 const recovered = await run(['updates:recover', store, ...pinned])
48 assert.equal(recovered.releaseFloor, 9); assert.equal(recovered.attempt.phase, 'recovered'); assert.equal(Object.hasOwn(recovered, 'candidate'), false); assert.equal(recovered.physicalDeploymentAllowed, false)
49 await writeFile(fixture.imagePath, fixture.image)
50 const fresh = await run(['updates:prepare', store, ...pinned, ...repositoryFlags, '--output', join(directory, 'fresh-candidate')])
51 assert.equal(fresh.candidate.sha256, fixture.imageHash); assert.equal(fresh.store.releaseFloor, 9); assert.equal(fresh.physicalDeploymentAllowed, false)
52 await run(['updates:prepare', store, ...flags, '--store-sha256', '0'.repeat(64), ...repositoryFlags, '--output', join(directory, 'wrong-pin')], 1)
53 assert.deepEqual(await run(['updates:store', store, ...pinned]), fresh.store)
54 }
55 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-preparation-v1', sourceSha256, results, physicalDeploymentAllowed: false }, null, 2)) }
56 return { evidence: 'copied-standalone-preparation-v1', operations: results.length, sourceSha256, physicalDeploymentAllowed: false }
57}
58if (import.meta.main) {
59 if (process.argv.length !== 4) throw new Error('Use qualify-preparation.ts NEW_DIRECTORY COMPILED_BINARY')
60 console.log(JSON.stringify(await qualifyPortablePreparation(resolve(process.argv[2]!), resolve(process.argv[3]!))))
61}
tests/fixtures/updates/repository.tsmodified+8-7
Changes to tests/fixtures/updates/repository.ts
@@ -19,18 +19,18 @@ export function signer(nonCanonical = false) {
1919}
2020
2121/** Generated throwaway keys stay in memory. These bytes are a test payload, not firmware. */
22export 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, badRotation?: boolean, badTargetsSignature?: boolean, nonCanonicalRoot?: boolean, nonCanonicalRotation?: boolean, nonCanonicalDelegation?: boolean } = {}) {
22export 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 } = {}) {
2323 await mkdir(join(directory, 'metadata'), { recursive: true })
2424 await mkdir(join(directory, 'targets/runtime'), { recursive: true })
2525 const keys = { root: signer(options.nonCanonicalRoot), targets: signer(), snapshot: signer(), timestamp: signer() }
26 const root = new Root({ ...fields, ...options.metadataFields?.root })
26 const root = new Root({ ...fields, ...options.metadataFields?.root, consistentSnapshot: options.consistentSnapshot ?? true })
2727 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const) root.addKey(keys[role].key, role)
2828 const rootMetadata = new Metadata(root)
2929 rootMetadata.sign(keys.root.sign)
3030 const rootBytes = Buffer.from(JSON.stringify(rootMetadata.toJSON()))
3131 await writeFile(join(directory, 'trusted-root.json'), rootBytes)
3232 if (options.rotate) {
33 const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2, ...options.metadataFields?.rotatedRoot })
33 const nextKey = signer(options.nonCanonicalRotation), next = new Root({ ...fields, version: 2, ...options.metadataFields?.rotatedRoot, consistentSnapshot: options.consistentSnapshot ?? true })
3434 next.addKey(nextKey.key, 'root')
3535 for (const role of ['targets', 'snapshot', 'timestamp'] as const) next.addKey(keys[role].key, role)
3636 const rotated = new Metadata(next)
@@ -54,8 +54,9 @@ export async function repository(directory: string, options: { custom?: Record<s
5454 const timestamp = new Metadata(new Timestamp({ ...fields, expires: options.expires ?? fields.expires, ...options.metadataFields?.timestamp, snapshotMeta: new MetaFile({ version: 1, length: snapshotBytes.length, hashes: { sha256: digest(snapshotBytes) } }) }))
5555 timestamp.sign(keys.timestamp.sign)
5656 await writeFile(join(directory, 'metadata/timestamp.json'), JSON.stringify(timestamp.toJSON()))
57 await writeFile(join(directory, 'metadata/1.snapshot.json'), snapshotBytes)
58 await writeFile(join(directory, 'metadata/1.targets.json'), targetsBytes)
59 await writeFile(join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`), image)
60 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath: join(directory, 'targets/runtime', `${imageHash}.pico2-bench.bin`) }
57 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/snapshot.json' : 'metadata/1.snapshot.json'), snapshotBytes)
58 await writeFile(join(directory, options.consistentSnapshot === false ? 'metadata/targets.json' : 'metadata/1.targets.json'), targetsBytes)
59 const imagePath = join(directory, 'targets/runtime', options.consistentSnapshot === false ? 'pico2-bench.bin' : `${imageHash}.pico2-bench.bin`)
60 await writeFile(imagePath, image)
61 return { rootPath: join(directory, 'trusted-root.json'), rootSha256: digest(rootBytes), imageHash, image, custom, imagePath }
6162}
tests/fixtures/updates/store-interruptions.tsmodified+62-20
Changes to tests/fixtures/updates/store-interruptions.ts
@@ -3,12 +3,13 @@ import { spawnSync } from 'node:child_process'
33import { mkdir, readFile, writeFile } from 'node:fs/promises'
44import { join, resolve } from 'node:path'
55import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
6import { createRuntimeTrustStore, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
6import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
7import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store'
78import { platform, repository, target } from './repository'
89
910type Reference = Parameters<typeof inspectRuntimeTrustStore>[0]
10type Cut = 'input-committed' | 'metadata-committed'
11interface WorkerInput { reference: Reference, repository: string, cut: Cut }
11type Cut = RuntimeUpdateProgress['phase']
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string, managed?: boolean, candidateId?: string }
1213const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
1314
1415async function worker(mode: string, file: string) {
@@ -23,27 +24,35 @@ async function worker(mode: string, file: string) {
2324 return
2425 }
2526 if (mode !== 'hold') throw new Error('Unknown interruption worker mode')
26 await refreshRuntimeTrustStore({ ...input.reference, repository: input.repository, clock, async onProgress(event) {
27 const options = { ...input.reference, repository: input.repository, clock, async onProgress(event: Readonly<RuntimeUpdateProgress>) {
2728 if (event.phase !== input.cut || event.ordinal !== (input.cut === 'input-committed' ? 1 : 0)) return
2829 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
2930 await Bun.stdin.text()
30 } })
31 } }
32 if (input.candidateId) await retireRuntimeFirmwareCandidate({ ...options, candidateId: input.candidateId })
33 else if (input.managed) await reserveRuntimeFirmwareCandidate(options)
34 else if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
35 else await refreshRuntimeTrustStore(options)
3136 throw new Error('Interruption worker unexpectedly finished')
3237}
3338
3439/** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */
35export async function qualifyStoreInterruptions(directory: string) {
40async function qualifyInterruptions(directory: string, operation: 'refresh' | 'prepare' | 'reserve') {
3641 await mkdir(directory, { mode: 0o700 })
3742 const results = []
38 for (const cut of ['input-committed', 'metadata-committed'] as const) {
43 const managed = operation === 'reserve', preparation = operation !== 'refresh'
44 const cuts: Cut[] = managed ? ['candidate-reserved', 'image-written', 'candidate-completed', 'candidate-recorded', 'candidate-retired'] : preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
45 for (const cut of cuts) {
3946 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
4047 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
4148 const enrollmentDirectory = join(root, 'enrollment')
4249 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
4350 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
4451 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
52 const prior = cut === 'candidate-retired' ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : null
4553 const file = join(root, 'worker.json')
46 await writeFile(file, JSON.stringify({ reference, repository: source, cut } satisfies WorkerInput), { mode: 0o600 })
54 let output = prior?.candidate?.directory ?? (!managed && preparation ? join(root, 'candidate') : undefined)
55 await writeFile(file, JSON.stringify({ reference, repository: source, cut, output: managed ? undefined : output, managed, candidateId: prior?.candidate?.id } satisfies WorkerInput), { mode: 0o600 })
4756 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
4857 const timer = setTimeout(() => child.kill('SIGKILL'), 15000)
4958 const reader = child.stdout.getReader(), decoder = new TextDecoder()
@@ -64,15 +73,42 @@ export async function qualifyStoreInterruptions(directory: string) {
6473 const exit = await child.exited, signal = child.signalCode
6574 assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0)
6675 const interrupted = await inspectRuntimeTrustStore(reference)
67 assert.equal(interrupted.attempt?.phase, 'pending')
68 assert.equal(interrupted.generation, cut === 'input-committed' ? 0 : 1)
69 const recovered = await recoverRuntimeTrustStore(reference)
70 assert.equal(recovered.generation, 1); assert.equal(recovered.attempt?.phase, 'recovered')
71 assert.equal(recovered.releaseFloor, 7)
72 const fresh = await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
73 assert.equal(fresh.generation, 3); assert.equal(fresh.releaseFloor, 9)
76 assert.equal(interrupted.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'pending')
77 assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1)
78 assert.equal(interrupted.releaseFloor, preparation ? 9 : 7)
79 const candidateBefore = managed ? await inspectRuntimeFirmwareCandidates(reference) : null
80 if (managed && cut !== 'candidate-retired') {
81 assert.equal(candidateBefore!.candidate!.phase, cut === 'candidate-recorded' ? 'prepared' : 'reserved')
82 assert.equal(candidateBefore!.candidate!.currentTrustValidated, false)
83 output = candidateBefore!.candidate!.directory
84 }
85 if (cut === 'candidate-retired') assert.equal(candidateBefore!.candidate, null)
86 const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false
87 if (output) {
88 if (cut !== 'candidate-reserved') assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
89 assert.equal(candidateStagePresent, ['candidate-completed', 'candidate-recorded', 'candidate-retired'].includes(cut))
90 }
91 const recovered = cut === 'candidate-retired' ? await inspectRuntimeTrustStore(reference) : await recoverRuntimeTrustStore(reference)
92 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'recovered')
93 assert.equal(recovered.releaseFloor, preparation ? 9 : 7)
94 assert.equal(Object.hasOwn(recovered, 'candidate'), false)
95 if (managed) {
96 assert.deepEqual((await inspectRuntimeFirmwareCandidates(reference)).candidate, candidateBefore!.candidate)
97 if (candidateBefore!.candidate) await retireRuntimeFirmwareCandidate({ ...reference, candidateId: candidateBefore!.candidate.id })
98 }
99 const completed = managed ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
100 const fresh = completed.schema === 'drone-runtime-prepared-image-v1' || completed.schema === 'drone-runtime-candidates-v1' ? completed.store : completed
101 if (completed.schema === 'drone-runtime-candidates-v1') {
102 assert.equal(completed.retainedCandidates, 2)
103 assert.equal(completed.candidate!.integrity, 'recorded-bytes-match')
104 assert.notEqual(completed.candidate!.id, candidateBefore!.candidate?.id ?? prior!.candidate!.id)
105 if (candidateStagePresent) assert.deepEqual(await readFile(join(output!, 'image.bin')), fixture.image)
106 }
107 assert.equal(fresh.generation >= interrupted.generation, true)
108 if (!preparation) assert.equal(fresh.generation, 3)
109 assert.equal(fresh.releaseFloor, 9)
74110 assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false)
75 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, interrupted, recovered, fresh })
111 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, candidateBefore, interrupted, recovered, fresh })
76112 }
77113 finally {
78114 clearTimeout(timer)
@@ -81,14 +117,20 @@ export async function qualifyStoreInterruptions(directory: string) {
81117 reader.releaseLock()
82118 await writeFile(join(root, 'worker.stdout'), stdout)
83119 await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text())
84 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', results, physicalDeploymentAllowed: false }, null, 2))
120 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation, results, physicalDeploymentAllowed: false }, null, 2))
85121 }
86122 }
87 return { evidence: 'host-process-interruption-only', cuts: results.length, physicalDeploymentAllowed: false }
123 return { evidence: 'host-process-interruption-only', operation, cuts: results.length, physicalDeploymentAllowed: false }
88124}
89125
126export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, 'refresh') }
127export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, 'prepare') }
128export async function qualifyCandidateInterruptions(directory: string) { return qualifyInterruptions(directory, 'reserve') }
129
90130if (import.meta.main) {
91 if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
131 if (process.argv.length === 4 && process.argv[2] === 'reserve') console.log(JSON.stringify(await qualifyCandidateInterruptions(resolve(process.argv[3]!))))
132 else if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
133 else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
92134 else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!))))
93 else throw new Error('Use store-interruptions.ts NEW_DIRECTORY')
135 else throw new Error('Use store-interruptions.ts [prepare|reserve] NEW_DIRECTORY')
94136}
tests/unit/updates-candidates-cli.test.tsadded+43-0
Changes to tests/unit/updates-candidates-cli.test.ts
@@ -0,0 +1,43 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, readFile, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
8import { platform, repository, target } from '../fixtures/updates/repository'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('native candidate commands require pinned trust and clock, retain identity and preserve explicit retirement semantics', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-cli-')); roots.push(root)
15 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
16 const enrollment = join(root, 'enrollment'), directory = join(root, 'store')
17 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollment })
18 const created = await createRuntimeTrustStore({ enrollmentDirectory: enrollment, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
19 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256]
20 const reserve = await command(['updates:reserve', directory, ...flags, '--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']).withTimeout(20000)
21 expect(reserve.exitCode).toBe(0)
22 const value = JSON.parse(reserve.stdout)
23 expect(value.candidate.phase).toBe('prepared')
24 expect(value.candidate.integrity).toBe('recorded-bytes-match')
25 expect(value.candidate.currentTrustValidated).toBe(false)
26 expect(value.store.releaseFloor).toBe(9)
27 expect(value.physicalDeploymentAllowed).toBe(false)
28 const status = await command(['updates:candidates', directory, ...flags]).withTimeout(20000)
29 expect(status.exitCode).toBe(0)
30 expect(JSON.parse(status.stdout)).toEqual(value)
31 const missingClock = await command(['updates:reserve', directory, ...flags, '--repository', source]).withTimeout(20000)
32 expect(missingClock.exitCode).toBe(1)
33 expect(missingClock.stdout).toBe('')
34 expect(missingClock.stderr).toBe('Runtime update store unavailable: invalid trust, clock, state or storage failure\n')
35 const wrong = await command(['updates:retire', directory, ...flags, '--candidate', '0'.repeat(32)]).withTimeout(20000)
36 expect(wrong.exitCode).toBe(1)
37 expect(wrong.stdout).toBe('')
38 const retired = await command(['updates:retire', directory, ...flags, '--candidate', value.candidate.id]).withTimeout(20000)
39 expect(retired.exitCode).toBe(0)
40 expect(JSON.parse(retired.stdout).candidate).toBeNull()
41 expect(JSON.parse(retired.stdout).store.releaseFloor).toBe(9)
42 expect(await readFile(join(value.candidate.directory, 'image.bin'))).toEqual(fixture.image)
43}, 120000)
tests/unit/updates-candidates.test.tsadded+188-0
Changes to tests/unit/updates-candidates.test.ts
@@ -0,0 +1,188 @@
1import { afterEach, expect, test } from 'bun:test'
2import { Database } from 'bun:sqlite'
3import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
8import { platform, repository, target } from '../fixtures/updates/repository'
9import { qualifyCandidateInterruptions } from '../fixtures/updates/store-interruptions'
10import { epochRepository } from '../fixtures/updates/epochs'
11
12const roots: string[] = []
13afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
14const clock = { source: 'independent-test-clock', read: () => new Date('2030-01-01T00:00:00Z') }
15async function setup() {
16 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-')); roots.push(root)
17 const source = join(root, 'repository'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
18 const enrollmentDirectory = join(root, 'enrollment')
19 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
20 const directory = join(root, 'store'), created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
21 return { root, source, fixture, reference: { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 } }
22}
23
24test('the pinned store exclusively owns a prepared candidate and retains its authenticated identity across reopening', async () => {
25 const item = await setup(), before = await inspectRuntimeTrustStore(item.reference)
26 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate).toBeNull()
27 const result = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
28 expect(result.schema).toBe('drone-runtime-candidates-v1')
29 expect(result.stateFormat).toBe(2)
30 expect(result.store.storeId).toBe(before.storeId)
31 expect(result.store.releaseFloor).toBe(9)
32 expect(result.store.generation).toBe(3)
33 expect(result.store.attempt?.phase).toBe('verified')
34 expect(result.retainedCandidates).toBe(1)
35 expect(result.candidate?.phase).toBe('prepared')
36 expect(result.candidate?.integrity).toBe('recorded-bytes-match')
37 expect(result.candidate?.description.sha256).toBe(item.fixture.imageHash)
38 expect(result.candidate?.description.releaseSequence).toBe(9)
39 expect(result.candidate?.currentTrustValidated).toBe(false)
40 expect(result.physicalDeploymentAllowed).toBe(false)
41 expect(await readFile(join(result.candidate!.directory, 'image.bin'))).toEqual(item.fixture.image)
42 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(result)
43 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })).rejects.toThrow('active candidate')
44 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(result)
45})
46
47test('explicit retirement retains old bytes and floors while allowing a distinct new candidate identity', async () => {
48 const item = await setup()
49 const first = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
50 const candidate = first.candidate!, original = await readFile(join(candidate.directory, 'stage.json'))
51 await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: '0'.repeat(32) })).rejects.toThrow('active candidate')
52 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(first)
53 const retired = await retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })
54 expect(retired.candidate).toBeNull()
55 expect(retired.retainedCandidates).toBe(1)
56 expect(retired.history.map(row => ({ id: row.id, phase: row.phase, sha256: row.description.sha256 }))).toEqual([{ id: candidate.id, phase: 'retired', sha256: item.fixture.imageHash }])
57 expect(retired.store.releaseFloor).toBe(9)
58 expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original)
59 expect(await readFile(join(candidate.directory, 'image.bin'))).toEqual(item.fixture.image)
60 const next = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
61 expect(next.candidate?.id).not.toBe(candidate.id)
62 expect(next.candidate?.integrity).toBe('recorded-bytes-match')
63 expect(next.retainedCandidates).toBe(2)
64 expect(next.store.releaseFloor).toBe(9)
65 expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original)
66 await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })).rejects.toThrow('active candidate')
67 expect(await inspectRuntimeFirmwareCandidates(item.reference)).toEqual(next)
68})
69
70test('a completed stage marker after an interrupted reservation cannot promote itself during metadata recovery', async () => {
71 const item = await setup(), interruption = new Error('Stop after durable candidate files')
72 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock, onProgress(event) {
73 if (event.phase === 'candidate-completed') throw interruption
74 } })).rejects.toBe(interruption)
75 const stopped = await inspectRuntimeFirmwareCandidates(item.reference), candidate = stopped.candidate!
76 expect(candidate.phase).toBe('reserved')
77 expect(candidate.integrity).toBe('incomplete')
78 expect(JSON.parse(await readFile(join(candidate.directory, 'stage.json'), 'utf8')).sha256).toBe(item.fixture.imageHash)
79 expect(stopped.store.releaseFloor).toBe(9)
80 expect(stopped.store.attempt?.phase).toBe('maintenance')
81 await expect(retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })).rejects.toThrow('Resolve the metadata attempt')
82 await recoverRuntimeTrustStore(item.reference)
83 const recovered = await inspectRuntimeFirmwareCandidates(item.reference)
84 expect(recovered.candidate).toEqual(candidate)
85 expect(recovered.store.attempt?.phase).toBe('recovered')
86 expect(recovered.store.releaseFloor).toBe(9)
87 expect(recovered.physicalDeploymentAllowed).toBe(false)
88 const original = await readFile(join(candidate.directory, 'stage.json'))
89 const retired = await retireRuntimeFirmwareCandidate({ ...item.reference, candidateId: candidate.id })
90 expect(retired.candidate).toBeNull()
91 expect(await readFile(join(candidate.directory, 'stage.json'))).toEqual(original)
92})
93
94test('a rejected actual image retains the reserved identity and accepted floors without creating a prepared artifact', async () => {
95 const item = await setup()
96 await writeFile(item.fixture.imagePath, Buffer.alloc(item.fixture.image.length, 0xff))
97 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })).rejects.toThrow()
98 const stopped = await inspectRuntimeFirmwareCandidates(item.reference)
99 expect(stopped.candidate?.phase).toBe('reserved')
100 expect(stopped.candidate?.integrity).toBe('incomplete')
101 expect(stopped.store.releaseFloor).toBe(9)
102 expect(stopped.store.attempt?.phase).toBe('maintenance')
103 expect(await Bun.file(join(stopped.candidate!.directory, 'stage.json')).exists()).toBe(false)
104 await recoverRuntimeTrustStore(item.reference)
105 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.phase).toBe('reserved')
106})
107
108test('missing or corrupt owned bytes produce actionable integrity status and never current-trust authorization', async () => {
109 const item = await setup(), prepared = await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
110 const candidate = prepared.candidate!, image = join(candidate.directory, 'image.bin')
111 await writeFile(image, Buffer.alloc(item.fixture.image.length, 0xff))
112 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.integrity).toBe('mismatch')
113 await rm(image)
114 const missing = await inspectRuntimeFirmwareCandidates(item.reference)
115 expect(missing.candidate?.integrity).toBe('unavailable')
116 expect(missing.candidate?.phase).toBe('prepared')
117 expect(missing.candidate?.currentTrustValidated).toBe(false)
118 expect(missing.store.releaseFloor).toBe(9)
119 expect(missing.physicalDeploymentAllowed).toBe(false)
120 await writeFile(image, item.fixture.image, { mode: 0o600 })
121 await writeFile(join(candidate.directory, 'repository/metadata/1.targets.json'), '{}\n')
122 expect((await inspectRuntimeFirmwareCandidates(item.reference)).candidate?.integrity).not.toBe('recorded-bytes-match')
123})
124
125test('actual killed candidate writers preserve reserved, prepared and retired states with concurrent writers excluded', async () => {
126 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-cuts-')); roots.push(root)
127 const result = await qualifyCandidateInterruptions(join(root, 'campaign'))
128 expect(result.cuts).toBe(5)
129 expect(result.physicalDeploymentAllowed).toBe(false)
130}, 90000)
131
132test('managed candidates reject repository overlap before advancing state and preserve a foreign state format unchanged', async () => {
133 const item = await setup(), initial = await inspectRuntimeTrustStore(item.reference)
134 for (const repository of [item.root, item.reference.directory]) {
135 await expect(reserveRuntimeFirmwareCandidate({ ...item.reference, repository, clock })).rejects.toThrow('separate')
136 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(initial)
137 }
138 await reserveRuntimeFirmwareCandidate({ ...item.reference, repository: item.source, clock })
139 const file = join(item.reference.directory, 'state.sqlite'), db = new Database(file, { create: false, readwrite: true })
140 try { db.exec('PRAGMA user_version=99') }
141 finally { db.close() }
142 const original = await readFile(file)
143 await expect(inspectRuntimeFirmwareCandidates(item.reference)).rejects.toThrow('schema')
144 expect(await readFile(file)).toEqual(original)
145})
146
147test('migration retains a prior rotated-root epoch, metadata history and clock/release floors across later rejections', async () => {
148 const root = await mkdtemp(join(tmpdir(), 'drone-owned-candidate-migration-')); roots.push(root)
149 const source = join(root, 'repository'), fixture = await epochRepository(source)
150 const enrollmentDirectory = join(root, 'enrollment'), directory = join(root, 'store')
151 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
152 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: directory })
153 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory, storeSha256: created.storeSha256 }
154 await fixture.publish(1, 100, 12)
155 await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
156 const nextClock = { source: clock.source, read: () => new Date('2030-01-02T00:00:00Z') }
157 const image = await fixture.publish(2, 1, 12)
158 const before = await refreshRuntimeTrustStore({ ...reference, repository: source, clock: nextClock })
159 expect(before.epoch).toBe(1)
160 expect(before.generation).toBe(7)
161 expect(before.releaseFloor).toBe(12)
162 expect((await inspectRuntimeFirmwareCandidates(reference)).stateFormat).toBe(1)
163 const candidateClock = { source: clock.source, read: () => new Date('2030-01-03T00:00:00Z') }
164 const prepared = await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: candidateClock })
165 expect(prepared.stateFormat).toBe(2)
166 expect(prepared.store.storeId).toBe(before.storeId)
167 expect(prepared.store.storeSha256).toBe(before.storeSha256)
168 expect(prepared.store.enrollmentSha256).toBe(before.enrollmentSha256)
169 expect(prepared.store.metadata).toEqual(before.metadata)
170 expect(prepared.store.retainedMetadataRecords).toBe(before.retainedMetadataRecords)
171 expect(prepared.store.epoch).toBe(1)
172 expect(prepared.store.releaseFloor).toBe(12)
173 expect(prepared.store.clockFloorMs).toBe(Date.parse('2030-01-03T00:00:00Z'))
174 expect(prepared.candidate?.description.rootVersion).toBe(2)
175 const retired = await retireRuntimeFirmwareCandidate({ ...reference, candidateId: prepared.candidate!.id })
176 await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock })).rejects.toThrow('clock')
177 expect(await inspectRuntimeFirmwareCandidates(reference)).toEqual(retired)
178 await fixture.publish(2, 2, 11)
179 await expect(reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock: candidateClock })).rejects.toThrow('below the trusted minimum')
180 await recoverRuntimeTrustStore(reference)
181 const recovered = await inspectRuntimeFirmwareCandidates(reference)
182 expect(recovered.store.releaseFloor).toBe(12)
183 expect(recovered.store.epoch).toBe(1)
184 expect(recovered.store.attempt?.phase).toBe('recovered')
185 expect(recovered.candidate).toBeNull()
186 expect(recovered.history[0]?.description.releaseSequence).toBe(12)
187 expect(await readFile(join(prepared.candidate!.directory, 'image.bin'))).toEqual(image.image)
188})
tests/unit/updates-preparation-cli.test.tsadded+45-0
Changes to tests/unit/updates-preparation-cli.test.ts
@@ -0,0 +1,45 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
7import { createRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
8import { repository, platform, target } from '../fixtures/updates/repository'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('native preparation requires protected trust and an explicit clock, authenticates the image and preserves failure status', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-cli-')); roots.push(root)
15 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
16 const enrollmentDirectory = join(root, 'enrollment')
17 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
18 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
19 const flags = ['--enrollment', enrollmentDirectory, '--enrollment-sha256', enrolled.enrollmentSha256, '--store-sha256', created.storeSha256]
20 const sourceFlags = ['--repository', source, '--trusted-system-clock-source', 'operator-managed-test-clock']
21 const prepared = await command(['updates:prepare', join(root, 'store'), ...flags, ...sourceFlags, '--output', join(root, 'candidate')]).withTimeout(20000)
22 expect(prepared.exitCode).toBe(0)
23 const value = JSON.parse(prepared.stdout)
24 expect(value.store.releaseFloor).toBe(9)
25 expect(value.candidate.sha256).toBe(fixture.imageHash)
26 expect(value.trustStatePersisted).toBe(true)
27 expect(value.physicalDeploymentAllowed).toBe(false)
28 const inspected = await command(['updates:inspect', join(root, 'candidate'), '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '9']).withTimeout(20000)
29 expect(inspected.exitCode).toBe(0)
30 expect(JSON.parse(inspected.stdout)).toEqual(value.candidate)
31 const missingClock = await command(['updates:prepare', join(root, 'store'), ...flags, '--repository', source, '--output', join(root, 'missing-clock')]).withTimeout(20000)
32 expect(missingClock.exitCode).toBe(1)
33 expect(missingClock.stdout).toBe('')
34 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
35 const rejected = await command(['updates:prepare', join(root, 'store'), ...flags, ...sourceFlags, '--output', join(root, 'bad-image')]).withTimeout(20000)
36 expect(rejected.exitCode).toBe(1)
37 expect(rejected.stdout).toBe('')
38 expect(rejected.stderr).toBe('Runtime image preparation unavailable: invalid trust, clock, rejected image or output failure\n')
39 const status = await command(['updates:store', join(root, 'store'), ...flags]).withTimeout(20000)
40 expect(status.exitCode).toBe(0)
41 const stopped = JSON.parse(status.stdout)
42 expect(stopped.releaseFloor).toBe(9)
43 expect(stopped.attempt.phase).toBe('maintenance')
44 expect(stopped.physicalDeploymentAllowed).toBe(false)
45}, 120000)
tests/unit/updates-preparation.test.tsadded+163-0
Changes to tests/unit/updates-preparation.test.ts
@@ -0,0 +1,163 @@
1import { afterEach, expect, test } from 'bun:test'
2import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
6import { inspectStagedRuntimeFirmware } from '../../resources/functions/drone/updates/staging'
7import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../resources/functions/drone/updates/store'
8import { platform, repository, target } from '../fixtures/updates/repository'
9import { qualifyPreparationInterruptions } from '../fixtures/updates/store-interruptions'
10import { epochRepository } from '../fixtures/updates/epochs'
11
12const roots: string[] = []
13afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
14const clock = { source: 'test-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
15async function setup(options: Parameters<typeof repository>[1] = {}) {
16 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-update-')); roots.push(root)
17 const source = join(root, 'repo'), fixture = await repository(source, { ...options, custom: { releaseSequence: 9, ...options.custom } })
18 const enrollmentDirectory = join(root, 'enrollment')
19 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
20 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
21 return { root, source, fixture, reference: { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }, output: join(root, 'candidate') }
22}
23
24test('protected preparation authenticates actual image bytes and writes an independently verifiable signed candidate', async () => {
25 const item = await setup()
26 const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
27 expect(result.schema).toBe('drone-runtime-prepared-image-v1')
28 expect(result.store.generation).toBe(3)
29 expect(result.store.releaseFloor).toBe(9)
30 expect(result.store.attempt?.phase).toBe('verified')
31 expect(result.candidate.sha256).toBe(item.fixture.imageHash)
32 expect(result.candidate.length).toBe(item.fixture.image.length)
33 expect(result.trustStatePersisted).toBe(true)
34 expect(result.physicalDeploymentAllowed).toBe(false)
35 expect(result.candidate.trustStatePersisted).toBe(false)
36 expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: item.fixture.rootSha256, minimumReleaseSequence: 9 })).toEqual(result.candidate)
37 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(result.store)
38})
39
40test('a rotated protected root and cached metadata produce a candidate without depending on the initial root chain', async () => {
41 const item = await setup({ rotate: true })
42 const current = await refreshRuntimeTrustStore({ ...item.reference, repository: item.source, clock })
43 const protectedRoot = current.metadata.find(row => row.role === 'root')!
44 expect(protectedRoot.version).toBe(2)
45 const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
46 expect(result.candidate.trustedRootSha256).toBe(protectedRoot.sha256)
47 expect(result.candidate.rootVersion).toBe(2)
48 expect(result.store.releaseFloor).toBe(9)
49 await rename(item.source, join(item.root, 'offline'))
50 expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: protectedRoot.sha256, minimumReleaseSequence: 9 })).toEqual(result.candidate)
51 await expect(inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: item.fixture.rootSha256, minimumReleaseSequence: 9 })).rejects.toThrow('independent pin')
52})
53
54test('an actual output collision after metadata acceptance preserves the release floor and the competing files', async () => {
55 const item = await setup()
56 const failure = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock, async onProgress(event) {
57 if (event.phase === 'metadata-committed' && event.ordinal === 2) {
58 await mkdir(item.output)
59 await writeFile(join(item.output, 'contender.txt'), 'Preserve the competing output\n')
60 }
61 } }).then(() => undefined, error => error)
62 expect(failure.code).toBe('EEXIST')
63 expect(await readFile(join(item.output, 'contender.txt'), 'utf8')).toBe('Preserve the competing output\n')
64 await expect(stat(join(item.output, 'stage.json'))).rejects.toHaveProperty('code', 'ENOENT')
65 const stopped = await inspectRuntimeTrustStore(item.reference)
66 expect(stopped.generation).toBe(3)
67 expect(stopped.releaseFloor).toBe(9)
68 expect(stopped.attempt?.phase).toBe('maintenance')
69 const recovered = await recoverRuntimeTrustStore(item.reference)
70 expect(recovered.releaseFloor).toBe(9)
71 expect(recovered.attempt?.phase).toBe('recovered')
72 expect(recovered.physicalDeploymentAllowed).toBe(false)
73})
74
75test('candidate paths cannot overlap the repository, enrollment or protocol store', async () => {
76 const item = await setup(), initial = await inspectRuntimeTrustStore(item.reference)
77 for (const source of [item.source, item.reference.enrollmentDirectory, item.reference.directory]) {
78 await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: join(source, 'candidate'), clock })).rejects.toThrow('separate')
79 expect(await inspectRuntimeTrustStore(item.reference)).toEqual(initial)
80 }
81})
82
83test('newer signed metadata cannot prepare a release below the protected floor or overwrite an earlier candidate', async () => {
84 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-floor-')); roots.push(root)
85 const source = join(root, 'repo'), fixture = await epochRepository(source)
86 const enrollmentDirectory = join(root, 'enrollment')
87 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
88 const store = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
89 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: store.storeSha256 }
90 const original = await fixture.publish(1, 1, 9), output = join(root, 'candidate')
91 await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, output, clock })
92 const receipt = await readFile(join(output, 'stage.json'))
93 await fixture.publish(1, 2, 8)
94 await expect(prepareRuntimeFirmwareUpdate({ ...reference, repository: source, output: join(root, 'downgrade'), clock })).rejects.toThrow('below the trusted minimum')
95 const stopped = await inspectRuntimeTrustStore(reference)
96 expect(stopped.releaseFloor).toBe(9)
97 expect(stopped.metadata.find(row => row.role === 'targets')?.version).toBe(2)
98 expect(stopped.attempt?.phase).toBe('maintenance')
99 expect(await readFile(join(output, 'image.bin'))).toEqual(original.image)
100 expect(await readFile(join(output, 'stage.json'))).toEqual(receipt)
101 const recovered = await recoverRuntimeTrustStore(reference)
102 expect(recovered.releaseFloor).toBe(9)
103 expect(recovered.attempt?.phase).toBe('recovered')
104 expect(recovered.physicalDeploymentAllowed).toBe(false)
105})
106
107test('bad image bytes cannot discard accepted metadata or release floors, including after offline recovery', async () => {
108 const item = await setup()
109 await writeFile(item.fixture.imagePath, Buffer.alloc(item.fixture.image.length, 0xff))
110 await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })).rejects.toThrow()
111 const stopped = await inspectRuntimeTrustStore(item.reference)
112 expect(stopped.generation).toBe(3)
113 expect(stopped.releaseFloor).toBe(9)
114 expect(stopped.attempt?.phase).toBe('maintenance')
115 await expect(stat(item.output)).rejects.toHaveProperty('code', 'ENOENT')
116 await rename(item.source, join(item.root, 'offline'))
117 const recovered = await recoverRuntimeTrustStore(item.reference)
118 expect(recovered.generation).toBe(3)
119 expect(recovered.releaseFloor).toBe(9)
120 expect(recovered.attempt?.phase).toBe('recovered')
121 expect(recovered.physicalDeploymentAllowed).toBe(false)
122 await rename(join(item.root, 'offline'), item.source)
123 await writeFile(item.fixture.imagePath, item.fixture.image)
124 const fresh = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
125 expect(fresh.store.releaseFloor).toBe(9)
126 expect(fresh.candidate.sha256).toBe(item.fixture.imageHash)
127 expect(fresh.physicalDeploymentAllowed).toBe(false)
128})
129
130test('real preparation process cuts retain accepted floors and never turn recovery into candidate authorization', async () => {
131 const root = await mkdtemp(join(tmpdir(), 'drone-prepare-host-cuts-')); roots.push(root)
132 const result = await qualifyPreparationInterruptions(join(root, 'campaign'))
133 expect(result.cuts).toBe(2)
134 expect(result.physicalDeploymentAllowed).toBe(false)
135}, 45000)
136
137test('protected preparation also archives repositories without consistent-snapshot filename prefixes', async () => {
138 const item = await setup({ rotate: true, consistentSnapshot: false })
139 const result = await prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock })
140 const current = await inspectRuntimeTrustStore(item.reference), root = current.metadata.find(row => row.role === 'root')!
141 expect(root.version).toBe(2)
142 expect(result.candidate.sha256).toBe(item.fixture.imageHash)
143 expect(await inspectStagedRuntimeFirmware({ directory: item.output, target, platform, trustedRootSha256: root.sha256, minimumReleaseSequence: current.releaseFloor })).toEqual(result.candidate)
144 expect(result.physicalDeploymentAllowed).toBe(false)
145})
146
147test('actual candidate-image corruption after file sync is rejected by readback before a completion marker', async () => {
148 const item = await setup(), damaged = Buffer.alloc(item.fixture.image.length, 0xff)
149 let changedAfterWrite = false
150 await expect(prepareRuntimeFirmwareUpdate({ ...item.reference, repository: item.source, output: item.output, clock, async onProgress(event) {
151 if (event.phase === 'image-written') {
152 await writeFile(join(item.output, 'image.bin'), damaged)
153 changedAfterWrite = true
154 }
155 } })).rejects.toThrow()
156 expect(changedAfterWrite).toBe(true)
157 expect(await readFile(join(item.output, 'image.bin'))).toEqual(damaged)
158 expect(await Bun.file(join(item.output, 'stage.json')).exists()).toBe(false)
159 const stopped = await inspectRuntimeTrustStore(item.reference)
160 expect(stopped.releaseFloor).toBe(9)
161 expect(stopped.attempt?.phase).toBe('maintenance')
162 expect(stopped.physicalDeploymentAllowed).toBe(false)
163})