ReviewOS

also looking at this

stacks/drone

feat: expose verified metadata persistence and failure phases

#45
Merged chris wants to merge codex/runtime-trust-sdk into main
30 files +1358 -133
.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -34,6 +34,10 @@ jobs:
3434 - run: bun run runtime:cli
3535 - name: Verify signed test-image staging through the portable CLI outside the checkout
3636 run: bun tests/fixtures/updates/qualify.ts "$RUNNER_TEMP/runtime-update-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
37 - name: Verify protected initial trust through a copied standalone CLI
38 run: bun tests/fixtures/updates/qualify-trust.ts "$RUNNER_TEMP/runtime-trust-qualification" "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone"
39 - name: Verify typed SDK failures in a minified copied standalone executable
40 run: bun tests/fixtures/updates/qualify-sdk.ts "$RUNNER_TEMP/runtime-sdk-qualification"
3741 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone runtime:bench --project "$GITHUB_WORKSPACE" --ticks 100 --fault deadline
3842 working-directory: ${{ runner.temp }}
3943 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone sensors:replay "$GITHUB_WORKSPACE/tests/fixtures/sensors/bench-v1.json" --at-ms 500
README.mdmodified+4-2
Changes to README.md
@@ -264,8 +264,10 @@ The viewer opens files locally in the browser. The v1 format excludes imagery, l
264264operator tokens. See [diagnostics, durability and remaining acceptance](docs/diagnostics.md).
265265The [signed runtime image stage](docs/runtime-updates.md) now verifies offline TUF
266266candidates through `updates:stage` and `updates:inspect`, with explicit root pins,
267compatibility and externally supplied release floors. Physical logging/commissioning,
268persistent trust state, image installation and known-good rollback remain pending.
267compatibility 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.
269271
270272## Sensor acquisition and replay
271273
app/Commands/UpdatesEnroll.tsadded+22-0
Changes to app/Commands/UpdatesEnroll.ts
@@ -0,0 +1,22 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { firmwareCommandPolicy, firmwarePolicyFlags } from '../../resources/functions/drone/updates/command'
4import { enrollRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
5
6export default defineCommand({
7 name: 'updates:enroll',
8 description: 'Preserve an independently trusted initial root and explicit policy without refreshing or installing firmware',
9 options: { ...firmwarePolicyFlags, '--trusted-root <path>': 'Independently authenticated initial root file', '--output <path>': 'New exclusive protected enrollment directory' },
10 async handle(options) {
11 try {
12 const policy = firmwareCommandPolicy(options)
13 if (typeof options.trustedRoot !== 'string' || typeof options.output !== 'string') throw new Error('Explicit root path and output required')
14 await printAsync(JSON.stringify(enrollRuntimeTrust({ ...policy, trustedRoot: options.trustedRoot, output: options.output })))
15 }
16 catch {
17 process.exitCode = 1
18 try { await printErrorAsync('Runtime trust enrollment unavailable: invalid anchor, policy or output failure') }
19 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
20 }
21 },
22})
app/Commands/UpdatesTrust.tsadded+20-0
Changes to app/Commands/UpdatesTrust.ts
@@ -0,0 +1,20 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
4
5export default defineCommand({
6 name: 'updates:trust <directory>',
7 description: 'Verify an initial trust enrollment against its independently retained record hash',
8 options: { '--enrollment-sha256 <hex>': 'Independently retained SHA-256 from trusted enrollment' },
9 async handle(options, directory) {
10 try {
11 if (typeof options.enrollmentSha256 !== 'string') throw new Error('An independently retained enrollment pin is required')
12 await printAsync(JSON.stringify(inspectRuntimeTrust({ directory, enrollmentSha256: options.enrollmentSha256 })))
13 }
14 catch {
15 process.exitCode = 1
16 try { await printErrorAsync('Runtime trust unavailable: invalid enrollment pin or protected state') }
17 catch { /* Preserve exit 1 when the diagnostic pipe also fails. */ }
18 }
19 },
20})
bin/drone.tsmodified+4-0
Changes to bin/drone.ts
@@ -21,6 +21,8 @@ import DiagnosticsInspect from '../app/Commands/DiagnosticsInspect'
2121import DiagnosticsExport from '../app/Commands/DiagnosticsExport'
2222import UpdatesStage from '../app/Commands/UpdatesStage'
2323import UpdatesInspect from '../app/Commands/UpdatesInspect'
24import UpdatesEnroll from '../app/Commands/UpdatesEnroll'
25import UpdatesTrust from '../app/Commands/UpdatesTrust'
2426
2527import MissionsSimulate from '../app/Commands/MissionsSimulate'
2628import MissionsValidate from '../app/Commands/MissionsValidate'
@@ -85,6 +87,8 @@ DiagnosticsInspect(app)
8587DiagnosticsExport(app)
8688UpdatesStage(app)
8789UpdatesInspect(app)
90UpdatesEnroll(app)
91UpdatesTrust(app)
8892MissionsSimulate(app)
8993MissionsValidate(app)
9094MissionsReplay(app)
bun.lockmodified+3-0
Changes to bun.lock
@@ -38,6 +38,9 @@
3838 "version": "0.75.221",
3939 },
4040 },
41 "patchedDependencies": {
42 "tuf-js@6.0.0": "patches/tuf-js@6.0.0.patch",
43 },
4144 "overrides": {
4245 "@stacksjs/bunpress": "0.2.22",
4346 },
config/docs.tsmodified+1-1
Changes to config/docs.ts
@@ -67,7 +67,7 @@ const config: BunPressOptions = {
6767 { text: 'Owned mission engine', link: '/missions' },
6868 { text: 'Owned range-backed navigation', link: '/navigation' },
6969 { text: 'Owned runtime diagnostics, journals and replay', link: '/diagnostics' },
70 { text: 'Signed runtime image staging', link: '/runtime-updates' },
70 { text: 'Runtime trust and image staging', link: '/runtime-updates' },
7171 ],
7272 },
7373 {
docs/diagnostics.mdmodified+3-2
Changes to docs/diagnostics.md
@@ -156,8 +156,9 @@ Physical flash power-loss, full-medium and electrical I/O fault campaigns are st
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.
158158- [Offline signed image staging](runtime-updates.md) now authenticates TUF roles,
159 declared target compatibility and a caller-supplied release floor. Build persistent
160 trust/key lifecycle and metadata/release high-water state, interrupted update
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
161162 recovery and a known-good rollback slot before flashing. Each staging invocation
162163 currently starts from the independently supplied root; it is not full anti-rollback.
163164- Implement a commissioning workflow backed by measured wiring, neutral outputs, watchdog,
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -47,7 +47,7 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md),
4747[navigation](navigation.md) have separate simulation and hardware acceptance gates.
4848
4949[Diagnostics and replay](diagnostics.md) retain explicit loss and provenance.
50[Signed image staging](runtime-updates.md) authenticates offline candidates;
50[Runtime trust and image staging](runtime-updates.md) preserves initial trust and authenticates offline candidates;
5151persistent trust, boot selection and rollback remain required before installation.
5252The [library API](api.md) describes behavior extensions. The
5353[shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground,
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](../runtime-updates.md); full mission correlation, hardware evidence, persistent trust 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 [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) |
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/runtime-updates.mdmodified+104-9
Changes to docs/runtime-updates.md
@@ -1,17 +1,19 @@
1# Signed runtime image staging
1# Runtime trust and signed image staging
22
33Issue [#9](https://github.com/stacksjs/drone/issues/9) now has offline signed-image
4staging and independent candidate verification. These commands prepare files on
5the host. Persistent trust state, installed images, boot selection and known-good
6rollback slots remain to be implemented. Physical deployment is disabled.
4staging, independent candidate verification and protected initial trust enrollment.
5These commands prepare files on the host. Cross-update metadata/release floors,
6installed images, boot selection and known-good rollback slots remain to be
7implemented. Physical deployment is disabled.
78
89## Explicit trust and policy
910
1011Enroll an initial TUF `root.json` and its SHA-256 through an independent trusted
1112channel. Do not calculate a pin from an untrusted bundle and call it trusted.
1213The release floor must likewise come from externally trusted release policy.
13Every invocation requires the root pin, exact target, expected bench platform
14and positive minimum release sequence. There are no permissive defaults.
14Every staging or candidate-inspection invocation requires the root pin, exact
15target, expected bench platform and positive minimum release sequence. There are
16no permissive defaults.
1517
1618```sh
1719./buddy updates:stage /path/to/offline-repository \
@@ -36,6 +38,50 @@ be group/world writable. Their parent paths must also be protected against
3638replacement by other users. Same-user privileged filesystem mutation is outside
3739the protection model; this is not an adversarial-filesystem sandbox.
3840
41## Protected initial trust enrollment
42
43Store the independently authenticated root and explicit policy in a new private
44directory with native Buddy or the compiled CLI:
45
46```sh
47./buddy updates:enroll --trusted-root /path/to/enrolled/root.json \
48 --root-sha256 "$ENROLLED_ROOT_SHA256" \
49 --target runtime/pico2-bench.bin --platform pico2-neutral-bench-v1 \
50 --minimum-release-sequence 7 --output /path/to/new-trust
51
52./buddy updates:trust /path/to/new-trust \
53 --enrollment-sha256 "$INDEPENDENTLY_RETAINED_ENROLLMENT_SHA256"
54```
55
56Retain the returned `enrollmentSha256` through your trusted configuration or
57backup channel. Readback requires that independent expected hash; a hash taken
58from the same untrusted directory does not establish trust. The record binds a
59random enrollment identity, exact root hash/version and copied release policy.
60Readback checks the actual root bytes, schema, policy and root self-signatures.
61
62Enrollment creates `root.json` and a final `enrollment.json` record, using an
63exclusive 0700 directory and 0600 files. The directory and immediate parent must
64be owned and protected from other principals' writes; higher parent paths must
65also be protected against replacement. Existing, partial or corrupt enrollments
66are preserved and rejected. Files/directories are synced, but the record is a
67logical completion marker, not proof of physical power-loss durability. A command
68can fail after creating the record, including if output fails. Preserve those
69files and independently verify the record before relying on them.
70
71The result reports `metadataState: "initial-root-only"`,
72`metadataRefreshPerformed: false`, `firmwareSelectionPerformed: false` and
73`physicalDeploymentAllowed: false`. An authenticated expired initial root may be
74enrolled for later sequential rotation; this does not make an expired final root
75valid for a repository refresh. No refresh or network operation runs here.
76
77Keep this immutable anchor separate from an SDK working cache. The current SDK
78writes cache files in place; a host write-limit probe truncated its cached root.
79Never refresh against the enrollment directory. Staging/inspection still require
80their explicit policy and do not load or advance this enrollment automatically.
81Persistent verified metadata/release floors, key lifecycle, interruption recovery
82and known-good coordination remain in [issue #42](https://github.com/stacksjs/drone/issues/42).
83Do not discard or re-enroll over failed state to reset a security floor.
84
3985## Repository and signed image contract
4086
4187Use a standard TUF repository with `metadata/` and `targets/` directories,
@@ -52,6 +98,53 @@ Metadata versions must be positive safe integers. Expiration dates must be real
5298UTC calendar dates in the TUF `YYYY-MM-DDTHH:MM:SSZ` format before the SDK makes
5399expiry comparisons; malformed or automatically normalized dates are rejected.
54100
101### Maintained SDK persistence seam
102
103This repository applies `patches/tuf-js@6.0.0.patch` through Bun's root
104`patchedDependencies`. It extends the pinned MIT-licensed client; these are
105repository-maintained options, not options supplied by unmodified upstream 6.0.0.
106The patch retains upstream signature, role and target verification.
107
108`onVerifiedMetadata(role, bytes)` receives an owned byte copy after the SDK
109accepts remote metadata. The SDK awaits it before writing that cache file or
110advancing to the next role. A rejected observer aborts refresh, including when
111its exception is an HTTP 404. Cache-preferred refresh also propagates persistence
112errors instead of retrying past the rejected observer. Initial root loading and
113cached metadata do not
114emit new observations. An authenticated intermediate root may be observed
115before a later final-root expiry check rejects the refresh. Discard an updater
116after any refresh failure: its upstream in-memory trust set may already have
117advanced. Reusing that instance is not a recovery or persistence retry protocol.
118
119`referenceTime` optionally supplies a finite, copied fixed start time for future
120recovery replay. It does not establish a trusted clock. Historical replay must
121never authorize firmware use: a coordinator must perform fresh verification at
122a trusted current wall time before using a candidate. Existing staging and
123inspection pass neither extension and continue to use the current wall clock.
124
125The maintained public exports `MetadataVerificationError`, `PersistError`,
126`DownloadError` and `DownloadHTTPError` identify operational failure phases.
127Snapshot, targets and persistence wrappers retain the original `Error.cause`.
128A caught value is never coerced to build a wrapper message, including a `Symbol`
129or an object whose string conversion throws. Persistence rejection must still
130abort a cache-preferred refresh in those cases.
131A remote metadata verification error includes the role being admitted and its
132original cause. Equal timestamp versions keep the normal unchanged-metadata
133path. Initial-root and cached-metadata errors retain upstream behavior.
134
135Classify a `PersistError` before examining its cause: an observer can itself
136throw a verification error, which is still failed persistence. A verification
137phase error alone does not prove that input was invalid, that a prefix is safe
138to discard, or that recovery may continue. Unknown crypto, resource or other
139causes must fail closed pending investigation. Error objects and type names do
140not establish authenticated trust or permit firmware use. The recovery
141coordinator and its full failure classification remain to be implemented.
142
143The seam alone does not persist a protected prefix or make SDK cache writes
144transactional. Its implementation and tests are a prerequisite for the protected
145store and recovery coordinator in issue #42. They do not complete that issue or
146permit flashing, boot selection or physical deployment.
147
55148The selected target must have SHA-256, a nonzero length of at most 16 MiB and
56149exactly these fields in its signed `custom.drone` object:
57150
@@ -104,7 +197,8 @@ must supply the trusted release floor every time. TUF expiry uses host wall
104197time; trusted clock acquisition and protection against clock/storage rollback
105198remain open. Control-loop freshness still uses its separate monotonic clock.
106199
107Next, implement persistent trust enrollment/rotation and release floors, an
200Initial enrollment is available above. Next, implement protected trust rotation
201and persistent metadata/release floors, an
108202interruption-safe coordinator, installed/known-good slot integrity, explicit
109203health confirmation and rollback without lowering security floors. Then add
110204the board bootloader/install protocol and measured power-loss, full-medium,
@@ -114,7 +208,8 @@ anti-rollback. See [TUF's workflow and threat model](https://theupdateframework.
114208
115209Tests use generated throwaway Ed25519 keys and clearly synthetic payload bytes.
116210They cover real role signatures/rotation, expiration, compatibility, supplied
117release floors, tampering, bounded inputs, incomplete candidates and native/
118portable CLI behavior. They establish host behavior, not actual firmware
211release floors, tampering, bounded inputs, incomplete candidates, enrollment
212readback, competing enrollment processes, an actual host write-limit failure and
213native/portable CLI behavior. They establish host behavior, not actual firmware
119214installation, board boot recovery or commissioning. The diagnostic supervisor's
120215update/rollback check remains blocked and `commissioningReady` stays false.
package.jsonmodified+3-0
Changes to package.json
@@ -82,5 +82,8 @@
8282 },
8383 "overrides": {
8484 "@stacksjs/bunpress": "0.2.22"
85 },
86 "patchedDependencies": {
87 "tuf-js@6.0.0": "patches/tuf-js@6.0.0.patch"
8588 }
8689}
patches/README.mdadded+35-0
Changes to patches/README.md
@@ -0,0 +1,35 @@
1# Maintained dependency patches
2
3## tuf-js 6.0.0
4
5`tuf-js@6.0.0.patch` applies to the npm 6.0.0 release from
6[the TUF JavaScript client](https://github.com/theupdateframework/tuf-js), upstream
7commit `c30653d07f0c41fbe5a2030d25eca6fd04f8547f`. The original MIT copyright and permission
8notice is retained in `tuf-js.LICENSE` and in the installed package.
9
10The patch adds an awaited, copy-owning `onVerifiedMetadata` observer before the
11SDK writes each newly verified remote metadata file, plus an owned finite
12`referenceTime` for recovery replay. Default callers keep upstream verification rules and the current fixed start
13time. Persistence failure aborts rather than being interpreted as a missing
14remote root or a cache miss under `forceCache`. Discard failed updater instances;
15their in-memory trust set is not rolled back. It does not make cache writes
16transactional or implement a protected trust store. Public operational error
17exports retain causes and identify remote verification, persistence and download
18phases. Verification errors identify an attempted role, not permission to discard
19state. Classify persistence first, including when an observer throws a
20verification error; unknown causes require maintenance rather than an assumed
21invalid-input verdict.
22Wrapper messages never coerce caught values, so opaque observer rejections keep
23their persistence classification and exact cause under cache-preferred refresh.
24
25Bun applies the patch through root `patchedDependencies` and the lockfile.
26Always prepare with `bun patch tuf-js@6.0.0` before editing an installed package,
27then generate changes with `bun patch --commit tuf-js@6.0.0`. Do not edit shared
28package caches. Exclude empty generated `.bun-tag-*` entries from the patch;
29they are installer cache markers rather than package source. For upgrades, review the upstream change against every patch
30hunk and rerun the SDK observer cases, update/enrollment regressions, clean
31frozen installation and copied standalone CLI qualification. Never silently
32carry a patch onto another version.
33
34See `docs/runtime-updates.md` for the trust and clock limits. The extensions are
35maintained here and are not claimed to be unmodified upstream options.
patches/tuf-js.LICENSEadded+21-0
Changes to patches/tuf-js.LICENSE
@@ -0,0 +1,21 @@
1MIT License
2
3Copyright (c) 2022 GitHub and the TUF Contributors
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
patches/tuf-js@6.0.0.patchadded+255-0
Changes to patches/tuf-js@6.0.0.patch
@@ -0,0 +1,255 @@
1diff --git a/dist/error.d.ts b/dist/error.d.ts
2index 3a42f0441b68f961d6e367efee45b4f4158bbccd..727dd12095b649a8d6203e7eba6d9ebb59be68d6 100644
3--- a/dist/error.d.ts
4+++ b/dist/error.d.ts
5@@ -6,6 +6,10 @@ export declare class PersistError extends Error {
6 }
7 export declare class RepositoryError extends Error {
8 }
9+export declare class MetadataVerificationError extends Error {
10+ readonly role: string;
11+ constructor(role: string, message: string, options?: ErrorOptions);
12+}
13 export declare class BadVersionError extends RepositoryError {
14 }
15 export declare class EqualVersionError extends BadVersionError {
16diff --git a/dist/error.js b/dist/error.js
17index 3a3c26a068a953998e16e552faf96b6fbe956f90..e4c07310caa36177ce390e6e3d82dd76178432f4 100644
18--- a/dist/error.js
19+++ b/dist/error.js
20@@ -17,6 +17,15 @@ exports.PersistError = PersistError;
21 class RepositoryError extends Error {
22 }
23 exports.RepositoryError = RepositoryError;
24+// Repository-maintained public distinction for pure remote metadata validation.
25+class MetadataVerificationError extends Error {
26+ role;
27+ constructor(role, message, options) {
28+ super(message, options);
29+ this.role = role;
30+ }
31+}
32+exports.MetadataVerificationError = MetadataVerificationError;
33 // An error for metadata that contains an invalid version number.
34 class BadVersionError extends RepositoryError {
35 }
36diff --git a/dist/index.d.ts b/dist/index.d.ts
37index 895fdf4b57c27baf4571e8d06bb93de69166f574..aa1e170b7832c2df49cf2628f77d7895a5f0cbf1 100644
38--- a/dist/index.d.ts
39+++ b/dist/index.d.ts
40@@ -4,3 +4,4 @@ export { Updater } from './updater';
41 export type { Config } from './config';
42 export type { Fetcher } from './fetcher';
43 export type { UpdaterOptions } from './updater';
44+export { DownloadError, DownloadHTTPError, MetadataVerificationError, PersistError } from './error';
45diff --git a/dist/index.js b/dist/index.js
46index 5a83b91f355d88c27615d2bc07310958b18d0142..645728da0d96110d323b0f5e5b2c5f0e839c6fce 100644
47--- a/dist/index.js
48+++ b/dist/index.js
49@@ -7,3 +7,10 @@ var fetcher_1 = require("./fetcher");
50 Object.defineProperty(exports, "BaseFetcher", { enumerable: true, get: function () { return fetcher_1.BaseFetcher; } });
51 var updater_1 = require("./updater");
52 Object.defineProperty(exports, "Updater", { enumerable: true, get: function () { return updater_1.Updater; } });
53+
54+// Repository-maintained operational error exports; use causes rather than message matching.
55+var error_1 = require("./error");
56+Object.defineProperty(exports, "DownloadError", { enumerable: true, get: function () { return error_1.DownloadError; } });
57+Object.defineProperty(exports, "DownloadHTTPError", { enumerable: true, get: function () { return error_1.DownloadHTTPError; } });
58+Object.defineProperty(exports, "MetadataVerificationError", { enumerable: true, get: function () { return error_1.MetadataVerificationError; } });
59+Object.defineProperty(exports, "PersistError", { enumerable: true, get: function () { return error_1.PersistError; } });
60diff --git a/dist/store.d.ts b/dist/store.d.ts
61index 20f835c29dee82ea3e61c775c83d8a34fad787dc..3b9c2f88cbe9082b45927ccabccb44f1d56d8739 100644
62--- a/dist/store.d.ts
63+++ b/dist/store.d.ts
64@@ -2,7 +2,7 @@ import { Metadata, Root, Snapshot, Targets, Timestamp } from '@tufjs/models';
65 export declare class TrustedMetadataStore {
66 private trustedSet;
67 private referenceTime;
68- constructor(rootData: Buffer);
69+ constructor(rootData: Buffer, referenceTime?: Date);
70 get root(): Metadata<Root>;
71 get timestamp(): Metadata<Timestamp> | undefined;
72 get snapshot(): Metadata<Snapshot> | undefined;
73diff --git a/dist/store.js b/dist/store.js
74index 1b1669029a8db00b7cc594ef202984f3d243a0e4..0ef50b3d8b599b7d49392d7d8c5f63d3c6f0ecce 100644
75--- a/dist/store.js
76+++ b/dist/store.js
77@@ -6,9 +6,9 @@ const error_1 = require("./error");
78 class TrustedMetadataStore {
79 trustedSet = {};
80 referenceTime;
81- constructor(rootData) {
82+ constructor(rootData, referenceTime = new Date()) {
83 // Client workflow 5.1: record fixed update start time
84- this.referenceTime = new Date();
85+ this.referenceTime = new Date(referenceTime.getTime());
86 // Client workflow 5.2: load trusted root metadata
87 this.loadTrustedRoot(rootData);
88 }
89diff --git a/dist/updater.d.ts b/dist/updater.d.ts
90index 883df0fe6a6628ef5d16bdb0de17c0850d7d19ec..0fe93d96f9a7912c6708bfdcd6f8bcc7330c9e2e 100644
91--- a/dist/updater.d.ts
92+++ b/dist/updater.d.ts
93@@ -9,6 +9,10 @@ export interface UpdaterOptions {
94 fetcher?: Fetcher;
95 forceCache?: boolean;
96 config?: Partial<Config>;
97+ /** Repository-maintained extension: called after verification, awaited before cache persistence. */
98+ onVerifiedMetadata?: (role: string, bytes: Buffer) => void | Promise<void>;
99+ /** Repository-maintained extension: copied fixed start time for recovery replay, not a trusted clock source. */
100+ referenceTime?: Date;
101 }
102 export declare class Updater {
103 private dir;
104@@ -19,6 +23,7 @@ export declare class Updater {
105 private trustedSet;
106 private config;
107 private fetcher;
108+ private onVerifiedMetadata;
109 constructor(options: UpdaterOptions);
110 refresh(): Promise<void>;
111 getTargetInfo(targetPath: string): Promise<TargetFile | undefined>;
112@@ -31,5 +36,6 @@ export declare class Updater {
113 private loadTargets;
114 private preorderDepthFirstWalk;
115 private generateTargetPath;
116+ private verifyMetadata;
117 private persistMetadata;
118 }
119diff --git a/dist/updater.js b/dist/updater.js
120index 94a7658155740fee3f3b7b32937905cc7f791e90..ce62cfdc83eeec41479089aa301cbea250d8d116 100644
121--- a/dist/updater.js
122+++ b/dist/updater.js
123@@ -57,6 +57,7 @@ class Updater {
124 trustedSet;
125 config;
126 fetcher;
127+ onVerifiedMetadata;
128 constructor(options) {
129 const { metadataDir, metadataBaseUrl, targetDir, targetBaseUrl, fetcher, config, } = options;
130 this.dir = metadataDir;
131@@ -64,8 +65,16 @@ class Updater {
132 this.targetDir = targetDir;
133 this.targetBaseUrl = targetBaseUrl;
134 this.forceCache = options.forceCache ?? false;
135+ const referenceTime = options.referenceTime ?? new Date();
136+ if (!(referenceTime instanceof Date) || !Number.isFinite(referenceTime.getTime())) {
137+ throw new error_1.ValueError('Invalid fixed update reference time');
138+ }
139+ if (options.onVerifiedMetadata !== undefined && typeof options.onVerifiedMetadata !== 'function') {
140+ throw new error_1.ValueError('Invalid verified metadata observer');
141+ }
142+ this.onVerifiedMetadata = options.onVerifiedMetadata;
143 const data = this.loadLocalMetadata(models_1.MetadataKind.Root);
144- this.trustedSet = new store_1.TrustedMetadataStore(data);
145+ this.trustedSet = new store_1.TrustedMetadataStore(data, new Date(referenceTime.getTime()));
146 this.config = { ...config_1.defaultConfig, ...config };
147 const userAgent = config?.userAgent
148 ? `${config.userAgent} tuf-js/${package_json_1.version}`
149@@ -92,6 +101,10 @@ class Updater {
150 await this.loadTimestamp({ checkRemote: false });
151 }
152 catch (error) {
153+ // A failed persistence observer/cache write is not a cache miss.
154+ if (error instanceof error_1.PersistError) {
155+ throw error;
156+ }
157 await this.loadRoot();
158 await this.loadTimestamp();
159 }
160@@ -173,9 +186,9 @@ class Updater {
161 // Client workflow 5.3.3: download new root metadata file
162 const bytesData = await this.fetcher.downloadBytes(rootUrl, this.config.rootMaxLength);
163 // Client workflow 5.3.4 - 5.4.7
164- this.trustedSet.updateRoot(bytesData);
165+ this.verifyMetadata(models_1.MetadataKind.Root, () => this.trustedSet.updateRoot(bytesData));
166 // Client workflow 5.3.8: persist root metadata file
167- this.persistMetadata(models_1.MetadataKind.Root, bytesData);
168+ await this.persistMetadata(models_1.MetadataKind.Root, bytesData);
169 }
170 catch (error) {
171 if (error instanceof error_1.DownloadHTTPError) {
172@@ -210,7 +223,7 @@ class Updater {
173 const bytesData = await this.fetcher.downloadBytes(timestampUrl, this.config.timestampMaxLength);
174 try {
175 // Client workflow 5.4.2 - 5.4.4
176- this.trustedSet.updateTimestamp(bytesData);
177+ this.verifyMetadata(models_1.MetadataKind.Timestamp, () => this.trustedSet.updateTimestamp(bytesData));
178 }
179 catch (error) {
180 // If new timestamp version is same as current, discardd the new one.
181@@ -222,7 +235,7 @@ class Updater {
182 throw error;
183 }
184 // Client workflow 5.4.5: persist timestamp metadata
185- this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData);
186+ await this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData);
187 }
188 // Load local and remote snapshot metadata.
189 // Client workflow 5.5: update snapshot role
190@@ -246,12 +259,12 @@ class Updater {
191 // Client workflow 5.5.1: download snapshot metadata file
192 const bytesData = await this.fetcher.downloadBytes(snapshotUrl, maxLength);
193 // Client workflow 5.5.2 - 5.5.6
194- this.trustedSet.updateSnapshot(bytesData);
195+ this.verifyMetadata(models_1.MetadataKind.Snapshot, () => this.trustedSet.updateSnapshot(bytesData));
196 // Client workflow 5.5.7: persist snapshot metadata file
197- this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData);
198+ await this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData);
199 }
200 catch (error) {
201- throw new error_1.RuntimeError(`Unable to load snapshot metadata error ${error}`);
202+ throw new error_1.RuntimeError('Unable to load snapshot metadata', { cause: error });
203 }
204 }
205 }
206@@ -282,12 +295,12 @@ class Updater {
207 // Client workflow 5.6.1: download targets metadata file
208 const bytesData = await this.fetcher.downloadBytes(metadataUrl, maxLength);
209 // Client workflow 5.6.2 - 5.6.6
210- this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole);
211+ this.verifyMetadata(role, () => this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole));
212 // Client workflow 5.6.7: persist targets metadata file
213- this.persistMetadata(role, bytesData);
214+ await this.persistMetadata(role, bytesData);
215 }
216 catch (error) {
217- throw new error_1.RuntimeError(`Unable to load targets error ${error}`);
218+ throw new error_1.RuntimeError('Unable to load targets metadata', { cause: error });
219 }
220 }
221 return this.trustedSet.getRole(role);
222@@ -358,15 +371,31 @@ class Updater {
223 const filePath = encodeURIComponent(targetInfo.path);
224 return path.join(this.targetDir, filePath);
225 }
226- persistMetadata(metaDataName, bytesData) {
227+ verifyMetadata(role, operation) {
228+ try {
229+ return operation();
230+ }
231+ catch (error) {
232+ // Equal timestamp versions retain the existing normal no-new-metadata path.
233+ if (error instanceof error_1.EqualVersionError) {
234+ throw error;
235+ }
236+ throw new error_1.MetadataVerificationError(role, `Failed to verify metadata ${encodeURIComponent(role)}`, { cause: error });
237+ }
238+ }
239+ async persistMetadata(metaDataName, bytesData) {
240 const encodedName = encodeURIComponent(metaDataName);
241 try {
242+ // The observer owns its copy and must finish before this cache or the next role advances.
243+ if (this.onVerifiedMetadata) {
244+ await this.onVerifiedMetadata(metaDataName, Buffer.from(bytesData));
245+ }
246 const filePath = path.join(this.dir, `${encodedName}.json`);
247 log('WRITE %s', filePath);
248 fs.writeFileSync(filePath, bytesData.toString('utf8'));
249 }
250 catch (error) {
251- throw new error_1.PersistError(`Failed to persist metadata ${encodedName} error: ${error}`);
252+ throw new error_1.PersistError(`Failed to persist metadata ${encodedName}`, { cause: error });
253 }
254 }
255 }
resources/functions/drone/updates/enrollment.tsadded+77-0
Changes to resources/functions/drone/updates/enrollment.ts
@@ -0,0 +1,77 @@
1import { randomBytes } from 'node:crypto'
2import { mkdirSync } from 'node:fs'
3import { dirname, join, resolve } from 'node:path'
4import { Updater } from 'tuf-js'
5import { directory, metadataLimit, regularFile, sha256, syncDirectory, writeExclusive } from './files'
6import { object, validateMetadata } from './metadata'
7import { admitPolicy } from './policy'
8import type { RuntimeFirmwarePolicy } from './policy'
9
10interface Enrollment {
11 schema: 'drone-runtime-trust-enrollment-v1'
12 enrollmentId: string
13 policy: Readonly<RuntimeFirmwarePolicy>
14 rootVersion: number
15}
16export interface RuntimeTrustSummary {
17 schema: 'drone-runtime-trust-v1'
18 enrollmentId: string
19 enrollmentSha256: string
20 policy: Readonly<RuntimeFirmwarePolicy>
21 rootVersion: number
22 metadataState: 'initial-root-only'
23 metadataRefreshPerformed: false
24 firmwareSelectionPerformed: false
25 physicalDeploymentAllowed: false
26}
27
28function authenticatedRoot(path: string, policy: RuntimeFirmwarePolicy) {
29 const root = regularFile(join(path, 'root.json'), metadataLimit)
30 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
31 validateMetadata(root)
32 // The public constructor verifies root self-signatures. Never refresh against the
33 // enrollment directory: it is the immutable anchor, not the SDK's writable cache.
34 new Updater({ metadataDir: path, metadataBaseUrl: 'http://127.0.0.1/unused-enrollment-only/' })
35 return object(object(JSON.parse(root.toString('utf8'))).signed).version as number
36}
37function summary(enrollment: Enrollment, enrollmentSha256: string): Readonly<RuntimeTrustSummary> {
38 return Object.freeze({ ...enrollment, schema: 'drone-runtime-trust-v1', enrollmentSha256, metadataState: 'initial-root-only', metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalDeploymentAllowed: false })
39}
40
41/** Persist an independently authenticated initial anchor and policy, without metadata refresh or deployment. */
42export function enrollRuntimeTrust(options: RuntimeFirmwarePolicy & { trustedRoot: string, output: string }): Readonly<RuntimeTrustSummary> {
43 const policy = admitPolicy(options), output = resolve(options.output)
44 const root = regularFile(options.trustedRoot, metadataLimit)
45 if (sha256(root) !== policy.trustedRootSha256) throw new Error('Trusted root SHA-256 does not match the independent pin')
46 validateMetadata(root)
47 directory(dirname(output))
48 mkdirSync(output, { mode: 0o700 })
49 writeExclusive(join(output, 'root.json'), root)
50 const rootVersion = authenticatedRoot(output, policy)
51 const enrollment: Enrollment = { schema: 'drone-runtime-trust-enrollment-v1', enrollmentId: randomBytes(16).toString('hex'), policy, rootVersion }
52 syncDirectory(output)
53 const bytes = Buffer.from(`${JSON.stringify(enrollment, null, 2)}\n`)
54 writeExclusive(join(output, 'enrollment.json'), bytes)
55 syncDirectory(output)
56 syncDirectory(dirname(output))
57 return summary(enrollment, sha256(bytes))
58}
59
60/** Independently verify an enrollment. A hash read from the same directory is not an independent trust pin. */
61export function inspectRuntimeTrust(options: { directory: string, enrollmentSha256: string }): Readonly<RuntimeTrustSummary> {
62 const path = resolve(options.directory), expected = options.enrollmentSha256
63 if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) throw new TypeError('Provide the independently retained enrollment SHA-256')
64 directory(dirname(path))
65 directory(path)
66 const bytes = regularFile(join(path, 'enrollment.json'), 16384)
67 if (sha256(bytes) !== expected) throw new Error('Enrollment SHA-256 does not match the independent pin')
68 const record = object(JSON.parse(bytes.toString('utf8')))
69 const keys = ['schema', 'enrollmentId', 'policy', 'rootVersion']
70 if (record.schema !== 'drone-runtime-trust-enrollment-v1' || Object.keys(record).length !== keys.length || !keys.every(key => Object.hasOwn(record, key)) || typeof record.enrollmentId !== 'string' || !/^[a-f0-9]{32}$/.test(record.enrollmentId)) throw new Error('Invalid runtime trust enrollment')
71 const input = object(record.policy), policyKeys = ['target', 'platform', 'trustedRootSha256', 'minimumReleaseSequence']
72 if (Object.keys(input).length !== policyKeys.length || !policyKeys.every(key => Object.hasOwn(input, key)) || typeof input.target !== 'string' || typeof input.trustedRootSha256 !== 'string' || typeof input.minimumReleaseSequence !== 'number' || (input.platform !== 'pico2-neutral-bench-v1' && input.platform !== 'host-neutral-bench-v1')) throw new Error('Invalid enrolled trust policy')
73 const policy = admitPolicy({ target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence })
74 const rootVersion = authenticatedRoot(path, policy)
75 if (rootVersion !== record.rootVersion) throw new Error('Enrolled root version does not match authenticated bytes')
76 return summary({ schema: 'drone-runtime-trust-enrollment-v1', enrollmentId: record.enrollmentId, policy, rootVersion }, expected)
77}
resources/functions/drone/updates/files.tsadded+74-0
Changes to resources/functions/drone/updates/files.ts
@@ -0,0 +1,74 @@
1import { constants, closeSync, fstatSync, fsyncSync, lstatSync, openSync, readSync, readdirSync, writeSync } from 'node:fs'
2import { join } from 'node:path'
3import { createHash } from 'node:crypto'
4
5export const metadataLimit = 512 * 1024
6export const imageLimit = 16 * 1024 * 1024
7export const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
8
9export function safePath(path: string) {
10 if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path')
11}
12export function regularFile(path: string, limit: number): Buffer {
13 const before = lstatSync(path)
14 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
15 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
16 try {
17 const stat = fstatSync(fd)
18 if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file')
19 const bytes = Buffer.alloc(stat.size + 1)
20 let offset = 0
21 while (offset < bytes.length) {
22 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
23 if (count === 0) break
24 offset += count
25 }
26 if (offset !== stat.size) throw new Error('Source file changed length while reading')
27 return bytes.subarray(0, offset)
28 }
29 finally { closeSync(fd) }
30}
31export function directory(path: string) {
32 const stat = lstatSync(path)
33 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
34 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Directories must be owned and not writable by other principals')
35}
36export function repositoryFile(root: string, path: string, limit: number) {
37 safePath(path)
38 directory(root)
39 const parts = path.split('/')
40 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
41 return regularFile(join(root, ...parts), limit)
42}
43export function writeExclusive(path: string, bytes: Uint8Array) {
44 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
45 try {
46 let offset = 0
47 while (offset < bytes.length) {
48 const count = writeSync(fd, bytes, offset, bytes.length - offset)
49 if (count <= 0) throw new Error('No progress writing protected runtime data')
50 offset += count
51 }
52 fsyncSync(fd)
53 }
54 finally { closeSync(fd) }
55}
56export function syncDirectory(path: string) {
57 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
58 try { fsyncSync(fd) }
59 finally { closeSync(fd) }
60}
61export function syncTree(path: string) {
62 directory(path)
63 for (const name of readdirSync(path)) {
64 const child = join(path, name), stat = lstatSync(child)
65 if (stat.isDirectory()) syncTree(child)
66 else {
67 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
68 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
69 try { fsyncSync(fd) }
70 finally { closeSync(fd) }
71 }
72 }
73 syncDirectory(path)
74}
resources/functions/drone/updates/metadata.tsadded+21-0
Changes to resources/functions/drone/updates/metadata.ts
@@ -0,0 +1,21 @@
1import { canonicalize } from '@tufjs/canonical-json'
2import { sha256 } from './files'
3
4export function object(value: unknown): Record<string, unknown> {
5 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
6 return value as Record<string, unknown>
7}
8export function validateMetadata(bytes: Buffer) {
9 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
10 if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version')
11 // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the
12 // specified UTC calendar representation before the SDK performs its expiry comparison.
13 const expires = signed.expires
14 if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date')
15 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
16 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
17 // identifier aliases by binding each declared ID to the canonical key object.
18 for (const [id, key] of Object.entries(keys ?? {})) {
19 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
20 }
21}
resources/functions/drone/updates/policy.tsadded+18-0
Changes to resources/functions/drone/updates/policy.ts
@@ -0,0 +1,18 @@
1import { safePath } from './files'
2
3export interface RuntimeFirmwarePolicy {
4 target: string
5 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
6 trustedRootSha256: string
7 /** Explicit externally trusted floor. Staging never advances it. */
8 minimumReleaseSequence: number
9}
10export function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
11 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
12 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
13 safePath(policy.target)
14 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
15 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
16 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
17 return Object.freeze(policy)
18}
resources/functions/drone/updates/staging.tsmodified+7-106
Changes to resources/functions/drone/updates/staging.ts
@@ -1,25 +1,19 @@
1import { createHash, randomBytes } from 'node:crypto'
2import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeSync } from 'node:fs'
1import { randomBytes } from 'node:crypto'
2import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
33import { dirname, join, relative, resolve, sep } from 'node:path'
44import { tmpdir } from 'node:os'
55import { Readable } from 'node:stream'
66import { isDeepStrictEqual } from 'node:util'
7import { canonicalize } from '@tufjs/canonical-json'
87import { Updater } from 'tuf-js'
98import type { TargetFile } from 'tuf-js'
9import { directory, imageLimit, metadataLimit, regularFile, repositoryFile, safePath, sha256, syncDirectory, syncTree, writeExclusive } from './files'
10import { object, validateMetadata } from './metadata'
11import { admitPolicy } from './policy'
12import type { RuntimeFirmwarePolicy } from './policy'
13export type { RuntimeFirmwarePolicy } from './policy'
1014
11const metadataLimit = 512 * 1024
12const imageLimit = 16 * 1024 * 1024
1315const originPrefix = '/offline-'
14const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
1516
16export interface RuntimeFirmwarePolicy {
17 target: string
18 platform: 'pico2-neutral-bench-v1' | 'host-neutral-bench-v1'
19 trustedRootSha256: string
20 /** Explicit externally trusted floor. This module does not persist or advance it. */
21 minimumReleaseSequence: number
22}
2317export interface RuntimeFirmwareStage {
2418 schema: 'drone-runtime-stage-v1'
2519 target: string
@@ -36,99 +30,6 @@ export interface RuntimeFirmwareStage {
3630 trustStatePersisted: false
3731}
3832
39function admitPolicy(input: RuntimeFirmwarePolicy): Readonly<RuntimeFirmwarePolicy> {
40 const policy = { target: input.target, platform: input.platform, trustedRootSha256: input.trustedRootSha256, minimumReleaseSequence: input.minimumReleaseSequence }
41 if (process.platform === 'win32') throw new Error('Firmware staging requires a POSIX filesystem')
42 safePath(policy.target)
43 if (policy.platform !== 'pico2-neutral-bench-v1' && policy.platform !== 'host-neutral-bench-v1') throw new TypeError('Select an implemented bench platform')
44 if (typeof policy.trustedRootSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(policy.trustedRootSha256)) throw new TypeError('Provide the independently trusted root SHA-256')
45 if (!Number.isSafeInteger(policy.minimumReleaseSequence) || policy.minimumReleaseSequence < 1) throw new TypeError('Provide a positive trusted minimum release sequence')
46 return Object.freeze(policy)
47}
48function safePath(path: string) {
49 if (typeof path !== 'string' || path.length > 256 || !path.split('/').every(part => /^[a-z0-9][a-z0-9._-]{0,179}$/i.test(part) && part !== '.' && part !== '..')) throw new TypeError('Unsafe offline repository path')
50}
51function regularFile(path: string, limit: number): Buffer {
52 const before = lstatSync(path)
53 if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > limit) throw new Error('Expected a nonempty bounded regular file')
54 const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY)
55 try {
56 const stat = fstatSync(fd)
57 if (!stat.isFile() || stat.size < 1 || stat.size > limit || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('Expected the same bounded regular file')
58 const bytes = Buffer.alloc(stat.size + 1)
59 let offset = 0
60 while (offset < bytes.length) {
61 const count = readSync(fd, bytes, offset, bytes.length - offset, null)
62 if (count === 0) break
63 offset += count
64 }
65 if (offset !== stat.size) throw new Error('Source file changed length while reading')
66 return bytes.subarray(0, offset)
67 }
68 finally { closeSync(fd) }
69}
70function directory(path: string) {
71 const stat = lstatSync(path)
72 if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Expected a real directory')
73 if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) throw new Error('Repository and candidate directories must be owned and not writable by other principals')
74}
75function repositoryFile(root: string, path: string, limit: number) {
76 safePath(path)
77 directory(root)
78 const parts = path.split('/')
79 for (let i = 1; i < parts.length; i++) directory(join(root, ...parts.slice(0, i)))
80 return regularFile(join(root, ...parts), limit)
81}
82function writeExclusive(path: string, bytes: Uint8Array) {
83 const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
84 try {
85 let offset = 0
86 while (offset < bytes.length) {
87 const count = writeSync(fd, bytes, offset, bytes.length - offset)
88 if (count <= 0) throw new Error('No progress writing firmware candidate')
89 offset += count
90 }
91 fsyncSync(fd)
92 }
93 finally { closeSync(fd) }
94}
95function syncDirectory(path: string) {
96 const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
97 try { fsyncSync(fd) }
98 finally { closeSync(fd) }
99}
100function syncTree(path: string) {
101 directory(path)
102 for (const name of readdirSync(path)) {
103 const child = join(path, name), stat = lstatSync(child)
104 if (stat.isDirectory()) syncTree(child)
105 else {
106 if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Unexpected candidate entry')
107 const fd = openSync(child, constants.O_RDONLY | constants.O_NOFOLLOW)
108 try { fsyncSync(fd) }
109 finally { closeSync(fd) }
110 }
111 }
112 syncDirectory(path)
113}
114function object(value: unknown): Record<string, unknown> {
115 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid signed runtime metadata')
116 return value as Record<string, unknown>
117}
118function validateMetadata(bytes: Buffer) {
119 const signed = object(object(JSON.parse(bytes.toString('utf8'))).signed)
120 if (!Number.isSafeInteger(signed.version) || (signed.version as number) < 1) throw new Error('TUF metadata requires a positive safe integer version')
121 // Date accepts invalid calendar dates and produces NaN for malformed input. Validate the
122 // specified UTC calendar representation before the SDK performs its expiry comparison.
123 const expires = signed.expires
124 if (typeof expires !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(expires) || !Number.isFinite(Date.parse(expires)) || new Date(expires).toISOString().replace('.000Z', 'Z') !== expires) throw new Error('TUF metadata requires a valid UTC expiration date')
125 const keys = signed._type === 'root' ? object(signed.keys) : signed._type === 'targets' && signed.delegations !== undefined ? object(object(signed.delegations).keys) : undefined
126 // The SDK verifies signatures by key ID but does not recompute the ID. Reject arbitrary
127 // identifier aliases by binding each declared ID to the canonical key object.
128 for (const [id, key] of Object.entries(keys ?? {})) {
129 if (id !== sha256(Buffer.from(canonicalize(object(key))))) throw new Error('TUF key identifier does not match canonical key data')
130 }
131}
13233function signedRuntime(info: TargetFile, policy: RuntimeFirmwarePolicy) {
13334 const data = object(info.custom.drone)
13435 const keys = ['schema', 'platform', 'protocolVersion', 'releaseSequence', 'sourceRevision', 'profile']
tests/fixtures/updates/enroll-worker.tsadded+4-0
Changes to tests/fixtures/updates/enroll-worker.ts
@@ -0,0 +1,4 @@
1import { enrollRuntimeTrust } from '../../../resources/functions/drone/updates/enrollment'
2
3try { console.log(JSON.stringify(enrollRuntimeTrust(JSON.parse(process.argv[2] ?? '')))) }
4catch { console.error('Trust enrollment rejected'); process.exitCode = 1 }
tests/fixtures/updates/qualify-sdk.tsadded+33-0
Changes to tests/fixtures/updates/qualify-sdk.ts
@@ -0,0 +1,33 @@
1import { constants } from 'node:fs'
2import { createHash } from 'node:crypto'
3import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
4import { join, resolve } from 'node:path'
5
6async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
7async function qualify(directory: string) {
8 await mkdir(directory, { mode: 0o700 })
9 const built = join(directory, 'sdk-built'), outside = join(directory, 'portable')
10 const result = await Bun.build({ entrypoints: [join(import.meta.dir, 'sdk-outcomes-worker.ts')], target: 'bun', minify: true, compile: { outfile: built, autoloadBunfig: false, autoloadDotenv: false, autoloadTsconfig: false, autoloadPackageJson: false } })
11 if (!result.success) throw new AggregateError(result.logs, 'SDK qualifier compilation failed')
12 const sourceSha256 = await hash(built)
13 await mkdir(outside, { mode: 0o700 })
14 const binary = join(outside, 'sdk')
15 await copyFile(built, binary, constants.COPYFILE_EXCL)
16 await chmod(binary, 0o700)
17 if (await hash(binary) !== sourceSha256 || await hash(built) !== sourceSha256) throw new Error('SDK qualifier executable changed')
18 const child = Bun.spawn([binary, join(outside, 'data')], { cwd: outside, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
19 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
20 try {
21 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
22 await writeFile(join(directory, 'qualification.json'), JSON.stringify({ sourceSha256, exit, stdout, stderr }, null, 2))
23 if (exit !== 0) throw new Error('Copied standalone SDK outcome qualification failed')
24 const observed = JSON.parse(stdout)
25 if (observed.evidence !== 'synthetic-sdk-operational-errors-v1' || observed.physicalDeploymentAllowed !== false || observed.outcomes.length !== 4) throw new Error('Invalid SDK outcome qualification')
26 return { ...observed, sourceSha256, copiedStandaloneExecutable: true }
27 }
28 finally { clearTimeout(timer) }
29}
30if (import.meta.main) {
31 if (process.argv.length !== 3) throw new Error('Use qualify-sdk.ts NEW_DIRECTORY')
32 console.log(JSON.stringify(await qualify(resolve(process.argv[2]!))))
33}
tests/fixtures/updates/qualify-trust.tsadded+51-0
Changes to tests/fixtures/updates/qualify-trust.ts
@@ -0,0 +1,51 @@
1import { constants } from 'node:fs'
2import { chmod, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
3import { createReadStream } from 'node:fs'
4import { createHash } from 'node:crypto'
5import { resolve, join } from 'node:path'
6import { repository, platform, target } from './repository'
7
8async function hashFile(path: string) {
9 const hash = createHash('sha256')
10 for await (const chunk of createReadStream(path)) hash.update(chunk)
11 return hash.digest('hex')
12}
13
14/** Real copied standalone CLI, generated throwaway keys, no firmware deployment. */
15export async function qualifyRuntimeTrust(directory: string, source: string) {
16 await mkdir(directory, { mode: 0o700 })
17 const binary = join(directory, 'drone'), sourceSha256 = await hashFile(source)
18 await copyFile(source, binary, constants.COPYFILE_EXCL)
19 await chmod(binary, 0o700)
20 if (await hashFile(binary) !== sourceSha256 || await hashFile(source) !== sourceSha256) throw new Error('Changed portable trust executable')
21 const fixture = await repository(join(directory, 'repo')), output = join(directory, 'trust')
22 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
23 const results: { command: string, exit: number, stdout: string, stderr: string }[] = []
24 async function run(args: string[], expected: number) {
25 const child = Bun.spawn([binary, ...args], { cwd: directory, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
26 const timer = setTimeout(() => child.kill('SIGKILL'), 20000)
27 try {
28 const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
29 results.push({ command: args[0]!, exit, stdout, stderr })
30 if (exit !== expected) throw new Error(`Unexpected portable trust exit: ${exit}`)
31 return stdout
32 }
33 finally { clearTimeout(timer) }
34 }
35 try {
36 const enrolled = JSON.parse(await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags], 0))
37 if (enrolled.policy.trustedRootSha256 !== fixture.rootSha256 || enrolled.policy.minimumReleaseSequence !== 7 || enrolled.metadataState !== 'initial-root-only' || enrolled.metadataRefreshPerformed !== false || enrolled.firmwareSelectionPerformed !== false || enrolled.physicalDeploymentAllowed !== false) throw new Error('Invalid portable trust enrollment')
38 const inspected = JSON.parse(await run(['updates:trust', output, '--enrollment-sha256', enrolled.enrollmentSha256], 0))
39 if (JSON.stringify(inspected) !== JSON.stringify(enrolled)) throw new Error('Portable enrollment mismatch')
40 const marker = await readFile(join(output, 'enrollment.json'))
41 const rejected = await run(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)], 1)
42 if (rejected !== '' || !(await readFile(join(output, 'root.json'))).equals(await readFile(fixture.rootPath))) throw new Error('Rejected trust readback changed the anchor')
43 if (await run(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags], 1) !== '' || !(await readFile(join(output, 'enrollment.json'))).equals(marker)) throw new Error('Existing enrollment was overwritten')
44 }
45 finally { await writeFile(join(directory, 'qualification.json'), JSON.stringify({ evidence: 'generated-initial-trust-only', sourceSha256, copiedExecutableBytes: (await stat(binary)).size, metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalFirmwareInstalled: false, results }, null, 2)) }
46 return { evidence: 'generated-initial-trust-only', operations: results.length, sourceSha256, metadataRefreshPerformed: false, firmwareSelectionPerformed: false, physicalFirmwareInstalled: false }
47}
48if (import.meta.main) {
49 if (process.argv.length !== 4) throw new Error('Use qualify-trust.ts NEW_DIRECTORY COMPILED_BINARY')
50 console.log(JSON.stringify(await qualifyRuntimeTrust(resolve(process.argv[2]!), resolve(process.argv[3]!))))
51}
tests/fixtures/updates/sdk-outcomes-worker.tsadded+72-0
Changes to tests/fixtures/updates/sdk-outcomes-worker.ts
@@ -0,0 +1,72 @@
1import assert from 'node:assert/strict'
2import { readFileSync } from 'node:fs'
3import { mkdir, writeFile, readFile } from 'node:fs/promises'
4import { join } from 'node:path'
5import { DownloadError, DownloadHTTPError, MetadataVerificationError, PersistError, Updater } from 'tuf-js'
6import { repository } from './repository'
7
8/** Synthetic metadata only. Compiled and run outside the checkout to verify public error identities. */
9async function run(directory: string) {
10 await mkdir(directory, { mode: 0o700 })
11 const results: { kind: string, role?: string, statusCode?: number, causePreserved?: boolean }[] = []
12 async function fixture(name: string, options: Parameters<typeof repository>[1] = {}, missing = false) {
13 const repo = join(directory, name, 'repo'), cache = join(directory, name, 'cache')
14 const data = await repository(repo, options)
15 await mkdir(cache, { mode: 0o700 })
16 await writeFile(join(cache, 'root.json'), await readFile(data.rootPath), { mode: 0o600 })
17 const fetcher = {
18 async downloadBytes(url: string, limit: number) {
19 const path = new URL(url).pathname
20 assert(/^\/metadata\/(?:[0-9]+\.)?(?:root|timestamp|snapshot|targets)\.json$/.test(path))
21 if (missing && path === '/metadata/timestamp.json') throw new DownloadHTTPError('Synthetic missing timestamp', 404)
22 try {
23 const bytes = readFileSync(join(repo, path))
24 assert(bytes.length <= limit)
25 return bytes
26 }
27 catch (error) {
28 if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new DownloadHTTPError('Synthetic missing metadata', 404)
29 throw error
30 }
31 },
32 async downloadFile(): Promise<never> { throw new Error('This qualifier never downloads image bytes') },
33 }
34 return { metadataDir: cache, metadataBaseUrl: 'https://offline.invalid/metadata/', fetcher }
35 }
36 const metadata = await fixture('metadata', { badTargetsSignature: true })
37 const prefix: string[] = []
38 const rejected = await new Updater({ ...metadata, onVerifiedMetadata(role) { prefix.push(role) } }).refresh().then(() => undefined, error => error)
39 assert(rejected instanceof Error && rejected.cause instanceof MetadataVerificationError)
40 assert.equal(rejected.cause.role, 'targets')
41 assert(rejected.cause.cause instanceof Error)
42 assert.deepEqual(prefix, ['timestamp', 'snapshot'])
43 results.push({ kind: 'metadata-verification-phase', role: rejected.cause.role, causePreserved: true })
44
45 const persistence = await fixture('persistence')
46 const marker = new MetadataVerificationError('timestamp', 'Synthetic observer failure, not an SDK verdict')
47 const observed: string[] = []
48 const failed = await new Updater({ ...persistence, forceCache: true, onVerifiedMetadata(role) { observed.push(role); throw marker } }).refresh().then(() => undefined, error => error)
49 assert(failed instanceof PersistError && failed.cause === marker)
50 assert(!(failed instanceof MetadataVerificationError))
51 assert.deepEqual(observed, ['timestamp'])
52 results.push({ kind: 'persistence-first', role: 'timestamp', causePreserved: true })
53
54 const opaque = await fixture('opaque')
55 const opaqueMarker = Symbol('Synthetic opaque observer rejection')
56 const opaqueSeen: string[] = []
57 const opaqueFailure = await new Updater({ ...opaque, forceCache: true, onVerifiedMetadata(role) { opaqueSeen.push(role); throw opaqueMarker } }).refresh().then(() => undefined, error => error)
58 assert(opaqueFailure instanceof PersistError && opaqueFailure.cause === opaqueMarker)
59 assert.deepEqual(opaqueSeen, ['timestamp'])
60 results.push({ kind: 'opaque-persistence', role: 'timestamp', causePreserved: true })
61
62 const absent = await fixture('missing', {}, true)
63 const missing = await new Updater(absent).refresh().then(() => undefined, error => error)
64 assert(missing instanceof DownloadHTTPError && missing instanceof DownloadError)
65 assert.equal(missing.statusCode, 404)
66 results.push({ kind: 'download-phase', statusCode: missing.statusCode })
67 return { evidence: 'synthetic-sdk-operational-errors-v1', outcomes: results, physicalDeploymentAllowed: false }
68}
69if (import.meta.main) {
70 if (process.argv.length !== 3) throw new Error('Use sdk-outcomes-worker NEW_DIRECTORY')
71 console.log(JSON.stringify(await run(process.argv[2]!)))
72}
tests/helpers/execution-browser-driver.tsmodified+9-11
Changes to tests/helpers/execution-browser-driver.ts
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
22import { mkdir, readFile } from 'node:fs/promises'
33import { basename, join } from 'node:path'
44import { startWorkspaceBrowser } from './workspace-browser'
5import { settleExecutionDiagnostics } from './execution-diagnostics'
56
67// This driver owns only browser transport. Native credentials stay in a private fixture file.
78const root = process.cwd()
@@ -23,8 +24,6 @@ let diagnosticPage: Awaited<ReturnType<typeof browser.page>> | undefined
2324let startBaseline: number | null = null
2425async function failureDiagnostics() {
2526 const abort = new AbortController()
26 let timer: ReturnType<typeof setTimeout> | undefined
27 const timeout = new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), 1000) })
2827 try {
2928 const snapshot = async () => {
3029 const response = await fetch(`${data.url}/__fixture/diagnostics`, { headers: { 'x-fixture-control': data.control }, signal: abort.signal })
@@ -46,19 +45,18 @@ async function failureDiagnostics() {
4645 hasSourceExists: !!document.querySelector('[data-execution-source-exists]'), hasExecutionError: !!document.querySelector('[data-execution-error]')
4746 };
4847 })()`) : Promise.resolve(null)
49 const collect = Promise.allSettled([snapshot(), dom]).then(([server, page]) => ({
48 const parts = await settleExecutionDiagnostics(snapshot(), dom)
49 const base = {
5050 label: 'execution-browser-failure-diagnostics', phase, startBaseline,
51 server: server.status === 'fulfilled' ? server.value : { available: false },
52 dom: page.status === 'fulfilled' ? page.value : null, browserErrorCount: diagnosticPage?.errors.length ?? null,
51 browserErrorCount: diagnosticPage?.errors.length ?? null,
52 executionStartRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution')).length : null,
5353 admissionRequests: diagnosticPage ? diagnosticPage.requests.filter(request => request.method === 'POST' && request.url.endsWith('/workspace/execution/admission')).length : null,
54 }))
55 const captured = await Promise.race([collect, timeout])
56 const text = `${JSON.stringify(captured ?? { label: 'execution-browser-failure-diagnostics', available: false })}\n`
57 console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text.trimEnd() : '{"label":"execution-browser-failure-diagnostics","available":false}')
54 }
55 const text = JSON.stringify({ ...base, ...parts })
56 console.error(Buffer.byteLength(text, 'utf8') <= 8192 ? text : JSON.stringify({ ...base, server: { available: false }, dom: null, byteBudgetExceeded: true }))
5857 }
59 catch { console.error('{"label":"execution-browser-failure-diagnostics","available":false}') }
58 catch { console.error(JSON.stringify({ label: 'execution-browser-failure-diagnostics', phase, available: false })) }
6059 finally {
61 clearTimeout(timer)
6260 abort.abort()
6361 // The owned socket close rejects pending CDP commands and clears their timers in the existing helper.
6462 diagnosticPage?.close()
tests/helpers/execution-diagnostics.tsadded+24-0
Changes to tests/helpers/execution-diagnostics.ts
@@ -0,0 +1,24 @@
1type DiagnosticStatus = 'pending' | 'fulfilled' | 'rejected'
2export interface ExecutionDiagnosticParts {
3 server: unknown
4 dom: unknown
5 serverStatus: DiagnosticStatus
6 domStatus: DiagnosticStatus
7}
8
9/** Preserve each completed observation within the existing one-second diagnostic budget. */
10export async function settleExecutionDiagnostics(server: Promise<unknown>, dom: Promise<unknown>): Promise<ExecutionDiagnosticParts> {
11 const parts: ExecutionDiagnosticParts = { server: { available: false }, dom: null, serverStatus: 'pending', domStatus: 'pending' }
12 let timer: ReturnType<typeof setTimeout> | undefined
13 const timeout = new Promise<void>(resolve => { timer = setTimeout(resolve, 1000) })
14 const tasks = [
15 server.then(value => { parts.server = value; parts.serverStatus = 'fulfilled' }, () => { parts.serverStatus = 'rejected' }),
16 dom.then(value => { parts.dom = value; parts.domStatus = 'fulfilled' }, () => { parts.domStatus = 'rejected' }),
17 ]
18 try {
19 await Promise.race([Promise.all(tasks), timeout])
20 // Late responses may update the private accumulator, never this returned observation.
21 return { ...parts }
22 }
23 finally { clearTimeout(timer) }
24}
tests/unit/public-safety-execution-diagnostics.test.tsadded+40-0
Changes to tests/unit/public-safety-execution-diagnostics.test.ts
@@ -0,0 +1,40 @@
1import { expect, test } from 'bun:test'
2import { settleExecutionDiagnostics } from '../helpers/execution-diagnostics'
3
4test('a pending DOM diagnostic cannot erase an already received server observation', async () => {
5 const result = await settleExecutionDiagnostics(Promise.resolve({ available: true, state: 'running' }), new Promise(() => {}))
6 expect(result?.server).toEqual({ available: true, state: 'running' })
7 expect(result?.dom).toBeNull()
8})
9
10
11test('a pending server diagnostic cannot erase an already received DOM observation', async () => {
12 const result = await settleExecutionDiagnostics(new Promise(() => {}), Promise.resolve({ executionPhase: 'completed' }))
13 expect(result.dom).toEqual({ executionPhase: 'completed' })
14 expect(result.serverStatus).toBe('pending')
15 expect(result.domStatus).toBe('fulfilled')
16})
17
18test('a rejected arm stays unavailable without discarding the other settled observation', async () => {
19 const result = await settleExecutionDiagnostics(Promise.reject(new Error('not logged')), Promise.resolve({ executionPhase: 'running' }))
20 expect(result.server).toEqual({ available: false })
21 expect(result.dom).toEqual({ executionPhase: 'running' })
22 expect(result.serverStatus).toBe('rejected')
23 expect(result.domStatus).toBe('fulfilled')
24})
25
26test('two stalled arms return explicit unavailability without inventing an observation', async () => {
27 const result = await settleExecutionDiagnostics(new Promise(() => {}), new Promise(() => {}))
28 expect(result).toEqual({ server: { available: false }, dom: null, serverStatus: 'pending', domStatus: 'pending' })
29})
30
31test('a response after the deadline cannot mutate the returned failure observation', async () => {
32 let resolveDom!: (value: unknown) => void
33 const dom = new Promise(resolve => { resolveDom = resolve })
34 const result = await settleExecutionDiagnostics(Promise.resolve({ available: true }), dom)
35 resolveDom({ executionPhase: 'completed' })
36 await dom
37 expect(result.dom).toBeNull()
38 expect(result.domStatus).toBe('pending')
39 expect(result.serverStatus).toBe('fulfilled')
40})
tests/unit/updates-enrollment-cli.test.tsadded+33-0
Changes to tests/unit/updates-enrollment-cli.test.ts
@@ -0,0 +1,33 @@
1import { afterEach, expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { platform, repository, target } from '../fixtures/updates/repository'
7
8const roots: string[] = []
9afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
10
11test('native trust commands enroll explicit policy and require the independent enrollment pin for readback', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-trust-cli-')); roots.push(root)
13 const fixture = await repository(join(root, 'repository')), output = join(root, 'trust')
14 const flags = ['--target', target, '--platform', platform, '--root-sha256', fixture.rootSha256, '--minimum-release-sequence', '7']
15 const enrolled = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', output, ...flags]).withTimeout(20000)
16 expect(enrolled.exitCode).toBe(0)
17 expect(enrolled.timedOut).toBe(false)
18 const value = JSON.parse(enrolled.stdout)
19 expect(value.policy.minimumReleaseSequence).toBe(7)
20 expect(value.metadataState).toBe('initial-root-only')
21 expect(value.firmwareSelectionPerformed).toBe(false)
22 const inspected = await command(['updates:trust', output, '--enrollment-sha256', value.enrollmentSha256]).withTimeout(20000)
23 expect(inspected.exitCode).toBe(0)
24 expect(JSON.parse(inspected.stdout)).toEqual(value)
25 const rejected = await command(['updates:trust', output, '--enrollment-sha256', '0'.repeat(64)]).withTimeout(20000)
26 expect(rejected.exitCode).toBe(1)
27 expect(rejected.stdout).toBe('')
28 expect(rejected.stderr).toBe('Runtime trust unavailable: invalid enrollment pin or protected state\n')
29 const missing = await command(['updates:enroll', '--trusted-root', fixture.rootPath, '--output', join(root, 'missing-policy')]).withTimeout(20000)
30 expect(missing.exitCode).toBe(1)
31 expect(missing.stdout).toBe('')
32 expect(await Bun.file(join(root, 'missing-policy/enrollment.json')).exists()).toBe(false)
33}, 100000)
tests/unit/updates-enrollment.test.tsadded+153-0
Changes to tests/unit/updates-enrollment.test.ts
@@ -0,0 +1,153 @@
1import { afterEach, expect, test } from 'bun:test'
2import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { spawnSync } from 'node:child_process'
5import { join } from 'node:path'
6import { digest, platform, repository, target } from '../fixtures/updates/repository'
7import type { RuntimeFirmwarePolicy } from '../../resources/functions/drone/updates/staging'
8import { enrollRuntimeTrust, inspectRuntimeTrust } from '../../resources/functions/drone/updates/enrollment'
9
10const roots: string[] = []
11afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
12
13test('independently pinned root and release policy can be enrolled and verified after readback', async () => {
14 const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root)
15 const fixture = await repository(join(root, 'repository')), output = join(root, 'trust')
16 const policy = { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7 } satisfies RuntimeFirmwarePolicy
17 const result = enrollRuntimeTrust({ ...policy, trustedRoot: fixture.rootPath, output })
18 expect(result.policy).toEqual(policy)
19 expect(result.rootVersion).toBe(1)
20 expect(result.metadataState).toBe('initial-root-only')
21 expect(result.metadataRefreshPerformed).toBe(false)
22 expect(result.firmwareSelectionPerformed).toBe(false)
23 expect(result.physicalDeploymentAllowed).toBe(false)
24 expect(result.enrollmentSha256).toBe(digest(await readFile(join(output, 'enrollment.json'))))
25 expect(await readFile(join(output, 'root.json'))).toEqual(await readFile(fixture.rootPath))
26 expect(inspectRuntimeTrust({ directory: output, enrollmentSha256: result.enrollmentSha256 })).toEqual(result)
27 expect((await readdir(output)).sort()).toEqual(['enrollment.json', 'root.json'])
28 expect((await stat(output)).mode & 0o777).toBe(0o700)
29 expect((await stat(join(output, 'enrollment.json'))).mode & 0o777).toBe(0o600)
30 expect((await stat(join(output, 'root.json'))).mode & 0o777).toBe(0o600)
31})
32
33
34test('trust readback requires an owned parent protected from other principals', async () => {
35 const root = await mkdtemp(join(tmpdir(), 'drone-trust-parent-')); roots.push(root)
36 const fixture = await repository(join(root, 'repository')), directory = join(root, 'trust')
37 const enrolled = enrollRuntimeTrust({ target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output: directory })
38 await chmod(root, 0o777)
39 try { expect(() => inspectRuntimeTrust({ directory, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow() }
40 finally { await chmod(root, 0o700) }
41})
42
43
44async function setup(options: Parameters<typeof repository>[1] = {}) {
45 const root = await mkdtemp(join(tmpdir(), 'drone-trust-enrollment-')); roots.push(root)
46 const fixture = await repository(join(root, 'repository'), options), output = join(root, 'trust')
47 return { root, fixture, output, input: { target, platform, trustedRootSha256: fixture.rootSha256, minimumReleaseSequence: 7, trustedRoot: fixture.rootPath, output } satisfies Parameters<typeof enrollRuntimeTrust>[0] }
48}
49
50test('bad pins, unsafe policy and malformed roots cannot complete enrollment', async () => {
51 const item = await setup()
52 for (const [i, change] of [{ trustedRootSha256: '0'.repeat(64) }, { trustedRootSha256: '' }, { minimumReleaseSequence: 0 }, { minimumReleaseSequence: Number.NaN }, { minimumReleaseSequence: 7.5 }, { target: '../image.bin' }].entries()) {
53 const output = join(item.root, `invalid-${i}`)
54 expect(() => enrollRuntimeTrust({ ...item.input, ...change, output })).toThrow()
55 expect(await Bun.file(join(output, 'enrollment.json')).exists()).toBe(false)
56 }
57 for (const options of [{ nonCanonicalRoot: true }, { metadataFields: { root: { expires: 'not-a-date' } } }, { metadataFields: { root: { version: 0 } } }]) {
58 const bad = await setup(options)
59 expect(() => enrollRuntimeTrust(bad.input)).toThrow()
60 expect(await Bun.file(join(bad.output, 'enrollment.json')).exists()).toBe(false)
61 }
62})
63
64test('a pinned root still requires its authorized self-signatures', async () => {
65 const item = await setup(), root = JSON.parse(await readFile(item.fixture.rootPath, 'utf8'))
66 root.signatures[0].sig = '0'.repeat(128)
67 const bytes = Buffer.from(JSON.stringify(root))
68 await writeFile(item.fixture.rootPath, bytes)
69 expect(() => enrollRuntimeTrust({ ...item.input, trustedRootSha256: digest(bytes) })).toThrow()
70 expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false)
71})
72
73test('readback rejects a wrong independent pin, modified root, altered record and invalid schema', async () => {
74 const item = await setup(), enrolled = enrollRuntimeTrust(item.input)
75 const manifestPath = join(item.output, 'enrollment.json'), original = await readFile(manifestPath), record = JSON.parse(original.toString())
76 for (const enrollmentSha256 of ['', '0'.repeat(64)]) expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256 })).toThrow()
77 const changed = { ...record, policy: { ...record.policy, minimumReleaseSequence: 1 } }
78 await writeFile(manifestPath, JSON.stringify(changed))
79 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
80 const invalid = [{ ...record, schema: 'unknown' }, { ...record, extra: true }, { ...record, enrollmentId: '../bad' }, { ...record, rootVersion: 2 }, { ...record, policy: { ...record.policy, minimumReleaseSequence: 0 } }, { ...record, policy: { ...record.policy, extra: true } }]
81 for (const value of invalid) {
82 const bytes = Buffer.from(JSON.stringify(value)); await writeFile(manifestPath, bytes)
83 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: digest(bytes) })).toThrow()
84 }
85 await writeFile(manifestPath, original)
86 await writeFile(join(item.output, 'root.json'), '{}')
87 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
88})
89
90test('source symlinks and shared writable storage are rejected', async () => {
91 const linked = await setup(), link = join(linked.root, 'root-link.json')
92 await symlink(linked.fixture.rootPath, link)
93 expect(() => enrollRuntimeTrust({ ...linked.input, trustedRoot: link })).toThrow()
94 const shared = await setup()
95 await chmod(shared.root, 0o777)
96 expect(() => enrollRuntimeTrust(shared.input)).toThrow()
97 await chmod(shared.root, 0o700)
98 const result = enrollRuntimeTrust(shared.input)
99 await chmod(shared.output, 0o777)
100 expect(() => inspectRuntimeTrust({ directory: shared.output, enrollmentSha256: result.enrollmentSha256 })).toThrow()
101 await chmod(shared.output, 0o700)
102})
103
104test('an existing enrollment or partial directory is never overwritten or implicitly repaired', async () => {
105 const item = await setup(), enrolled = enrollRuntimeTrust(item.input)
106 const marker = await readFile(join(item.output, 'enrollment.json'))
107 expect(() => enrollRuntimeTrust(item.input)).toThrow()
108 expect(await readFile(join(item.output, 'enrollment.json'))).toEqual(marker)
109 expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled)
110 const partial = await setup(); await mkdir(partial.output)
111 await writeFile(join(partial.output, 'root.json'), await readFile(partial.fixture.rootPath))
112 expect(() => enrollRuntimeTrust(partial.input)).toThrow()
113 expect(() => inspectRuntimeTrust({ directory: partial.output, enrollmentSha256: enrolled.enrollmentSha256 })).toThrow()
114})
115
116test('owned policy survives caller mutation and a valid expired root is only an initial anchor', async () => {
117 const item = await setup({ metadataFields: { root: { expires: '2020-01-01T00:00:00Z' } } })
118 const enrolled = enrollRuntimeTrust(item.input)
119 item.input.minimumReleaseSequence = 1
120 item.input.trustedRootSha256 = '0'.repeat(64)
121 const checked = inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })
122 expect(checked.policy.minimumReleaseSequence).toBe(7)
123 expect(checked.policy.trustedRootSha256).toBe(item.fixture.rootSha256)
124 expect(checked.metadataState).toBe('initial-root-only')
125 expect(checked.metadataRefreshPerformed).toBe(false)
126 expect(checked.firmwareSelectionPerformed).toBe(false)
127})
128
129test('two real processes cannot enroll into the same output', async () => {
130 const item = await setup(), worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts')
131 const processes = [Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 }), Bun.spawn([process.execPath, worker, JSON.stringify(item.input)], { stdout: 'pipe', stderr: 'pipe', timeout: 20000 })]
132 const results = await Promise.all(processes.map(async child => ({ exit: await child.exited, stdout: await new Response(child.stdout).text(), stderr: await new Response(child.stderr).text() })))
133 expect(results.map(r => r.exit).sort()).toEqual([0, 1])
134 const success = results.find(r => r.exit === 0)!
135 const enrolled = JSON.parse(success.stdout)
136 expect(inspectRuntimeTrust({ directory: item.output, enrollmentSha256: enrolled.enrollmentSha256 })).toEqual(enrolled)
137 expect(results.find(r => r.exit === 1)!.stdout).toBe('')
138}, 60000)
139
140test('an actual child write-limit failure leaves incomplete evidence and preserves the trusted source', async () => {
141 const item = await setup(), source = await readFile(item.fixture.rootPath)
142 const worker = join(import.meta.dir, '../fixtures/updates/enroll-worker.ts')
143 const code = 'import os, resource, signal, sys; signal.signal(signal.SIGXFSZ, signal.SIG_IGN); resource.setrlimit(resource.RLIMIT_FSIZE, (256,256)); os.execv(sys.argv[1],sys.argv[1:])'
144 const failed = spawnSync('python3', ['-c', code, process.execPath, worker, JSON.stringify(item.input)], { timeout: 20000, encoding: 'utf8' })
145 expect(failed.error).toBeUndefined()
146 expect(failed.status).toBe(1)
147 expect(failed.stderr).toContain('Trust enrollment rejected')
148 expect(await Bun.file(join(item.output, 'enrollment.json')).exists()).toBe(false)
149 expect((await stat(join(item.output, 'root.json'))).size).toBe(256)
150 expect(await readFile(item.fixture.rootPath)).toEqual(source)
151 expect(() => inspectRuntimeTrust({ directory: item.output, enrollmentSha256: '0'.repeat(64) })).toThrow()
152 expect(() => enrollRuntimeTrust(item.input)).toThrow()
153}, 60000)
tests/unit/updates-sdk.test.tsadded+261-0
Changes to tests/unit/updates-sdk.test.ts
@@ -0,0 +1,261 @@
1import { afterEach, expect, test } from 'bun:test'
2import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { basename, join } from 'node:path'
5import { Updater, type UpdaterOptions } from 'tuf-js'
6import * as Tuf from 'tuf-js'
7import { repository } from '../fixtures/updates/repository'
8
9const roots: string[] = []
10const servers: ReturnType<typeof Bun.serve>[] = []
11afterEach(async () => {
12 for (const server of servers.splice(0)) await server.stop(true)
13 for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
14})
15
16async function setup(options: Parameters<typeof repository>[1] = { rotate: true }, missing?: string) {
17 const root = await mkdtemp(join(tmpdir(), 'drone-sdk-test-')); roots.push(root)
18 const repo = join(root, 'repo'), cache = join(root, 'cache'), requests: string[] = []
19 const fixture = await repository(repo, options)
20 await mkdir(cache, { mode: 0o700 })
21 await copyFile(fixture.rootPath, join(cache, 'root.json'))
22 const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
23 const path = new URL(request.url).pathname
24 requests.push(path)
25 if (!/^\/metadata\/(?:[0-9]+\.)?(?:root|timestamp|snapshot|targets)\.json$/.test(path) || basename(path) === missing) return new Response(null, { status: 404 })
26 const file = Bun.file(join(repo, 'metadata', basename(path)))
27 return await file.exists() ? new Response(file) : new Response(null, { status: 404 })
28 } }); servers.push(server)
29 const input = { metadataDir: cache, metadataBaseUrl: `http://127.0.0.1:${server.port}/metadata`, config: { fetchRetries: 0, fetchTimeout: 1000 } } satisfies UpdaterOptions
30 return { root, repo, cache, fixture, requests, input }
31}
32
33const roles = ['root', 'timestamp', 'snapshot', 'targets'] as const
34
35test('the maintained SDK observer receives exact verified bytes before each cache write', async () => {
36 const item = await setup(), seen: string[] = []
37 const updater = new Updater({ ...item.input, async onVerifiedMetadata(role, bytes) {
38 seen.push(role)
39 if (role === 'root') expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1)
40 else expect(await Bun.file(join(item.cache, `${role}.json`)).exists()).toBe(false)
41 const file = role === 'root' ? '2.root.json' : role === 'timestamp' ? 'timestamp.json' : `1.${role}.json`
42 expect(bytes).toEqual(await readFile(join(item.repo, 'metadata', file)))
43 } })
44 await updater.refresh()
45 expect(seen).toEqual([...roles])
46 expect((await readdir(item.cache)).sort()).toEqual(['root.json', 'snapshot.json', 'targets.json', 'timestamp.json'])
47})
48
49test('a pending observer prevents the cache write and the next metadata request', async () => {
50 const item = await setup()
51 let release!: () => void, entered!: () => void
52 const gate = new Promise<void>(resolve => { release = resolve })
53 const seen = new Promise<void>(resolve => { entered = resolve })
54 const pending = new Updater({ ...item.input, async onVerifiedMetadata(role) {
55 if (role === 'root') { entered(); await gate }
56 } }).refresh()
57 try {
58 await seen
59 expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1)
60 expect(item.requests).toEqual(['/metadata/2.root.json'])
61 }
62 finally { release(); await pending }
63})
64
65for (const failedRole of roles) {
66 test(`an observer rejection for ${failedRole} aborts before that cache or a later role advances`, async () => {
67 const item = await setup(), seen: string[] = [], cause = new Error('protected persistence rejected')
68 const updater = new Updater({ ...item.input, async onVerifiedMetadata(role) {
69 seen.push(role)
70 if (role === failedRole) throw cause
71 } })
72 const failure = await updater.refresh().then(() => undefined, error => error)
73 const persistence = failedRole === 'snapshot' || failedRole === 'targets' ? failure.cause : failure
74 expect(persistence).toBeInstanceOf(Tuf.PersistError)
75 expect(persistence.message).toBe(`Failed to persist metadata ${failedRole}`)
76 expect(persistence.cause).toBe(cause)
77 expect(seen).toEqual(roles.slice(0, roles.indexOf(failedRole) + 1))
78 if (failedRole === 'root') expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1)
79 else expect(await Bun.file(join(item.cache, `${failedRole}.json`)).exists()).toBe(false)
80 })
81}
82
83test('a genuine SDK HTTP404 thrown by the observer cannot be mistaken for the end of root rotation', async () => {
84 const source = await setup({ rotate: false }, 'timestamp.json')
85 const downloadFailure = await new Updater(source.input).refresh().then(() => { throw new Error('Missing metadata unexpectedly succeeded') }, error => error)
86 expect(downloadFailure.statusCode).toBe(404)
87 const item = await setup(), seen: string[] = []
88 await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role); throw downloadFailure } }).refresh()).rejects.toThrow('Failed to persist metadata root')
89 expect(seen).toEqual(['root'])
90 expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1)
91})
92
93test('the observer owns a byte copy and cannot mutate SDK verification or cache bytes', async () => {
94 const item = await setup(), observed: Record<string, Buffer> = {}
95 await new Updater({ ...item.input, onVerifiedMetadata(role, bytes) { observed[role] = Buffer.from(bytes); bytes.fill(0) } }).refresh()
96 for (const role of roles) expect((await readFile(join(item.cache, `${role}.json`))).equals(observed[role]!)).toBe(true)
97})
98
99test('an unauthorized root never reaches the observer and leaves the initial cache intact', async () => {
100 const item = await setup({ rotate: true, badRotation: true }), seen: string[] = []
101 await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()).rejects.toThrow()
102 expect(seen).toEqual([])
103 expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1)
104})
105
106test('a later invalid target signature preserves only the earlier verified metadata observations', async () => {
107 const item = await setup({ rotate: true, badTargetsSignature: true }), seen: string[] = []
108 await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()).rejects.toThrow()
109 expect(seen).toEqual(['root', 'timestamp', 'snapshot'])
110 expect(await Bun.file(join(item.cache, 'targets.json')).exists()).toBe(false)
111})
112
113test('snapshot-bound tampering is rejected before it reaches the observer', async () => {
114 const item = await setup(), seen: string[] = []
115 await writeFile(join(item.repo, 'metadata/1.targets.json'), '{}')
116 await expect(new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()).rejects.toThrow()
117 expect(seen).toEqual(['root', 'timestamp', 'snapshot'])
118})
119
120test('the supplied fixed start time is owned before callers can mutate it', async () => {
121 const item = await setup(), time = new Date('2030-01-01T00:00:00Z')
122 const updater = new Updater({ ...item.input, referenceTime: time })
123 time.setUTCFullYear(2040)
124 await updater.refresh()
125 expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(2)
126})
127
128test('fixed-time recovery replay is distinct from current-time authorization', async () => {
129 const past = await setup({ expires: '2025-01-01T00:00:00Z' })
130 await new Updater({ ...past.input, referenceTime: new Date('2024-01-01T00:00:00Z') }).refresh()
131 const current = await setup({ expires: '2025-01-01T00:00:00Z' })
132 const failure = await new Updater(current.input).refresh().then(() => undefined, error => error)
133 expect(failure).toBeInstanceOf(Tuf.MetadataVerificationError)
134 expect(failure.role).toBe('timestamp')
135 expect(failure.cause).toBeInstanceOf(Error)
136 expect(failure.cause.message).toContain('expired')
137})
138
139test('invalid fixed time is rejected before loading even a missing bootstrap root', () => {
140 expect(() => new Updater({ metadataDir: '/missing-drone-sdk-bootstrap', metadataBaseUrl: 'http://127.0.0.1:1', referenceTime: new Date(Number.NaN) })).toThrow('Invalid fixed update reference time')
141})
142
143
144test('a fully cached trusted refresh does not emit observations for cache bytes', async () => {
145 const item = await setup()
146 await new Updater(item.input).refresh()
147 const seen: string[] = []
148 await new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh()
149 expect(seen).toEqual([])
150})
151
152test('an authenticated intermediate root is observed before final-root expiry rejects the refresh', async () => {
153 const item = await setup({ rotate: true, metadataFields: { rotatedRoot: { expires: '2020-01-01T00:00:00Z' } } }), seen: string[] = []
154 const failure = await new Updater({ ...item.input, onVerifiedMetadata(role) { seen.push(role) } }).refresh().then(() => undefined, error => error)
155 expect(failure).toBeInstanceOf(Tuf.MetadataVerificationError)
156 expect(failure.role).toBe('timestamp')
157 expect(failure.cause).toBeInstanceOf(Error)
158 expect(failure.cause.message).toContain('expired')
159 expect(seen).toEqual(['root'])
160 expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(2)
161})
162
163
164test('forceCache cannot swallow a rejected remote timestamp observer when its local cache is missing', async () => {
165 const item = await setup({ rotate: false }), seen: string[] = []
166 await expect(new Updater({ ...item.input, forceCache: true, onVerifiedMetadata(role) {
167 seen.push(role)
168 if (role === 'timestamp') throw new Error('protected timestamp persistence rejected')
169 } }).refresh()).rejects.toThrow('Failed to persist metadata timestamp')
170 expect(seen).toEqual(['timestamp'])
171 expect(item.requests).toEqual(['/metadata/timestamp.json'])
172 expect((await readdir(item.cache)).sort()).toEqual(['root.json'])
173})
174
175
176test('a signed metadata rejection retains its original typed cause through the SDK wrapper', async () => {
177 const item = await setup({ badTargetsSignature: true })
178 const failure = await new Updater(item.input).refresh().then(() => undefined, error => error)
179 expect(failure).toBeInstanceOf(Error)
180 expect(failure.cause).toBeInstanceOf(Error)
181})
182
183
184test('recovery can identify a remote metadata rejection through a public typed cause', async () => {
185 const item = await setup({ badTargetsSignature: true })
186 const failure = await new Updater(item.input).refresh().then(() => undefined, error => error)
187 expect(Tuf.MetadataVerificationError).toBeFunction()
188 expect(failure.cause).toBeInstanceOf(Tuf.MetadataVerificationError)
189 expect(failure.cause.role).toBe('targets')
190})
191
192
193for (const role of roles) {
194 test(`remote ${role} verification failure has a typed role and original cause`, async () => {
195 const options: Parameters<typeof repository>[1] = role === 'root' ? { rotate: true, badRotation: true } : { metadataFields: { [role]: { expires: '2020-01-01T00:00:00Z' } } }
196 const item = await setup(options)
197 const failure = await new Updater(item.input).refresh().then(() => undefined, error => error)
198 const verification = failure instanceof Tuf.MetadataVerificationError ? failure : failure.cause
199 expect(verification).toBeInstanceOf(Tuf.MetadataVerificationError)
200 expect(verification.role).toBe(role)
201 expect(verification.cause).toBeInstanceOf(Error)
202 })
203}
204
205test('observer failure stays a persistence failure and preserves its exact cause', async () => {
206 const item = await setup(), cause = new Error('protected storage failure')
207 const failure = await new Updater({ ...item.input, onVerifiedMetadata() { throw cause } }).refresh().then(() => undefined, error => error)
208 expect(failure).toBeInstanceOf(Tuf.PersistError)
209 expect(failure).not.toBeInstanceOf(Tuf.MetadataVerificationError)
210 expect(failure.cause).toBe(cause)
211 expect(item.requests).toEqual(['/metadata/2.root.json'])
212})
213
214test('an observer-supplied verification error is still classified as failed persistence first', async () => {
215 const item = await setup({ rotate: false }), cause = new Tuf.MetadataVerificationError('timestamp', 'caller error, not an SDK verification verdict')
216 const failure = await new Updater({ ...item.input, forceCache: true, onVerifiedMetadata() { throw cause } }).refresh().then(() => undefined, error => error)
217 expect(failure).toBeInstanceOf(Tuf.PersistError)
218 expect(failure.cause).toBe(cause)
219 expect(item.requests).toEqual(['/metadata/timestamp.json'])
220})
221
222test('the public HTTP failure class matches actual missing-metadata failures', async () => {
223 const item = await setup({ rotate: false }, 'timestamp.json')
224 const failure = await new Updater(item.input).refresh().then(() => undefined, error => error)
225 expect(failure).toBeInstanceOf(Tuf.DownloadHTTPError)
226 expect(failure).toBeInstanceOf(Tuf.DownloadError)
227 expect(failure.statusCode).toBe(404)
228 expect(failure).not.toBeInstanceOf(Tuf.MetadataVerificationError)
229})
230
231test('a Symbol observer rejection remains a persistence failure under forceCache', async () => {
232 const item = await setup({ rotate: false }), seen: string[] = [], cause = Symbol('opaque storage failure')
233 const failure = await new Updater({ ...item.input, forceCache: true, onVerifiedMetadata(role) {
234 seen.push(role)
235 if (role === 'timestamp') throw cause
236 } }).refresh().then(() => undefined, error => error)
237 expect(failure).toBeInstanceOf(Tuf.PersistError)
238 expect(failure.cause).toBe(cause)
239 expect(seen).toEqual(['timestamp'])
240 expect(item.requests).toEqual(['/metadata/timestamp.json'])
241 expect((await readdir(item.cache)).sort()).toEqual(['root.json'])
242})
243
244test('observer failures are never coerced while wrapping any metadata role', async () => {
245 for (const failedRole of roles) {
246 const item = await setup(), seen: string[] = []
247 let coercions = 0
248 const cause = { toString() { coercions++; throw new Error('Cause coercion must not run') } }
249 const failure = await new Updater({ ...item.input, onVerifiedMetadata(role) {
250 seen.push(role)
251 if (role === failedRole) throw cause
252 } }).refresh().then(() => undefined, error => error)
253 const persistence = failedRole === 'snapshot' || failedRole === 'targets' ? failure.cause : failure
254 expect(persistence).toBeInstanceOf(Tuf.PersistError)
255 expect(persistence.cause).toBe(cause)
256 expect(coercions).toBe(0)
257 expect(seen).toEqual(roles.slice(0, roles.indexOf(failedRole) + 1))
258 if (failedRole === 'root') expect(JSON.parse(await readFile(join(item.cache, 'root.json'), 'utf8')).signed.version).toBe(1)
259 else expect(await Bun.file(join(item.cache, `${failedRole}.json`)).exists()).toBe(false)
260 }
261})