ReviewOS

also looking at this

stacks/drone

feat: prepare runtime images with protected trust

#47
Merged chris wants to merge codex/runtime-store-staging into main
27 files +1675 -73

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
@@ -40,6 +40,8 @@ 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"
4345 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
4446 working-directory: ${{ runner.temp }}
4547 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+6-4
Changes to README.md
@@ -262,12 +262,14 @@ 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.
271Physical logging/commissioning, key revocation/repair, installed/known-good slots
272and image-bound boot/session health remain pending.
271273
272274## Sensor acquisition and replay
273275
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})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -27,6 +27,7 @@ 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'
3031
3132import MissionsSimulate from '../app/Commands/MissionsSimulate'
3233import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -97,6 +98,7 @@ UpdatesStoreInit(app)
9798UpdatesStore(app)
9899UpdatesRefresh(app)
99100UpdatesRecover(app)
101UpdatesPrepare(app)
100102MissionsSimulate(app)
101103MissionsValidate(app)
102104MissionsReplay(app)
docs/adr/0028-runtime-trust-store.mdmodified+9-3
Changes to docs/adr/0028-runtime-trust-store.md
@@ -47,7 +47,13 @@ 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
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,
5359bootloader behavior, secure boot and hardware anti-rollback remain gated by #2/#9.
docs/diagnostics.mdmodified+8-6
Changes to docs/diagnostics.md
@@ -155,12 +155,14 @@ 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. Complete key revocation/repair, installed and
162 known-good slot coordination, current-policy rollback and exact-image boot/session
163 health before flashing. Standalone staging/inspection still need explicit trust
164 and are separate from protected-current-policy selection. Host preparation does
165 not satisfy full update/rollback or hardware anti-rollback acceptance.
164166- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
165167 estimator calibration, RC override/kill and recovery evidence from #2/#3/#4/#6.
166168
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 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) |
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-2
Changes to docs/roadmap/issues.json
@@ -267,13 +267,17 @@
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 "docs/runtime-updates.md",
274 "tests/unit/updates-preparation.test.ts"
271275 ],
272276 "remaining": [
273277 "Live runtime/sensor/estimator/authority/mission/payload correlation and measured clock relations",
274278 "Physical crash, reset cause, power loss, ENOSPC/EDQUOT and electrical storage fault evidence",
275279 "Measured board logging deadlines, memory, rotation and long-mission retention",
276 "Signed firmware updates, trust/key lifecycle, anti-rollback and known-good recovery slot",
280 "Complete signed firmware update/rollback coordinator, key/clock lifecycle, protected slots and exact-image boot/session health",
277281 "Commissioning workflow backed by independent physical interlock, calibration and recovery evidence"
278282 ]
279283 },
docs/runtime-updates.mdmodified+74-10
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.
@@ -180,7 +181,68 @@ The v1 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. Durable candidate ownership, installed/known-good slots and current
228policy revalidation before selection remain coordinator work in #42.
229
230Image or candidate-output failure during an attempt leaves accepted metadata and
231release floors intact and marks the attempt for maintenance. `updates:recover` replays metadata only; it never
232reconstructs image success or confirms an existing candidate. Preserve partial
233outputs and use a new output directory for a fresh preparation after recovery.
234A complete candidate marker can exist when a later callback or process failure
235prevents the operation returning. Marker existence alone is not authorization.
236Repository, enrollment and protocol-store directories cannot contain the candidate
237output or be contained by it. Existing output directories are not overwritten.
238
239Host tests kill actual competing-writer-excluded processes at `image-written`
240and `candidate-completed` progress points. The first follows image-file sync;
241the second follows the shared file writer's final marker and directory syncs.
242After restart, metadata recovery retains release floor 9 and returns only a store
243summary; a fresh preparation uses another new directory. These measured process
244cuts do not establish every write/sync boundary, electrical power-loss behavior
245or hardware anti-rollback.
184246
185247## Repository and signed image contract
186248
@@ -188,9 +250,11 @@ Use a standard TUF repository with `metadata/` and `targets/` directories,
188250including consistent-snapshot names when enabled. Verification uses the pinned
189251[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
190252rotation, 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.
253target length/hashes. Standalone staging/inspection use a private, randomly
254addressed loopback server supplying bounded local files to the public HTTP
255interface, then stop it. Protected refresh/preparation use the public Fetcher
256interface with bounded local files and the durable input journal. Neither path
257provides an external network fetch or arbitrary URL option.
194258The host additionally verifies that root and delegation key IDs equal SHA-256
195259of their canonical key objects, using the upstream canonical JSON library.
196260This rejects arbitrary key-ID aliases before role verification.
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+63-6
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
@@ -203,7 +207,7 @@ export interface RuntimeUpdateClock {
203207}
204208
205209export interface RuntimeUpdateProgress {
206 phase: 'input-committed' | 'metadata-committed'
210 phase: 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
207211 ordinal: number
208212}
209213type ProgressObserver = (event: Readonly<RuntimeUpdateProgress>) => void | Promise<void>
@@ -258,8 +262,17 @@ function commitMetadata(db: Database, binding: Binding, attempt: string, ordinal
258262 return changed
259263}
260264
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>> {
265interface RefreshOptions extends StoreReference { repository: string, clock: RuntimeUpdateClock, onProgress?: ProgressObserver }
266interface VerifiedSelection {
267 db: Database
268 binding: Binding
269 trusted: Readonly<RuntimeTrustSummary>
270 source: string
271 info: TargetFile
272 runtime: ReturnType<typeof admitSignedRuntime>
273}
274
275async function refreshAndComplete<T>(options: RefreshOptions, complete: (selection: VerifiedSelection) => Promise<T>): Promise<{ store: Readonly<RuntimeTrustStoreSummary>, value: T }> {
263276 options = { ...options }
264277 return withStore(options, async (db, binding, trusted) => {
265278 if (db.query("SELECT id FROM attempts WHERE phase IN ('pending', 'maintenance')").get()) throw new Error('An unresolved update attempt requires recovery')
@@ -305,11 +318,14 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos
305318 const info = await updater.getTargetInfo(trusted.policy.target)
306319 if (!info) throw new Error('Selected runtime target is absent')
307320 const runtime = admitSignedRuntime(info, { ...trusted.policy, minimumReleaseSequence: state(db, binding).release_floor })
321 // The signed release floor survives later image or candidate-output failure.
308322 db.transaction(() => {
309323 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)
324 db.query('UPDATE attempts SET accepted_release = ? WHERE id = ?').run(runtime.releaseSequence, attempt)
311325 })()
312 return summary(db, binding, options.storeSha256)
326 const value = await complete({ db, binding, trusted, source, info, runtime })
327 db.query("UPDATE attempts SET phase = 'verified' WHERE id = ?").run(attempt)
328 return { store: summary(db, binding, options.storeSha256), value }
313329 }
314330 catch (error) {
315331 try { db.query("UPDATE attempts SET phase = 'maintenance' WHERE id = ?").run(attempt) }
@@ -320,6 +336,47 @@ export async function refreshRuntimeTrustStore(options: StoreReference & { repos
320336 })
321337}
322338
339/** Verify bounded offline metadata and retain its accepted prefix. This never downloads an image. */
340export async function refreshRuntimeTrustStore(options: RefreshOptions): Promise<Readonly<RuntimeTrustStoreSummary>> {
341 return (await refreshAndComplete(options, async () => undefined)).store
342}
343
344export interface PreparedRuntimeFirmware {
345 schema: 'drone-runtime-prepared-image-v1'
346 store: Readonly<RuntimeTrustStoreSummary>
347 candidate: Readonly<RuntimeFirmwareStage>
348 trustStatePersisted: true
349 physicalDeploymentAllowed: false
350}
351
352/** Authenticate an image under current protected metadata. Never installs, selects or confirms firmware. */
353export async function prepareRuntimeFirmwareUpdate(options: RefreshOptions & { output: string }): Promise<Readonly<PreparedRuntimeFirmware>> {
354 options = { ...options }
355 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')!
358 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>()
363 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))
366 }
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)
376 })
377 return Object.freeze({ schema: 'drone-runtime-prepared-image-v1', store: result.store, candidate: result.value, trustStatePersisted: true, physicalDeploymentAllowed: false })
378}
379
323380function causedBy(error: unknown, marker: Error) {
324381 const seen = new Set<unknown>()
325382 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-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+37-19
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, inspectRuntimeTrustStore, prepareRuntimeFirmwareUpdate, recoverRuntimeTrustStore, refreshRuntimeTrustStore } 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 = 'input-committed' | 'metadata-committed' | 'image-written' | 'candidate-completed'
12interface WorkerInput { reference: Reference, repository: string, cut: Cut, output?: 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,30 @@ 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.output) await prepareRuntimeFirmwareUpdate({ ...options, output: input.output })
33 else await refreshRuntimeTrustStore(options)
3134 throw new Error('Interruption worker unexpectedly finished')
3235}
3336
3437/** Keep generated signed repositories and actual host process outcomes for inspection. No power-loss claim. */
35export async function qualifyStoreInterruptions(directory: string) {
38async function qualifyInterruptions(directory: string, preparation: boolean) {
3639 await mkdir(directory, { mode: 0o700 })
3740 const results = []
38 for (const cut of ['input-committed', 'metadata-committed'] as const) {
41 const cuts: Cut[] = preparation ? ['image-written', 'candidate-completed'] : ['input-committed', 'metadata-committed']
42 for (const cut of cuts) {
3943 const root = join(directory, cut); await mkdir(root, { mode: 0o700 })
4044 const source = join(root, 'repo'), fixture = await repository(source, { custom: { releaseSequence: 9 } })
4145 const enrollmentDirectory = join(root, 'enrollment')
4246 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: enrollmentDirectory })
4347 const created = await createRuntimeTrustStore({ enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, output: join(root, 'store') })
4448 const reference = { enrollmentDirectory, enrollmentSha256: enrolled.enrollmentSha256, directory: join(root, 'store'), storeSha256: created.storeSha256 }
45 const file = join(root, 'worker.json')
46 await writeFile(file, JSON.stringify({ reference, repository: source, cut } satisfies WorkerInput), { mode: 0o600 })
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 })
4751 const child = Bun.spawn([process.execPath, import.meta.path, 'hold', file], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
4852 const timer = setTimeout(() => child.kill('SIGKILL'), 15000)
4953 const reader = child.stdout.getReader(), decoder = new TextDecoder()
@@ -65,14 +69,24 @@ export async function qualifyStoreInterruptions(directory: string) {
6569 assert.equal(signal, 'SIGKILL'); assert.notEqual(exit, 0)
6670 const interrupted = await inspectRuntimeTrustStore(reference)
6771 assert.equal(interrupted.attempt?.phase, 'pending')
68 assert.equal(interrupted.generation, cut === 'input-committed' ? 0 : 1)
72 assert.equal(interrupted.generation, preparation ? 3 : cut === 'input-committed' ? 0 : 1)
73 assert.equal(interrupted.releaseFloor, preparation ? 9 : 7)
74 const candidateStagePresent = output ? await Bun.file(join(output, 'stage.json')).exists() : false
75 if (output) {
76 assert.deepEqual(await readFile(join(output, 'image.bin')), fixture.image)
77 assert.equal(candidateStagePresent, cut === 'candidate-completed')
78 }
6979 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)
80 assert.equal(recovered.generation, preparation ? 3 : 1); assert.equal(recovered.attempt?.phase, 'recovered')
81 assert.equal(recovered.releaseFloor, preparation ? 9 : 7)
82 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
85 assert.equal(fresh.generation >= interrupted.generation, true)
86 if (!preparation) assert.equal(fresh.generation, 3)
87 assert.equal(fresh.releaseFloor, 9)
7488 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 })
89 results.push({ cut, ready, killedExit: exit, killedSignal: signal, contenderExit: contender.status, contender: contender.stdout, candidateStagePresent, interrupted, recovered, fresh })
7690 }
7791 finally {
7892 clearTimeout(timer)
@@ -81,14 +95,18 @@ export async function qualifyStoreInterruptions(directory: string) {
8195 reader.releaseLock()
8296 await writeFile(join(root, 'worker.stdout'), stdout)
8397 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))
98 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', results, physicalDeploymentAllowed: false }, null, 2))
8599 }
86100 }
87 return { evidence: 'host-process-interruption-only', cuts: results.length, physicalDeploymentAllowed: false }
101 return { evidence: 'host-process-interruption-only', operation: preparation ? 'prepare' : 'refresh', cuts: results.length, physicalDeploymentAllowed: false }
88102}
89103
104export async function qualifyStoreInterruptions(directory: string) { return qualifyInterruptions(directory, false) }
105export async function qualifyPreparationInterruptions(directory: string) { return qualifyInterruptions(directory, true) }
106
90107if (import.meta.main) {
91 if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
108 if (process.argv.length === 4 && process.argv[2] === 'prepare') console.log(JSON.stringify(await qualifyPreparationInterruptions(resolve(process.argv[3]!))))
109 else if (process.argv.length === 4) await worker(process.argv[2]!, process.argv[3]!)
92110 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')
111 else throw new Error('Use store-interruptions.ts [prepare] NEW_DIRECTORY')
94112}
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})