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

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

.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -42,6 +42,8 @@ jobs:
4242 run: bun tests/fixtures/updates/qualify-store.ts "$RUNNER_TEMP/runtime-store-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4343 - name: Verify protected signed-image preparation with a copied standalone CLI
4444 run: bun tests/fixtures/updates/qualify-preparation.ts "$RUNNER_TEMP/runtime-preparation-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
45 - name: Verify owned candidate identity and retirement with a copied standalone CLI
46 run: bun tests/fixtures/updates/qualify-candidates.ts "$RUNNER_TEMP/runtime-candidates-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
4547 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4648 working-directory: ${{ runner.temp }}
4749 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+2-0
Changes to README.md
@@ -268,6 +268,8 @@ compatibility and externally supplied release floors. `updates:enroll` and
268268`updates:trust` preserve the initial root/policy record. The independently pinned
269269store retains verified metadata, clock/release floors and historical recovery;
270270`updates:prepare` authenticates actual image bytes under that protected state.
271`updates:reserve`, `updates:candidates` and `updates:retire` add durable candidate
272identity, recorded-byte status and explicit retirement while retaining files/history.
271273Physical logging/commissioning, key revocation/repair, installed/known-good slots
272274and image-bound boot/session health remain pending.
273275
app/Commands/UpdatesCandidates.tsadded+14-0
Changes to app/Commands/UpdatesCandidates.ts
@@ -0,0 +1,14 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { inspectRuntimeFirmwareCandidates } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:candidates <directory>',
8 description: 'Inspect owned candidate identity and recorded-byte integrity without granting selection authority',
9 options: storeReferenceFlags,
10 async handle(options, directory) {
11 try { await printAsync(JSON.stringify(await inspectRuntimeFirmwareCandidates(storeCommandReference(options, directory)))) }
12 catch { await storeCommandFailure() }
13 },
14})
app/Commands/UpdatesReserve.tsadded+17-0
Changes to app/Commands/UpdatesReserve.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { reserveRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:reserve <directory>',
8 description: 'Reserve an owned signed-image candidate in a pinned store without selecting or installing firmware',
9 options: { ...storeReferenceFlags, '--repository <path>': 'Bounded offline signed-image repository', '--trusted-system-clock-source <name>': 'Audit label for the independently managed trusted system clock, not an attestation' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.repository !== 'string' || typeof options.trustedSystemClockSource !== 'string') throw new Error('Explicit repository and independently trusted system-clock source required')
13 await printAsync(JSON.stringify(await reserveRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), repository: options.repository, clock: { source: options.trustedSystemClockSource, read: () => new Date() } })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
app/Commands/UpdatesRetire.tsadded+17-0
Changes to app/Commands/UpdatesRetire.ts
@@ -0,0 +1,17 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync } from '../../resources/functions/cli/output'
3import { storeCommandFailure, storeCommandReference, storeReferenceFlags } from '../../resources/functions/drone/updates/command'
4import { retireRuntimeFirmwareCandidate } from '../../resources/functions/drone/updates/store'
5
6export default defineCommand({
7 name: 'updates:retire <directory>',
8 description: 'Retire the exact active candidate reservation while retaining its history, bytes and trust floors',
9 options: { ...storeReferenceFlags, '--candidate <id>': 'Exact active candidate identity from pinned store status' },
10 async handle(options, directory) {
11 try {
12 if (typeof options.candidate !== 'string') throw new Error('Explicit candidate identity required')
13 await printAsync(JSON.stringify(await retireRuntimeFirmwareCandidate({ ...storeCommandReference(options, directory), candidateId: options.candidate })))
14 }
15 catch { await storeCommandFailure() }
16 },
17})
bin/drone.tsmodified+6-0
Changes to bin/drone.ts
@@ -28,6 +28,9 @@ import UpdatesStore from '../app/Commands/UpdatesStore'
2828import UpdatesRefresh from '../app/Commands/UpdatesRefresh'
2929import UpdatesRecover from '../app/Commands/UpdatesRecover'
3030import UpdatesPrepare from '../app/Commands/UpdatesPrepare'
31import UpdatesReserve from '../app/Commands/UpdatesReserve'
32import UpdatesCandidates from '../app/Commands/UpdatesCandidates'
33import UpdatesRetire from '../app/Commands/UpdatesRetire'
3134
3235import MissionsSimulate from '../app/Commands/MissionsSimulate'
3336import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -99,6 +102,9 @@ UpdatesStore(app)
99102UpdatesRefresh(app)
100103UpdatesRecover(app)
101104UpdatesPrepare(app)
105UpdatesReserve(app)
106UpdatesCandidates(app)
107UpdatesRetire(app)
102108MissionsSimulate(app)
103109MissionsValidate(app)
104110MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdmodified+16-3
Changes to docs/adr/0028-runtime-trust-store.md
@@ -53,7 +53,20 @@ same file writer serves standalone staging. Candidate inspection requires a root
5353pin from authenticated store readback when preparation uses the current rotated
5454root. A candidate receipt supplies no current trust or selection authority.
5555
56This v1 database journals metadata, not durable candidate ownership or slot
57transitions. Installed, candidate and known-good slots and health confirmation
58bound to an exact image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
56The original format1 database journals metadata. Candidate reservation upgrades
57its exact schema to format2 in a single transaction, preserving all earlier
58records, floors and the independently pinned store identity. The writer database
59stays format1. New code reads both formats; older binaries reject format2 before
60rewriting it. Missing/corrupt or unknown schemas are never reinitialized.
61
62Format2 adds bounded owned candidate records and one active-reservation index.
63A record binds the exact attempt/generation and generated signed description,
64is committed before image IO, and changes to prepared only after authenticated
65bytes and full candidate-file readback. The files use exclusive immutable-ID
66directories inside the private store. Explicit retirement preserves the files
67and history. Metadata-only recovery cannot promote a reserved candidate based on
68its receipt. Recorded-byte status is not current-policy authorization.
69
70Installed and known-good slot roles and health confirmation bound to an exact
71image and boot/session identity remain part of #42. No receipt or healthy boolean can select firmware. Physical installation,
5972bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/diagnostics.mdmodified+2-1
Changes to docs/diagnostics.md
@@ -158,7 +158,8 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
158158- [Signed image staging and protected preparation](runtime-updates.md) authenticate
159159 TUF roles, declared compatibility and actual image bytes. Independently pinned
160160 enrollment/store records retain metadata and clock/release floors; historical
161 recovery retains floors only. Complete key revocation/repair, installed and
161 recovery retains floors only. Owned candidate reservation/status/retirement retain
162 exact identities and files without granting boot authority. Complete key revocation/repair, installed and
162163 known-good slot coordination, current-policy rollback and exact-image boot/session
163164 health before flashing. Standalone staging/inspection still need explicit trust
164165 and are separate from protected-current-policy selection. Host preparation does
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -13,7 +13,7 @@ means there is no native implementation yet. A protocol-compatible transport is
1313| Arming, RC override, kill, fences, battery and recovery | Versioned simulation authority, explicit arming/override/fault latches and modeled independent output expiry; board port, HIL and recovery controllers pending | No arm/mode/kill commands; physical receive-only | [#6](https://github.com/stacksjs/drone/issues/6) |
1414| MAVLink routing, ACK, mission/parameter protocol, signing, time and transport | Bounded MAVLink 2 command ACK/retry, named parameters, waypoint transfer/resume and optional signing; full firmware SITL, serial/TCP and deeper services pending | ArduPilot/PX4 telemetry, SITL velocity and shared acknowledged protocol subset; scripted UDP evidence only | [#7](https://github.com/stacksjs/drone/issues/7) |
1515| Payloads, buses, camera triggers, ESCs, power, gimbals and winches | Owned still-capture ACKs, bounded clock sync and estimator-pose association, Pi IMX500 adapter, replay and bus contracts; physical validation and other payload drivers pending | No payload actuator exposure | [#8](https://github.com/stacksjs/drone/issues/8) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors and actual image preparation](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, slot coordination and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
16| Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, crash-prefix recovery, replay, CLI/viewer and [signed staging, protected trust floors, actual image preparation and durable candidate ownership](../runtime-updates.md); full mission correlation, hardware evidence, key revocation/repair, installed/known-good slots and install/rollback remain pending | No log/FTP/parameter management | [#9](https://github.com/stacksjs/drone/issues/9) |
1717| Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | No mission upload or mission-mode control | [#10](https://github.com/stacksjs/drone/issues/10) |
1818| Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) |
1919| Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing aerodynamic trainer with runway takeoff/survey/landing and wind/airspeed-loss tests; VTOL, helicopter, soaring, blimp and experimental families remain planned | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) |
docs/roadmap/issues.jsonmodified+6-1
Changes to docs/roadmap/issues.json
@@ -270,8 +270,13 @@
270270 "tests/unit/diagnostics.test.ts",
271271 "resources/functions/drone/updates/",
272272 "app/Commands/UpdatesPrepare.ts",
273 "app/Commands/UpdatesReserve.ts",
274 "app/Commands/UpdatesCandidates.ts",
275 "app/Commands/UpdatesRetire.ts",
273276 "docs/runtime-updates.md",
274 "tests/unit/updates-preparation.test.ts"
277 "tests/unit/updates-preparation.test.ts",
278 "tests/unit/updates-candidates.test.ts",
279 "tests/unit/updates-candidates-cli.test.ts"
275280 ],
276281 "remaining": [
277282 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
docs/runtime-updates.mdmodified+93-7
Changes to docs/runtime-updates.md
@@ -177,7 +177,7 @@ actual SIGKILL cuts after input and metadata commits, observe SQLITE_BUSY from a
177177competing process, restart and retain the timestamp before a fresh refresh. These
178178cuts do not cover every write/sync boundary or actual physical power loss.
179179
180The v1 format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
180The metadata format is bounded to 256 attempts, 10,000 metadata advances and a 128 MiB
181181state database. Each attempt allows 64 inputs, 32 MiB of metadata and 512 KiB per
182182file. Exhaustion fails closed; history is not automatically deleted or compacted.
183183Capacity migration, the full storage-fault campaign, authenticated repair/key
@@ -224,8 +224,9 @@ still has `trustStatePersisted: false`: that portable receipt is not the protect
224224store or current authorization. A successful preparation authenticates these
225225candidate bytes at this operation's trusted time. It does not install, select,
226226boot or confirm them, and does not promise future trust/expiry policy will allow
227selection. Durable candidate ownership, installed/known-good slots and current
228policy revalidation before selection remain coordinator work in #42.
227selection. The owned-candidate operation below adds durable reservation records;
228installed/known-good slots and current-policy revalidation before selection remain
229coordinator work in #42.
229230
230231Image or candidate-output failure during an attempt leaves accepted metadata and
231232release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never
@@ -244,6 +245,91 @@ summary; a fresh preparation uses another new directory. These measured process
244245cuts do not establish every write/sync boundary, electrical power-loss behavior
245246or hardware anti-rollback.
246247
248## Owned candidate reservations and retirement
249
250Use `updates:reserve` when the pinned store should own the candidate, rather than
251writing a standalone output directory. Repository and enrollment directories must
252remain separate from the store and cannot contain it or be contained by it.
253
254```sh
255./buddy updates:reserve /path/to/store --enrollment /path/to/trust \
256 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
257 --repository /path/to/offline-repository \
258 --trusted-system-clock-source operator-managed-utc-clock
259
260./buddy updates:candidates /path/to/store --enrollment /path/to/trust \
261 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256"
262
263./buddy updates:retire /path/to/store --enrollment /path/to/trust \
264 --enrollment-sha256 "$ENROLLMENT_SHA256" --store-sha256 "$STORE_SHA256" \
265 --candidate "$EXACT_ACTIVE_CANDIDATE_ID"
266```
267
268The library functions are `reserveRuntimeFirmwareCandidate`,
269`inspectRuntimeFirmwareCandidates` and `retireRuntimeFirmwareCandidate`. The
270native Buddy commands and copied standalone commands share them. A writer lease
271covers metadata verification, reservation, image output and completion. Only one
272candidate can remain active; another reservation fails before starting a metadata
273attempt. A fresh trusted clock and the existing protected SDK operation verify
274the signed selection, then commit its release floor before image acquisition.
275
276Reservation upgrades an exact format1 `state.sqlite` to format2 in one SQLite
277transaction, adding `candidates` and a partial unique index for one active
278reservation. It retains every metadata/attempt record, clock/release floor and
279store identity. `writer.sqlite` stays format1, and the independently retained
280`store.json` hash is unchanged. New code still reads format1 stores without
281migrating them during inspection. Older binaries fail closed on format2; a
282retained older executable was tested before and after upgrade, including unchanged
283state and writer database hashes after its rejection. Do not downgrade or reset a
284store to make an older binary operate on it. Unknown schemas remain rejected
285before configuration that could rewrite their format.
286
287Each candidate record binds a random ID, exact metadata attempt/generation and
288the generated signed-stage description/hash. The record is committed as
289`reserved` before image IO. Files are exclusively created under
290`store/candidates/<id>` with the same actual-image verification and archive writer
291as protected preparation. Only complete file, image and archive readback permits
292the `prepared` record commit. A receipt cannot introduce an owned record.
293
294`updates:candidates` returns `drone-runtime-candidates-v1`, `stateFormat`, protected
295`store` status, the active `candidate`, and bounded declaration `history`. History
296includes retired identities and paths; retired file integrity is not inspected.
297There are at most 128 retained candidate records. Exhaustion fails closed rather
298than deleting history or images. Active candidate `integrity` is:
299
300| Value | Meaning |
301| --- | --- |
302| `incomplete` | The record is still reserved, even if a stage marker exists |
303| `recorded-bytes-match` | Prepared image, receipt, root and archive match the owned record and its historical metadata bytes |
304| `mismatch` | Available file content or layout differs from the owned candidate |
305| `unavailable` | Files cannot be completely read or their protected file/directory requirements fail |
306
307This status is a local byte check. It does not establish fresh TUF expiration,
308revocation or current-policy authorization. `currentTrustValidated` and
309`physicalDeploymentAllowed` remain false. No installed, booted or healthy result
310is invented from a prepared record or matching files.
311
312After an interrupted or failed operation, preserve the whole store and its
313candidate files. Resolve the pending/maintenance metadata attempt with
314`updates:recover` first. Recovery never promotes `reserved` to `prepared`, even
315when a completed stage marker exists. A process can also stop after the prepared
316record commit but before attempt completion; the record survives, while metadata
317recovery still supplies no current selection authority. Missing/corrupt files
318remain actionable integrity failures. Explicit retirement requires the exact
319active ID and a resolved metadata attempt. It changes the record to `retired`,
320retains all files/history/floors, and allows a new ID with a new directory. It does
321not erase or repair the earlier artifact.
322
323Host qualification kills actual processes after reservation, image write, stage
324marker completion, prepared-record commit and retirement commit. Each cut observes
325an actual competing process rejected with SQLITE_BUSY, then restarts, checks
326unchanged candidate phase/floors and prepares a distinct fresh ID after explicit
327recovery/retirement. This does not cover every SQLite/filesystem write or sync,
328ENOSPC/EDQUOT/EIO, electrical power loss or actual board boot behavior. Installed
329and known-good slot roles, identity-bound runtime health, current-policy selection
330and rollback, authenticated key maintenance and the full fault campaign remain
331required by #42.
332
247333## Repository and signed image contract
248334
249335Use a standard TUF repository with `metadata/` and `targets/` directories,
@@ -364,10 +450,10 @@ time. Neither path is a complete anti-rollback coordinator. Trusted clock
364450acquisition and protection against privileged clock/storage rollback remain
365451open. Control-loop freshness still uses its separate monotonic clock.
366452
367Initial enrollment, the bounded persistent metadata protocol and protected
368signed-image preparation are available above. Complete authenticated
369maintenance/key revocation and the full storage-fault campaign, then durable
370candidate ownership, installed/known-good slot integrity, explicit health
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
371457confirmation and rollback without lowering security floors. Then add
372458the board bootloader/install protocol and measured power-loss, full-medium,
373459storage-latency and recovery campaigns. Local privileged filesystem tampering
resources/functions/drone/updates/store.tsmodified+200-29
Changes to resources/functions/drone/updates/store.ts
@@ -25,6 +25,10 @@ const schema = [
2525 `CREATE TABLE inputs (attempt_id TEXT NOT NULL, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), path TEXT NOT NULL CHECK(length(path) BETWEEN 1 AND 256), status INTEGER NOT NULL CHECK(status IN (200, 404)), bytes BLOB, sha256 TEXT, PRIMARY KEY(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id), CHECK((status = 404 AND bytes IS NULL AND sha256 IS NULL) OR (status = 200 AND bytes IS NOT NULL AND sha256 IS NOT NULL AND length(bytes) BETWEEN 1 AND ${metadataLimit} AND length(sha256) = 64))) STRICT`,
2626 `CREATE TABLE metadata (generation INTEGER PRIMARY KEY CHECK(generation BETWEEN 0 AND ${maximumGeneration}), attempt_id TEXT, ordinal INTEGER NOT NULL CHECK(ordinal BETWEEN 0 AND 63), role TEXT NOT NULL CHECK(length(role) BETWEEN 1 AND 180), version INTEGER NOT NULL CHECK(version BETWEEN 1 AND 9007199254740991), epoch INTEGER NOT NULL CHECK(epoch BETWEEN 0 AND generation), bytes BLOB NOT NULL CHECK(length(bytes) BETWEEN 1 AND ${metadataLimit}), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), UNIQUE(attempt_id, ordinal), FOREIGN KEY(attempt_id) REFERENCES attempts(id)) STRICT`,
2727]
28const candidateSchema = [
29 'CREATE TABLE candidates (id TEXT PRIMARY KEY CHECK(length(id) = 32), attempt_id TEXT NOT NULL UNIQUE, generation INTEGER NOT NULL, phase TEXT NOT NULL CHECK(phase IN (\'reserved\', \'prepared\', \'retired\')), description BLOB NOT NULL CHECK(length(description) BETWEEN 1 AND 16384), sha256 TEXT NOT NULL CHECK(length(sha256) = 64), FOREIGN KEY(attempt_id) REFERENCES attempts(id), FOREIGN KEY(generation) REFERENCES metadata(generation)) STRICT',
30 "CREATE UNIQUE INDEX candidate_active ON candidates ((1)) WHERE phase IN ('reserved', 'prepared')",
31]
2832
2933interface TrustReference { enrollmentDirectory: string, enrollmentSha256: string }
3034interface StoreReference extends TrustReference { directory: string, storeSha256: string }
@@ -63,7 +67,7 @@ function configure(db: Database) {
6367 if (!result || Object.values(result)[0] !== expected) throw new Error('Required SQLite update-store configuration unavailable')
6468 }
6569}
66function openDatabase(path: string, expectedSchema: string[]) {
70function openDatabase(path: string, expectedSchema: string[], allowCandidates = false) {
6771 const before = protectedFile(path, path.endsWith('writer.sqlite') ? 1024 * 1024 : maximumDatabaseBytes)
6872 const db = new Database(path, { create: false, strict: true })
6973 try {
@@ -72,19 +76,20 @@ function openDatabase(path: string, expectedSchema: string[]) {
7276 db.exec('PRAGMA trusted_schema=OFF; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=0;')
7377 // Authenticate the protocol format before any configuration that could
7478 // rewrite a foreign database's journal/header format.
75 checkSchema(db, expectedSchema)
79 const version = databaseVersion(db)
80 checkSchema(db, allowCandidates && version === 2 ? [...expectedSchema, ...candidateSchema] : expectedSchema, allowCandidates && version === 2 ? 2 : 1)
7681 if (Object.values(db.query('PRAGMA journal_mode').get() as Record<string, unknown>)[0] !== 'delete') throw new Error('Unexpected update-store journal format')
7782 configure(db)
7883 return db
7984 }
8085 catch (error) { db.close(); throw error }
8186}
82function checkSchema(db: Database, expected: string[]) {
87function databaseVersion(db: Database) { return db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version }
88function checkSchema(db: Database, expected: string[], version = 1) {
8389 const definitions = db.query<{ sql: string }, []>("SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name").all().map(row => row.sql).sort()
8490 if (JSON.stringify(definitions) !== JSON.stringify([...expected].sort())) throw new Error('Unexpected update-store schema')
8591 const values = db.query<{ application_id: number }, []>('PRAGMA application_id').get()
86 const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()
87 if (values?.application_id !== applicationId || version?.user_version !== 1 || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
92 if (values?.application_id !== applicationId || databaseVersion(db) !== version || Object.values(db.query('PRAGMA quick_check').get() as Record<string, unknown>)[0] !== 'ok' || db.query('PRAGMA foreign_key_check').all().length) throw new Error('Invalid update-store database integrity or version')
8893}
8994function createDatabase(path: string, statements: string[]) {
9095 writeExclusive(path, Buffer.alloc(0))
@@ -148,17 +153,22 @@ async function withStore<T>(options: StoreReference, operation: (db: Database, b
148153 let writer: Database | undefined, db: Database | undefined
149154 try {
150155 for (const name of readdirSync(path)) {
151 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal'].includes(name)) throw new Error('Unexpected update-store entry')
156 if (!['store.json', 'writer.sqlite', 'state.sqlite', 'writer.sqlite-journal', 'state.sqlite-journal', 'candidates'].includes(name)) throw new Error('Unexpected update-store entry')
152157 if (name.endsWith('-journal')) protectedFile(join(path, name), maximumDatabaseBytes, true)
153158 }
154159 writer = openDatabase(join(path, 'writer.sqlite'), [writerSchema])
155160 writer.exec('BEGIN IMMEDIATE')
156161 const owner = writer.query<{ store_id: string }, []>('SELECT store_id FROM writer').all()
157162 if (owner.length !== 1 || owner[0]!.store_id !== binding.storeId) throw new Error('Invalid update-store writer identity')
158 db = openDatabase(join(path, 'state.sqlite'), schema)
163 db = openDatabase(join(path, 'state.sqlite'), schema, true)
164 if (readdirSync(path).includes('candidates')) {
165 if (databaseVersion(db) !== 2) throw new Error('Candidate files require an owned format2 store')
166 protectedDirectory(join(path, 'candidates'))
167 }
159168 const initial = db.query<MetadataRow, []>('SELECT * FROM metadata WHERE generation = 0').get()
160169 if (initial?.sha256 !== trusted.policy.trustedRootSha256 || initial.version !== trusted.rootVersion || state(db, binding).release_floor < trusted.policy.minimumReleaseSequence) throw new Error('Update-store anchor or floor mismatch')
161170 summary(db, binding, options.storeSha256)
171 for (const row of candidateRows(db)) ownedDescription(db, row, trusted)
162172 return await operation(db, binding, trusted, path)
163173 }
164174 finally {
@@ -207,7 +217,7 @@ export interface RuntimeUpdateClock {
207217}
208218
209219export interface RuntimeUpdateProgress {
210 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
220 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed' | 'candidate-reserved' | 'candidate-recorded' | 'candidate-retired'
211221 ordinal: number
212222}
213223type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
@@ -270,11 +280,14 @@ interface VerifiedSelection {
270280 source: string
271281 info: TargetFile
272282 runtime: ReturnType<typeof admitSignedRuntime>
283 attempt: string
284 path: string
273285}
274286
275async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
287async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>, before?: (db: Database) => void): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
276288 options = { ...options }
277 return withStore(options, async (db, binding, trusted) => {
289 return withStore(options, async (db, binding, trusted, path) => {
290 before?.(db)
278291 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
279292 const current = state(db, binding), now = clockReading(options.clock, current.clock_floor_ms)
280293 if (db.query<{ count: number }, []>('SELECT count(*) AS count FROM attempts').get()!.count >= 256) throw new Error('Update attempt history limit reached')
@@ -323,7 +336,7 @@ async function refreshAndComplete<T>(options: RefreshOptions, complete: (selecti
323336 db.query('UPDATE store_state SET release_floor = max(release_floor, ?) WHERE id = 1').run(runtime.releaseSequence)
324337 db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt)
325338 })()
326 const value = await complete({ db, binding, trusted, source, info, runtime })
339 const value = await complete({ db, binding, trusted, source, info, runtime, attempt, path })
327340 db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt)
328341 return { store: summary(db, binding, options.storeSha256), value }
329342 }
@@ -353,28 +366,186 @@ export interface PreparedRuntimeFirmware {
353366export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> {
354367 options = { ...options }
355368 const output = runtimeCandidateOutput(options.output, [options.repository, options.directory, options.enrollmentDirectory])
356 const result = await refreshAndComplete(options, async ({ db, binding, trusted, source, info, runtime }) => {
357 const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')!
369 const result = await refreshAndComplete(options, selection => writePreparedCandidate(selection, output, options.onProgress))
370 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
371}
372
373function candidateDescription({ db, binding, trusted, info, runtime }: VerifiedSelection): RuntimeFirmwareStage {
374 const root = activeMetadata(db, state(db, binding).generation).find(row => row.role === 'root')!
375 return { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false }
376}
377
378async function writePreparedCandidate(selection: VerifiedSelection, output: string, onProgress?: ProgressObserver) {
379 const { db, binding, source, info } = selection
380 const rows = activeMetadata(db, state(db, binding).generation), root = rows.find(row => row.role === 'root')!
381 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
382 const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path
383 const image = repositoryFile(source, `targets/${path}`, imageLimit)
384 await info.verify(Readable.from(image))
385 const files = new Map<string, Buffer>()
386 for (const row of rows) {
387 if (row.role === 'root') continue
388 files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
389 }
390 files.set(`targets/${path}`, image)
391 const candidate = candidateDescription(selection)
392 mkdirSync(output, { mode: 0o700 })
393 writeExclusive(join(output, 'trust-root.json'), rootBytes)
394 writeExclusive(join(output, 'image.bin'), image)
395 await onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 }))
396 await finishRuntimeCandidate(output, info, candidate, files)
397 await onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 }))
398 return Object.freeze(candidate)
399}
400
401interface CandidateRow { id: string, attempt_id: string, generation: number, phase: 'reserved' | 'prepared' | 'retired', description: Uint8Array, sha256: string }
402export interface RuntimeFirmwareCandidate {
403 id: string
404 attemptId: string
405 generation: number
406 phase: CandidateRow['phase']
407 directory: string
408 description: Readonly<RuntimeFirmwareStage>
409 integrity: 'incomplete' | 'recorded-bytes-match' | 'mismatch' | 'unavailable'
410 currentTrustValidated: false
411}
412export interface RuntimeFirmwareCandidates {
413 schema: 'drone-runtime-candidates-v1'
414 stateFormat: 1 | 2
415 store: Readonly<RuntimeTrustStoreSummary>
416 retainedCandidates: number
417 /** Owned declarations only. Retired file integrity is not inspected or authenticated for current use. */
418 history: ReadonlyArray<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>>
419 candidate: Readonly<RuntimeFirmwareCandidate> | null
420 physicalDeploymentAllowed: false
421}
422
423function candidateRows(db: Database) {
424 if (databaseVersion(db) === 1) return []
425 const rows = db.query<CandidateRow, []>('SELECT * FROM candidates ORDER BY rowid LIMIT 129').all()
426 if (rows.length > 128) throw new Error('Candidate history budget exceeded')
427 return rows
428}
429function ownedDescription(db: Database, row: CandidateRow, trusted: Readonly<RuntimeTrustSummary>): Readonly<RuntimeFirmwareStage> {
430 const bytes = Buffer.from(row.description)
431 if (!/^[a-f0-9]{32}$/.test(row.id) || sha256(bytes) !== row.sha256) throw new Error('Invalid owned candidate record')
432 const value = object(JSON.parse(bytes.toString('utf8')))
433 const keys = ['schema', 'target', 'platform', 'trustedRootSha256', 'rootVersion', 'releaseSequence', 'protocolVersion', 'sourceRevision', 'signerDeclaredProfile', 'length', 'sha256', 'trustStatePersisted', 'physicalDeploymentAllowed']
434 const root = activeMetadata(db, row.generation).find(item => item.role === 'root')!
435 checkedMetadataBytes(root)
436 const attempt = db.query<Attempt, [string]>('SELECT * FROM attempts WHERE id = ?').get(row.attempt_id)
437 if (Object.keys(value).length !== keys.length || !keys.every(key => Object.hasOwn(value, key)) || value.schema !== 'drone-runtime-stage-v1' || value.target !== trusted.policy.target || value.platform !== trusted.policy.platform || value.trustedRootSha256 !== root.sha256 || value.rootVersion !== root.version || !Number.isSafeInteger(value.releaseSequence) || (value.releaseSequence as number) < trusted.policy.minimumReleaseSequence || value.protocolVersion !== 1 || typeof value.sourceRevision !== 'string' || !/^[a-f0-9]{40}$/.test(value.sourceRevision) || value.signerDeclaredProfile !== 'neutral-bench' || !Number.isSafeInteger(value.length) || (value.length as number) < 1 || (value.length as number) > imageLimit || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || value.trustStatePersisted !== false || value.physicalDeploymentAllowed !== false || !attempt || attempt.accepted_release !== value.releaseSequence || row.generation < attempt.base_generation) throw new Error('Owned candidate does not match protected selection')
438 return Object.freeze(value as unknown as RuntimeFirmwareStage)
439}
440function candidateIntegrity(db: Database, generation: number, path: string, description: Readonly<RuntimeFirmwareStage>): RuntimeFirmwareCandidate['integrity'] {
441 try {
442 protectedDirectory(path)
443 const rows = activeMetadata(db, generation), root = rows.find(row => row.role === 'root')!
358444 const rootBytes = checkedMetadataBytes(root), consistent = object(object(JSON.parse(rootBytes.toString('utf8'))).signed).consistent_snapshot === true
359 const path = consistent ? info.path.replace(/([^/]+)$/, `${info.hashes.sha256}.$1`) : info.path
360 const image = repositoryFile(source, `targets/${path}`, imageLimit)
361 await info.verify(Readable.from(image))
362 const files = new Map<string, Buffer>()
445 const imagePath = consistent ? description.target.replace(/([^/]+)$/, `${description.sha256}.$1`) : description.target
446 const expected = new Map<string, Buffer>([['trust-root.json', rootBytes], ['stage.json', Buffer.from(`${JSON.stringify(description, null, 2)}\n`)]])
363447 for (const row of rows) {
364 if (row.role === 'root') continue
365 files.set(`metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
448 if (row.role !== 'root') expected.set(`repository/metadata/${row.role === 'timestamp' || !consistent ? '' : `${row.version}.`}${row.role}.json`, checkedMetadataBytes(row))
366449 }
367 files.set(`targets/${path}`, image)
368 const candidate: RuntimeFirmwareStage = { schema: 'drone-runtime-stage-v1', target: trusted.policy.target, platform: trusted.policy.platform, trustedRootSha256: root.sha256, rootVersion: root.version, ...runtime, protocolVersion: 1, signerDeclaredProfile: 'neutral-bench', length: info.length, sha256: info.hashes.sha256!, trustStatePersisted: false, physicalDeploymentAllowed: false }
369 mkdirSync(output, { mode: 0o700 })
370 writeExclusive(join(output, 'trust-root.json'), rootBytes)
371 writeExclusive(join(output, 'image.bin'), image)
372 await options.onProgress?.(Object.freeze({ phase: 'image-written', ordinal: 0 }))
373 await finishRuntimeCandidate(output, info, candidate, files)
374 await options.onProgress?.(Object.freeze({ phase: 'candidate-completed', ordinal: 0 }))
375 return Object.freeze(candidate)
450 for (const file of ['image.bin', `repository/targets/${imagePath}`]) {
451 const address = join(path, file)
452 protectedFile(address, imageLimit)
453 const bytes = regularFile(address, imageLimit)
454 if (bytes.length !== description.length || sha256(bytes) !== description.sha256) return 'mismatch'
455 expected.set(file, bytes)
456 }
457 const directories = new Set<string>()
458 for (const name of expected.keys()) {
459 for (let parent = dirname(name); parent !== '.'; parent = dirname(parent)) directories.add(parent)
460 }
461 const visit = (relative: string): boolean => {
462 const base = relative ? join(path, relative) : path
463 protectedDirectory(base)
464 const names = readdirSync(base)
465 if (names.length > 64) return false
466 return names.every(name => {
467 const child = relative ? `${relative}/${name}` : name
468 if (directories.has(child)) return visit(child)
469 const bytes = expected.get(child)
470 if (!bytes) return false
471 protectedFile(join(path, child), child.endsWith('image.bin') || child.startsWith('repository/targets/') ? imageLimit : metadataLimit)
472 return regularFile(join(path, child), Math.max(bytes.length, 1)).equals(bytes)
473 })
474 }
475 if (!visit('')) return 'mismatch'
476 for (const [name, bytes] of expected) {
477 protectedFile(join(path, name), Math.max(bytes.length, 1))
478 if (!regularFile(join(path, name), Math.max(bytes.length, 1)).equals(bytes)) return 'mismatch'
479 }
480 return 'recorded-bytes-match'
481 }
482 catch { return 'unavailable' }
483}
484function candidatesSummary(db: Database, binding: Binding, trusted: Readonly<RuntimeTrustSummary>, path: string, storeSha256: string): Readonly<RuntimeFirmwareCandidates> {
485 const rows = candidateRows(db), active = rows.filter(row => row.phase !== 'retired')
486 if (active.length > 1) throw new Error('Multiple active candidate records')
487 let candidate: Readonly<RuntimeFirmwareCandidate> | null = null
488 const history: Array<Readonly<Omit<RuntimeFirmwareCandidate, 'integrity' | 'currentTrustValidated'>>> = []
489 for (const row of rows) {
490 const description = ownedDescription(db, row, trusted)
491 const directory = join(path, 'candidates', row.id)
492 const recorded = Object.freeze({ id: row.id, attemptId: row.attempt_id, generation: row.generation, phase: row.phase, directory, description })
493 history.push(recorded)
494 if (row === active[0]) {
495 candidate = Object.freeze({ ...recorded, integrity: row.phase === 'reserved' ? 'incomplete' : candidateIntegrity(db, row.generation, directory, description), currentTrustValidated: false })
496 }
497 }
498 return Object.freeze({ schema: 'drone-runtime-candidates-v1', stateFormat: databaseVersion(db) as 1 | 2, store: summary(db, binding, storeSha256), retainedCandidates: rows.length, history: Object.freeze(history), candidate, physicalDeploymentAllowed: false })
499}
500
501/** Inspect owned host candidates. Recorded-byte integrity is not fresh selection or boot authorization. */
502export async function inspectRuntimeFirmwareCandidates(options: StoreReference): Promise<Readonly<RuntimeFirmwareCandidates>> {
503 return withStore(options, (db, binding, trusted, path) => candidatesSummary(db, binding, trusted, path, options.storeSha256))
504}
505
506/** Release only the exact active candidate reservation. Retain its records and bytes; never reset trust floors. */
507export async function retireRuntimeFirmwareCandidate(options: StoreReference & { candidateId: string, onProgress?: ProgressObserver }): Promise<Readonly<RuntimeFirmwareCandidates>> {
508 options = { ...options }
509 if (typeof options.candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(options.candidateId)) throw new Error('Exact active candidate identity required')
510 return withStore(options, async (db, binding, trusted, path) => {
511 const current = candidatesSummary(db, binding, trusted, path, options.storeSha256)
512 if (current.store.attempt?.phase === 'pending' || current.store.attempt?.phase === 'maintenance') throw new Error('Resolve the metadata attempt before candidate retirement')
513 if (!current.candidate || current.candidate.id !== options.candidateId) throw new Error('Exact active candidate identity required')
514 db.query("UPDATE candidates SET phase = 'retired' WHERE id = ? AND phase IN ('reserved', 'prepared')").run(options.candidateId)
515 await options.onProgress?.(Object.freeze({ phase: 'candidate-retired', ordinal: 0 }))
516 return candidatesSummary(db, binding, trusted, path, options.storeSha256)
376517 })
377 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
518}
519
520/** Reserve one owned candidate and prepare signed bytes. No installed slot or health confirmation is changed. */
521export async function reserveRuntimeFirmwareCandidate(options: RefreshOptions): Promise<Readonly<RuntimeFirmwareCandidates>> {
522 options = { ...options }
523 runtimeCandidateOutput(options.directory, [options.repository, options.enrollmentDirectory])
524 const result = await refreshAndComplete(options, async selection => {
525 const { db, binding, trusted, path, attempt } = selection
526 if (databaseVersion(db) === 1) {
527 db.transaction(() => { for (const sql of candidateSchema) db.exec(sql); db.exec('PRAGMA user_version=2') })()
528 checkSchema(db, [...schema, ...candidateSchema], 2)
529 }
530 const id = randomBytes(16).toString('hex'), description = candidateDescription(selection)
531 const bytes = Buffer.from(`${JSON.stringify(description)}\n`), generation = state(db, binding).generation
532 db.query("INSERT INTO candidates VALUES(?, ?, ?, 'reserved', ?, ?)").run(id, attempt, generation, bytes, sha256(bytes))
533 await options.onProgress?.(Object.freeze({ phase: 'candidate-reserved', ordinal: 0 }))
534 const parent = join(path, 'candidates')
535 if (!readdirSync(path).includes('candidates')) { mkdirSync(parent, { mode: 0o700 }); syncDirectory(path) }
536 protectedDirectory(parent)
537 const output = join(parent, id)
538 await writePreparedCandidate(selection, output, options.onProgress)
539 if (candidateIntegrity(db, generation, output, description) !== 'recorded-bytes-match') throw new Error('Owned candidate file readback failed')
540 db.query("UPDATE candidates SET phase = 'prepared' WHERE id = ? AND phase = 'reserved'").run(id)
541 await options.onProgress?.(Object.freeze({ phase: 'candidate-recorded', ordinal: 0 }))
542 return candidatesSummary(db, binding, trusted, path, options.storeSha256)
543 }, db => {
544 const rows = candidateRows(db)
545 if (rows.some(row => row.phase !== 'retired')) throw new Error('An active candidate requires explicit retirement or slot transition')
546 if (rows.length >= 128) throw new Error('Candidate history budget reached')
547 })
548 return Object.freeze({ ...result.value, store: result.store })
378549}
379550
380551function causedBy(error: unknown, marker: Error) {
tests/fixtures/updates/qualify-candidates.tsadded+90-0
Changes to tests/fixtures/updates/qualify-candidates.ts
@@ -0,0 +1,90 @@
1import assert from 'node:assert/strict'
2import { constants } from 'node:fs'
3import { createHash } from 'node:crypto'
4import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
5import { join, resolve } from 'node:path'
6import { platform, repository, target } from './repository'
7
8async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
9
10/** Real copied commands, signed synthetic bytes and optional retained older binary. Never executes a candidate. */
11export async function qualifyPortableCandidates(directory: string, source: string, olderBinary?: string) {
12 await mkdir(directory, { mode: 0o700 })
13 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
14 await copyFile(source, binary, constants.COPYFILE_EXCL); await chmod(binary, 0o700)
15 assert.equal(await hash(binary), sourceSha256); assert.equal(await hash(source), sourceSha256)
16 const olderSha256 = olderBinary ? await hash(olderBinary) : null
17 const repo = join(directory, 'repo'), fixture = await repository(repo, { rotate: true, custom: { releaseSequence: 9 } })
18 const enrollment = join(directory, 'enrollment'), store = join(directory, 'store')
19 const results: { command: string, binarySha256: string, exit: number, stdout: string, stderr: string }[] = []
20 async function run(args: string[], expected = 0, executable = binary) {
21 const child = Bun.spawn([executable, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
22 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
23 try {
24 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
25 results.push({ command: args[0]!, binarySha256: executable === binary ? sourceSha256 : olderSha256!, exit, stdout, stderr }); assert.equal(exit, expected)
26 if (expected) { assert.equal(stdout, ''); assert.equal(stderr, 'Runtime update store unavailable: invalid trust, clock, state or storage failure\n') }
27 return stdout ? JSON.parse(stdout) : undefined
28 }
29 finally { clearTimeout(timer) }
30 }
31 try {
32 const enrolled = await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', enrollment, '--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7'])
33 const flags = ['--enrollment', enrollment, '--enrollment-sha256', enrolled.enrollmentSha256]
34 const created = await run(['updates:store-init', ...flags, '--output', store])
35 const pinned = [...flags, '--store-sha256', created.storeSha256]
36 const repositoryFlags = ['--repository', repo, '--trusted-system-clock-source', 'operator-managed-test-clock']
37 const initialHash = await hash(join(store, 'state.sqlite'))
38 const empty = await run(['updates:candidates', store, ...pinned])
39 assert.equal(empty.stateFormat, 1); assert.equal(empty.candidate, null)
40 assert.equal(await hash(join(store, 'state.sqlite')), initialHash)
41 if (olderBinary) assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), created)
42 await run(['updates:reserve', store, ...pinned, '--repository', repo], 1)
43 const prepared = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
44 assert.equal(prepared.stateFormat, 2); assert.equal(prepared.store.releaseFloor, 9)
45 assert.equal(prepared.store.attempt.phase, 'verified'); assert.equal(prepared.store.metadata.find((row: { role: string }) => row.role === 'root').version, 2)
46 assert.equal(prepared.candidate.phase, 'prepared'); assert.equal(prepared.candidate.integrity, 'recorded-bytes-match')
47 assert.equal(prepared.candidate.description.sha256, fixture.imageHash); assert.equal(prepared.candidate.currentTrustValidated, false); assert.equal(prepared.physicalDeploymentAllowed, false)
48 assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared)
49 await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1)
50 await run(['updates:retire', store, ...pinned, '--candidate', '0'.repeat(32)], 1)
51 assert.deepEqual(await run(['updates:candidates', store, ...pinned]), prepared)
52 if (olderBinary) {
53 const stateHash = await hash(join(store, 'state.sqlite')), writerHash = await hash(join(store, 'writer.sqlite'))
54 await run(['updates:store', store, ...pinned], 1, olderBinary)
55 assert.equal(await hash(join(store, 'state.sqlite')), stateHash); assert.equal(await hash(join(store, 'writer.sqlite')), writerHash)
56 assert.equal(await hash(olderBinary), olderSha256)
57 }
58 await run(['updates:candidates', store, ...flags, '--store-sha256', '0'.repeat(64)], 1)
59 const original = await readFile(join(prepared.candidate.directory, 'stage.json'))
60 await writeFile(join(prepared.candidate.directory, 'image.bin'), Buffer.alloc(fixture.image.length, 0xff))
61 const damaged = await run(['updates:candidates', store, ...pinned])
62 assert.equal(damaged.candidate.integrity, 'mismatch'); assert.equal(damaged.candidate.currentTrustValidated, false)
63 await writeFile(join(prepared.candidate.directory, 'image.bin'), fixture.image)
64 const retired = await run(['updates:retire', store, ...pinned, '--candidate', prepared.candidate.id])
65 assert.equal(retired.candidate, null); assert.equal(retired.retainedCandidates, 1); assert.equal(retired.store.releaseFloor, 9)
66 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original)
67 await writeFile(fixture.imagePath, Buffer.alloc(fixture.image.length, 0xff))
68 await run(['updates:reserve', store, ...pinned, ...repositoryFlags], 1)
69 const stopped = await run(['updates:candidates', store, ...pinned])
70 assert.equal(stopped.candidate.phase, 'reserved'); assert.equal(stopped.candidate.integrity, 'incomplete')
71 assert.equal(stopped.store.releaseFloor, 9); assert.equal(stopped.store.attempt.phase, 'maintenance')
72 await run(['updates:recover', store, ...pinned])
73 const recovered = await run(['updates:candidates', store, ...pinned])
74 assert.deepEqual(recovered.candidate, stopped.candidate); assert.equal(recovered.store.releaseFloor, 9)
75 await run(['updates:retire', store, ...pinned, '--candidate', stopped.candidate.id])
76 await writeFile(fixture.imagePath, fixture.image)
77 const fresh = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
78 assert.equal(fresh.retainedCandidates, 3); assert.notEqual(fresh.candidate.id, prepared.candidate.id)
79 assert.equal(fresh.candidate.integrity, 'recorded-bytes-match'); assert.equal(fresh.store.releaseFloor, 9)
80 assert.equal(fresh.candidate.currentTrustValidated, false); assert.equal(fresh.physicalDeploymentAllowed, false)
81 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'image.bin')), fixture.image)
82 assert.deepEqual(await readFile(join(prepared.candidate.directory, 'stage.json')), original)
83 }
84 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'copied-standalone-owned-candidates-v1', sourceSha256, olderSha256, results, physicalDeploymentAllowed: false }, null, 2)) }
85 return { evidence: 'copied-standalone-owned-candidates-v1', operations: results.length, sourceSha256, olderSha256, physicalDeploymentAllowed: false }
86}
87if (import.meta.main) {
88 if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [OLDER_BINARY]')
89 console.log(JSON.stringify(await qualifyPortableCandidates(resolve(process.argv[2]!), resolve(process.argv[3]!), process.argv[4] ? resolve(process.argv[4]) : undefined)))
90}
tests/fixtures/updates/store-interruptions.tsmodified+46-22
Changes to tests/fixtures/updates/store-interruptions.ts
@@ -3,13 +3,13 @@ import { spawnSync } from 'node:child_process'
33import { mkdir, readFile, writeFile } from 'node:fs/promises'
44import { join, resolve } from 'node:path'
55import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
6import { createRuntimeTrustStore, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } from '../../../resources/functions/drone/updates/store'
6import { createRuntimeTrustStore, inspectRuntimeFirmwareCandidates, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore, reserveRuntimeFirmwareCandidate, retireRuntimeFirmwareCandidate } from '../../../resources/functions/drone/updates/store'
77import type { RuntimeUpdateProgress } from '../../../resources/functions/drone/updates/store'
88import { platform, repository, target } from './repository'
99
1010type Reference = Parameters<typeof inspectRuntimeTrustStore>[0]
11type Cut = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string }
11type Cut = RuntimeUpdateProgress['phase']
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: string, managed?: boolean, candidateId?: string }
1313const clock = { source: 'synthetic-independent-clock', read: () => new Date('2030-01-01T00:00:00Z') }
1414
1515async function worker(mode: string, file: string) {
@@ -29,16 +29,19 @@ async function worker(mode: string, file: string) {
2929 console.log(JSON.stringify({ ready: true, phase: event.phase, ordinal: event.ordinal }))
3030 await Bun.stdin.text()
3131 } }
32 if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
32 if (input.candidateId) await retireRuntimeFirmwareCandidate({ ...options, candidateId: input.candidateId })
33 else if (input.managed) await reserveRuntimeFirmwareCandidate(options)
34 else if (input.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
3335 else await refreshRuntimeTrustStore(options)
3436 throw new Error('Interruption worker unexpectedly finished')
3537}
3638
3739/** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */
38async function qualifyInterruptions(directory: string, preparation: boolean) {
40async function qualifyInterruptions(directory: string, operation: 'refresh' | 'prepare' | 'reserve') {
3941 await mkdir(directory, { mode: 0o700 })
4042 const results = []
41 const cuts: Cut[] = preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
43 const managed = operation === 'reserve', preparation = operation !== 'refresh'
44 const cuts: Cut[] = managed ? ['candidate-reserved', 'image-written', 'candidate-completed', 'candidate-recorded', 'candidate-retired'] : preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
4245 for (const cut of cuts) {
4346 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
4447 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
@@ -46,8 +49,10 @@ async function qualifyInterruptions(directory: string, preparation: boolean) {
4649 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
4750 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
4851 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
49 const file = join(root, 'worker.json'), output = preparation ? join(root, 'candidate') : undefined
50 await writeFile(file, JSON.stringify({ reference, repository: source, cut, output } satisfies WorkerInput), { mode: 0o600 })
52 const prior = cut === 'candidate-retired' ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : null
53 const file = join(root, 'worker.json')
54 let output = prior?.candidate?.directory ?? (!managed && preparation ? join(root, 'candidate') : undefined)
55 await writeFile(file, JSON.stringify({ reference, repository: source, cut, output: managed ? undefined : output, managed, candidateId: prior?.candidate?.id } satisfies WorkerInput), { mode: 0o600 })
5156 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
5257 const timer = setTimeout(() => child.kill('SIGKILL'), 15000)
5358 const reader = child.stdout.getReader(), decoder = new TextDecoder()
@@ -68,25 +73,42 @@ async function qualifyInterruptions(directory: string, preparation: boolean) {
6873 const exit = await child.exited, signal = child.signalCode
6974 assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0)
7075 const interrupted = await inspectRuntimeTrustStore(reference)
71 assert.equal(interrupted.attempt?.phase, 'pending')
76 assert.equal(interrupted.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'pending')
7277 assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1)
7378 assert.equal(interrupted.releaseFloor, preparation ? 9 : 7)
79 const candidateBefore = managed ? await inspectRuntimeFirmwareCandidates(reference) : null
80 if (managed && cut !== 'candidate-retired') {
81 assert.equal(candidateBefore!.candidate!.phase, cut === 'candidate-recorded' ? 'prepared' : 'reserved')
82 assert.equal(candidateBefore!.candidate!.currentTrustValidated, false)
83 output = candidateBefore!.candidate!.directory
84 }
85 if (cut === 'candidate-retired') assert.equal(candidateBefore!.candidate, null)
7486 const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false
7587 if (output) {
76 assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
77 assert.equal(candidateStagePresent, cut === 'candidate-completed')
88 if (cut !== 'candidate-reserved') assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
89 assert.equal(candidateStagePresent, ['candidate-completed', 'candidate-recorded', 'candidate-retired'].includes(cut))
7890 }
79 const recovered = await recoverRuntimeTrustStore(reference)
80 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, 'recovered')
91 const recovered = cut === 'candidate-retired' ? await inspectRuntimeTrustStore(reference) : await recoverRuntimeTrustStore(reference)
92 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, cut === 'candidate-retired' ? 'verified' : 'recovered')
8193 assert.equal(recovered.releaseFloor, preparation ? 9 : 7)
8294 assert.equal(Object.hasOwn(recovered, 'candidate'), false)
83 const completed = preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
84 const fresh = completed.schema === 'drone-runtime-prepared-image-v1' ? completed.store : completed
95 if (managed) {
96 assert.deepEqual((await inspectRuntimeFirmwareCandidates(reference)).candidate, candidateBefore!.candidate)
97 if (candidateBefore!.candidate) await retireRuntimeFirmwareCandidate({ ...reference, candidateId: candidateBefore!.candidate.id })
98 }
99 const completed = managed ? await reserveRuntimeFirmwareCandidate({ ...reference, repository: source, clock }) : preparation ? await prepareRuntimeFirmwareUpdate({ ...reference, repository: source, clock, output: join(root, 'fresh-candidate') }) : await refreshRuntimeTrustStore({ ...reference, repository: source, clock })
100 const fresh = completed.schema === 'drone-runtime-prepared-image-v1' || completed.schema === 'drone-runtime-candidates-v1' ? completed.store : completed
101 if (completed.schema === 'drone-runtime-candidates-v1') {
102 assert.equal(completed.retainedCandidates, 2)
103 assert.equal(completed.candidate!.integrity, 'recorded-bytes-match')
104 assert.notEqual(completed.candidate!.id, candidateBefore!.candidate?.id ?? prior!.candidate!.id)
105 if (candidateStagePresent) assert.deepEqual(await readFile(join(output!, 'image.bin')), fixture.image)
106 }
85107 assert.equal(fresh.generation >= interrupted.generation, true)
86108 if (!preparation) assert.equal(fresh.generation, 3)
87109 assert.equal(fresh.releaseFloor, 9)
88110 assert.equal(fresh.attempt?.phase, 'verified'); assert.equal(fresh.physicalDeploymentAllowed, false)
89 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, interrupted, recovered, fresh })
111 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, candidateBefore, interrupted, recovered, fresh })
90112 }
91113 finally {
92114 clearTimeout(timer)
@@ -95,18 +117,20 @@ async function qualifyInterruptions(directory: string, preparation: boolean) {
95117 reader.releaseLock()
96118 await writeFile(join(root, 'worker.stdout'), stdout)
97119 await writeFile(join(root, 'worker.stderr'), await new Response(child.stderr).text())
98 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', results, physicalDeploymentAllowed: false }, null, 2))
120 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation, results, physicalDeploymentAllowed: false }, null, 2))
99121 }
100122 }
101 return { evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', cuts: results.length, physicalDeploymentAllowed: false }
123 return { evidence: 'host-process-interruption-only', operation, cuts: results.length, physicalDeploymentAllowed: false }
102124}
103125
104export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) }
105export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) }
126export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, 'refresh') }
127export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, 'prepare') }
128export async function qualifyCandidateInterruptions(directory: string) { return qualifyInterruptions(directory, 'reserve') }
106129
107130if (import.meta.main) {
108 if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
131 if (process.argv.length === 4 && process.argv[2] === 'reserve') console.log(JSON.stringify(await qualifyCandidateInterruptions(resolve(process.argv[3]!))))
132 else if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
109133 else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
110134 else if (process.argv.length === 3) console.log(JSON.stringify(await qualifyStoreInterruptions(resolve(process.argv[2]!))))
111 else throw new Error('Use store-interruptions.ts [prepare] NEW_DIRECTORY')
135 else throw new Error('Use store-interruptions.ts [prepare|reserve] NEW_DIRECTORY')
112136}
tests/unit/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})