ReviewOS

also looking at this

stacks/tuf-js

feat: observe verified metadata before persistence

#1
Merged chris wants to merge codex/verified-metadata-source into main
8 files +807 -21
packages/client/README.mdmodified+28-0
Changes to packages/client/README.md
@@ -1,3 +1,31 @@
11# tuf-js
22
33JavaScript TUF client implementation.
4
5## Verified metadata observation
6
7This source branch adds optional `UpdaterOptions.onVerifiedMetadata(role, bytes)`.
8The updater awaits the observer after metadata verification and before its cache
9write or the next metadata request. Each call receives an owned byte copy. A
10rejection becomes `PersistError` with its exact original cause and aborts the
11refresh. Persistence failures are never interpreted as a missing root or a
12cache miss, including with `forceCache`.
13
14`UpdaterOptions.referenceTime` accepts a finite `Date` and copies its value at
15construction. Omitting it preserves the normal fixed update-start time. This
16option supports recovery replay; it does not establish a trusted clock or
17permit accepting expired metadata at the actual trusted time.
18
19Remote verification failures expose `MetadataVerificationError.role` and retain
20the original failure in `cause`. Download failures and equal-version timestamp
21handling preserve their existing paths. Classify persistence failures before
22examining an observer's nested cause. An observer can reject with any value,
23including another verification error.
24
25Discard an updater after a failed refresh. Observer ordering does not roll back
26its in-memory trust set, make cache writes transactional, or provide a protected
27trust store. Applications must implement those boundaries independently.
28
29These extensions are maintained source changes, not options present in the
30unmodified upstream 6.0.0 package. Release and qualify this source before removing
31an application's legacy distribution patch.
packages/client/src/error.tsmodified+10-0
Changes to packages/client/src/error.ts
@@ -10,6 +10,16 @@ export class PersistError extends Error {}
1010// looking from the perspective of users of metadata API or ngclient.
1111export class RepositoryError extends Error {}
1212
13export class MetadataVerificationError extends Error {
14 constructor(
15 readonly role: string,
16 message: string,
17 options?: ErrorOptions
18 ) {
19 super(message, options);
20 }
21}
22
1323// An error for metadata that contains an invalid version number.
1424export class BadVersionError extends RepositoryError {}
1525
packages/client/src/index.tsmodified+7-0
Changes to packages/client/src/index.ts
@@ -5,3 +5,10 @@ export { Updater } from './updater';
55export type { Config } from './config';
66export type { Fetcher } from './fetcher';
77export type { UpdaterOptions } from './updater';
8
9export {
10 DownloadError,
11 DownloadHTTPError,
12 MetadataVerificationError,
13 PersistError,
14} from './error';
packages/client/src/store.tsmodified+2-2
Changes to packages/client/src/store.ts
@@ -25,9 +25,9 @@ export class TrustedMetadataStore {
2525 private trustedSet: TrustedSet = {};
2626 private referenceTime: Date;
2727
28 constructor(rootData: Buffer) {
28 constructor(rootData: Buffer, referenceTime = new Date()) {
2929 // Client workflow 5.1: record fixed update start time
30 this.referenceTime = new Date();
30 this.referenceTime = new Date(referenceTime.getTime());
3131
3232 // Client workflow 5.2: load trusted root metadata
3333 this.loadTrustedRoot(rootData);
packages/client/src/updater.tsmodified+72-17
Changes to packages/client/src/updater.ts
@@ -7,6 +7,7 @@ import { Config, defaultConfig } from './config';
77import {
88 DownloadHTTPError,
99 EqualVersionError,
10 MetadataVerificationError,
1011 PersistError,
1112 RuntimeError,
1213 ValueError,
@@ -23,6 +24,10 @@ export interface UpdaterOptions {
2324 fetcher?: Fetcher;
2425 forceCache?: boolean;
2526 config?: Partial<Config>;
27 /** Awaited after verification and before cache persistence. Receives an owned copy. */
28 onVerifiedMetadata?: (role: string, bytes: Buffer) => void | Promise<void>;
29 /** Copied fixed start time for recovery replay, not a trusted clock source. */
30 referenceTime?: Date;
2631}
2732
2833const log = debug('tuf:cache');
@@ -41,6 +46,7 @@ export class Updater {
4146 private trustedSet: TrustedMetadataStore;
4247 private config: Config;
4348 private fetcher: Fetcher;
49 private onVerifiedMetadata?: UpdaterOptions['onVerifiedMetadata'];
4450
4551 constructor(options: UpdaterOptions) {
4652 const {
@@ -59,9 +65,26 @@ export class Updater {
5965 this.targetBaseUrl = targetBaseUrl;
6066 this.forceCache = options.forceCache ?? false;
6167
68 const referenceTime = options.referenceTime ?? new Date();
69 if (
70 !(referenceTime instanceof Date) ||
71 !Number.isFinite(referenceTime.getTime())
72 ) {
73 throw new ValueError('Invalid fixed update reference time');
74 }
75 if (
76 options.onVerifiedMetadata !== undefined &&
77 typeof options.onVerifiedMetadata !== 'function'
78 ) {
79 throw new ValueError('Invalid verified metadata observer');
80 }
81 this.onVerifiedMetadata = options.onVerifiedMetadata;
6282 const data = this.loadLocalMetadata(MetadataKind.Root);
6383
64 this.trustedSet = new TrustedMetadataStore(data);
84 this.trustedSet = new TrustedMetadataStore(
85 data,
86 new Date(referenceTime.getTime())
87 );
6588 this.config = { ...defaultConfig, ...config };
6689 const userAgent = config?.userAgent
6790 ? `${config.userAgent} tuf-js/${version}`
@@ -89,6 +112,10 @@ export class Updater {
89112 try {
90113 await this.loadTimestamp({ checkRemote: false });
91114 } catch (error) {
115 // A failed observer or cache write is not a cache miss.
116 if (error instanceof PersistError) {
117 throw error;
118 }
92119 await this.loadRoot();
93120 await this.loadTimestamp();
94121 }
@@ -202,10 +229,12 @@ export class Updater {
202229 );
203230
204231 // Client workflow 5.3.4 - 5.4.7
205 this.trustedSet.updateRoot(bytesData);
232 this.verifyMetadata(MetadataKind.Root, () =>
233 this.trustedSet.updateRoot(bytesData)
234 );
206235
207236 // Client workflow 5.3.8: persist root metadata file
208 this.persistMetadata(MetadataKind.Root, bytesData);
237 await this.persistMetadata(MetadataKind.Root, bytesData);
209238 } catch (error) {
210239 if (error instanceof DownloadHTTPError) {
211240 // 404/403 means current root is newest available
@@ -249,7 +278,9 @@ export class Updater {
249278
250279 try {
251280 // Client workflow 5.4.2 - 5.4.4
252 this.trustedSet.updateTimestamp(bytesData);
281 this.verifyMetadata(MetadataKind.Timestamp, () =>
282 this.trustedSet.updateTimestamp(bytesData)
283 );
253284 } catch (error) {
254285 // If new timestamp version is same as current, discardd the new one.
255286 // This is normal and should NOT raise an error.
@@ -262,7 +293,7 @@ export class Updater {
262293 }
263294
264295 // Client workflow 5.4.5: persist timestamp metadata
265 this.persistMetadata(MetadataKind.Timestamp, bytesData);
296 await this.persistMetadata(MetadataKind.Timestamp, bytesData);
266297 }
267298
268299 // Load local and remote snapshot metadata.
@@ -297,14 +328,16 @@ export class Updater {
297328 );
298329
299330 // Client workflow 5.5.2 - 5.5.6
300 this.trustedSet.updateSnapshot(bytesData);
331 this.verifyMetadata(MetadataKind.Snapshot, () =>
332 this.trustedSet.updateSnapshot(bytesData)
333 );
301334
302335 // Client workflow 5.5.7: persist snapshot metadata file
303 this.persistMetadata(MetadataKind.Snapshot, bytesData);
336 await this.persistMetadata(MetadataKind.Snapshot, bytesData);
304337 } catch (error) {
305 throw new RuntimeError(
306 `Unable to load snapshot metadata error ${error}`
307 );
338 throw new RuntimeError('Unable to load snapshot metadata', {
339 cause: error,
340 });
308341 }
309342 }
310343 }
@@ -351,12 +384,16 @@ export class Updater {
351384 );
352385
353386 // Client workflow 5.6.2 - 5.6.6
354 this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole);
387 this.verifyMetadata(role, () =>
388 this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole)
389 );
355390
356391 // Client workflow 5.6.7: persist targets metadata file
357 this.persistMetadata(role, bytesData);
392 await this.persistMetadata(role, bytesData);
358393 } catch (error) {
359 throw new RuntimeError(`Unable to load targets error ${error}`);
394 throw new RuntimeError('Unable to load targets metadata', {
395 cause: error,
396 });
360397 }
361398 }
362399 return this.trustedSet.getRole(role);
@@ -445,16 +482,34 @@ export class Updater {
445482 return path.join(this.targetDir, filePath);
446483 }
447484
448 private persistMetadata(metaDataName: string, bytesData: Buffer) {
485 private verifyMetadata<T>(role: string, operation: () => T): T {
486 try {
487 return operation();
488 } catch (error) {
489 if (error instanceof EqualVersionError) {
490 throw error;
491 }
492 throw new MetadataVerificationError(
493 role,
494 `Failed to verify metadata ${encodeURIComponent(role)}`,
495 { cause: error }
496 );
497 }
498 }
499
500 private async persistMetadata(metaDataName: string, bytesData: Buffer) {
449501 const encodedName = encodeURIComponent(metaDataName);
450502 try {
503 if (this.onVerifiedMetadata) {
504 await this.onVerifiedMetadata(metaDataName, Buffer.from(bytesData));
505 }
451506 const filePath = path.join(this.dir, `${encodedName}.json`);
452507 log('WRITE %s', filePath);
453508 fs.writeFileSync(filePath, bytesData.toString('utf8'));
454509 } catch (error) {
455 throw new PersistError(
456 `Failed to persist metadata ${encodedName} error: ${error}`
457 );
510 throw new PersistError(`Failed to persist metadata ${encodedName}`, {
511 cause: error,
512 });
458513 }
459514 }
460515}
packages/client/tests/fixtures/observer-repository.tsadded+158-0
Changes to packages/client/tests/fixtures/observer-repository.ts
@@ -0,0 +1,158 @@
1import { generateKeyPairSync, sign, createHash } from 'node:crypto';
2import { mkdir, writeFile } from 'node:fs/promises';
3import { join } from 'node:path';
4import {
5 Key,
6 Metadata,
7 MetaFile,
8 Root,
9 Signature,
10 Snapshot,
11 TargetFile,
12 Targets,
13 Timestamp,
14} from '@tufjs/models';
15import { canonicalize } from '@tufjs/canonical-json';
16
17const digest = (bytes: Uint8Array) =>
18 createHash('sha256').update(bytes).digest('hex');
19const fields = {
20 version: 1,
21 specVersion: '1.0.31',
22 expires: '2035-01-01T00:00:00Z',
23};
24
25function signer() {
26 const pair = generateKeyPairSync('ed25519');
27 const publicKey = pair.publicKey
28 .export({ format: 'der', type: 'spki' })
29 .subarray(-32)
30 .toString('hex');
31 const keyObject = {
32 keytype: 'ed25519',
33 scheme: 'ed25519',
34 keyval: { public: publicKey },
35 };
36 const keyID = digest(Buffer.from(canonicalize(keyObject)));
37 const key = new Key({
38 keyID,
39 keyType: 'ed25519',
40 scheme: 'ed25519',
41 keyVal: { public: publicKey },
42 });
43 return {
44 key,
45 sign: (data: Buffer) =>
46 new Signature({
47 keyID: key.keyID,
48 sig: sign(null, data, pair.privateKey).toString('hex'),
49 }),
50 };
51}
52
53/** Throwaway Ed25519 keys and payload bytes are used only by local tests. */
54export async function repository(
55 directory: string,
56 options: {
57 expires?: string;
58 metadataFields?: Partial<
59 Record<
60 'root' | 'rotatedRoot' | 'timestamp' | 'snapshot' | 'targets',
61 { expires?: string; version?: number }
62 >
63 >;
64 rotate?: boolean;
65 badRotation?: boolean;
66 badTargetsSignature?: boolean;
67 } = {}
68) {
69 await mkdir(join(directory, 'metadata'), { recursive: true });
70 const keys = {
71 root: signer(),
72 targets: signer(),
73 snapshot: signer(),
74 timestamp: signer(),
75 };
76 const root = new Root({
77 ...fields,
78 ...options.metadataFields?.root,
79 consistentSnapshot: true,
80 });
81 for (const role of ['root', 'targets', 'snapshot', 'timestamp'] as const)
82 root.addKey(keys[role].key, role);
83 const rootMetadata = new Metadata(root);
84 rootMetadata.sign(keys.root.sign);
85 const rootPath = join(directory, 'trusted-root.json');
86 await writeFile(rootPath, JSON.stringify(rootMetadata.toJSON()));
87 if (options.rotate) {
88 const nextKey = signer();
89 const next = new Root({
90 ...fields,
91 version: 2,
92 ...options.metadataFields?.rotatedRoot,
93 consistentSnapshot: true,
94 });
95 next.addKey(nextKey.key, 'root');
96 for (const role of ['targets', 'snapshot', 'timestamp'] as const)
97 next.addKey(keys[role].key, role);
98 const rotated = new Metadata(next);
99 if (!options.badRotation) rotated.sign(keys.root.sign);
100 rotated.sign(nextKey.sign);
101 await writeFile(
102 join(directory, 'metadata/2.root.json'),
103 JSON.stringify(rotated.toJSON())
104 );
105 }
106 const image = Buffer.from('observer test payload');
107 const target = 'observer-fixture.txt';
108 const targets = new Metadata(
109 new Targets({
110 ...fields,
111 ...options.metadataFields?.targets,
112 targets: {
113 [target]: new TargetFile({
114 path: target,
115 length: image.length,
116 hashes: { sha256: digest(image) },
117 }),
118 },
119 })
120 );
121 targets.sign(options.badTargetsSignature ? signer().sign : keys.targets.sign);
122 const targetsBytes = Buffer.from(JSON.stringify(targets.toJSON()));
123 const snapshot = new Metadata(
124 new Snapshot({
125 ...fields,
126 ...options.metadataFields?.snapshot,
127 meta: {
128 'targets.json': new MetaFile({
129 version: 1,
130 length: targetsBytes.length,
131 hashes: { sha256: digest(targetsBytes) },
132 }),
133 },
134 })
135 );
136 snapshot.sign(keys.snapshot.sign);
137 const snapshotBytes = Buffer.from(JSON.stringify(snapshot.toJSON()));
138 const timestamp = new Metadata(
139 new Timestamp({
140 ...fields,
141 expires: options.expires ?? fields.expires,
142 ...options.metadataFields?.timestamp,
143 snapshotMeta: new MetaFile({
144 version: 1,
145 length: snapshotBytes.length,
146 hashes: { sha256: digest(snapshotBytes) },
147 }),
148 })
149 );
150 timestamp.sign(keys.timestamp.sign);
151 await writeFile(
152 join(directory, 'metadata/timestamp.json'),
153 JSON.stringify(timestamp.toJSON())
154 );
155 await writeFile(join(directory, 'metadata/1.snapshot.json'), snapshotBytes);
156 await writeFile(join(directory, 'metadata/1.targets.json'), targetsBytes);
157 return { rootPath };
158}
packages/client/tests/observer.test.tsadded+521-0
Changes to packages/client/tests/observer.test.ts
@@ -0,0 +1,521 @@
1import assert from 'node:assert/strict';
2import { existsSync, readFileSync } from 'node:fs';
3import { createServer, type Server } from 'node:http';
4import {
5 copyFile,
6 mkdir,
7 mkdtemp,
8 readFile,
9 readdir,
10 rm,
11 writeFile,
12} from 'node:fs/promises';
13import { tmpdir } from 'node:os';
14import { basename, join } from 'node:path';
15import { Updater, type UpdaterOptions } from '../src/index';
16import * as Tuf from '../src/index';
17import { repository } from './fixtures/observer-repository';
18
19const roots: string[] = [];
20const servers: Server[] = [];
21afterEach(async () => {
22 for (const server of servers.splice(0)) {
23 server.closeAllConnections();
24 await new Promise<void>((resolve, reject) => {
25 server.close((error) => (error ? reject(error) : resolve()));
26 });
27 }
28 for (const root of roots.splice(0))
29 await rm(root, { recursive: true, force: true });
30});
31
32async function setup(
33 options: Parameters<typeof repository>[1] = { rotate: true },
34 missing?: string
35) {
36 const root = await mkdtemp(join(tmpdir(), 'drone-sdk-test-'));
37 roots.push(root);
38 const repo = join(root, 'repo'),
39 cache = join(root, 'cache'),
40 requests: string[] = [];
41 const fixture = await repository(repo, options);
42 await mkdir(cache, { mode: 0o700 });
43 await copyFile(fixture.rootPath, join(cache, 'root.json'));
44 const server = createServer((request, response) => {
45 const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
46 requests.push(path);
47 const filePath = join(repo, 'metadata', basename(path));
48 if (
49 !/^\/metadata\/(?:[0-9]+\.)?(?:root|timestamp|snapshot|targets)\.json$/.test(
50 path
51 ) ||
52 basename(path) === missing ||
53 !existsSync(filePath)
54 ) {
55 response.writeHead(404).end();
56 return;
57 }
58 response.end(readFileSync(filePath));
59 });
60 await new Promise<void>((resolve, reject) => {
61 server.once('error', reject);
62 server.listen(0, '127.0.0.1', () => {
63 server.off('error', reject);
64 resolve();
65 });
66 });
67 servers.push(server);
68 const address = server.address();
69 if (!address || typeof address === 'string')
70 throw new Error('Fixture server did not bind TCP');
71 const input = {
72 metadataDir: cache,
73 metadataBaseUrl: `http://127.0.0.1:${address.port}/metadata`,
74 config: { fetchRetries: 0, fetchTimeout: 1000 },
75 } satisfies UpdaterOptions;
76 return { root, repo, cache, fixture, requests, input };
77}
78
79async function failureOf(pending: Promise<unknown>): Promise<Error> {
80 try {
81 await pending;
82 } catch (error) {
83 assert(error instanceof Error);
84 return error;
85 }
86 throw new Error('Expected refresh to reject');
87}
88
89function rootVersion(bytes: string): number {
90 const metadata: unknown = JSON.parse(bytes);
91 assert(metadata && typeof metadata === 'object' && 'signed' in metadata);
92 const signed = metadata.signed;
93 assert(signed && typeof signed === 'object' && 'version' in signed);
94 assert(typeof signed.version === 'number');
95 return signed.version;
96}
97
98const roles = ['root', 'timestamp', 'snapshot', 'targets'] as const;
99
100test('the maintained SDK observer receives exact verified bytes before each cache write', async () => {
101 const item = await setup(),
102 seen: string[] = [];
103 const updater = new Updater({
104 ...item.input,
105 async onVerifiedMetadata(role, bytes) {
106 seen.push(role);
107 if (role === 'root')
108 expect(
109 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
110 ).toBe(1);
111 else expect(existsSync(join(item.cache, `${role}.json`))).toBe(false);
112 const file =
113 role === 'root'
114 ? '2.root.json'
115 : role === 'timestamp'
116 ? 'timestamp.json'
117 : `1.${role}.json`;
118 expect(bytes).toEqual(await readFile(join(item.repo, 'metadata', file)));
119 },
120 });
121 await updater.refresh();
122 expect(seen).toEqual([...roles]);
123 expect((await readdir(item.cache)).sort()).toEqual([
124 'root.json',
125 'snapshot.json',
126 'targets.json',
127 'timestamp.json',
128 ]);
129});
130
131test('a pending observer prevents the cache write and the next metadata request', async () => {
132 const item = await setup();
133 let release!: () => void, entered!: () => void;
134 const gate = new Promise<void>((resolve) => {
135 release = resolve;
136 });
137 const seen = new Promise<void>((resolve) => {
138 entered = resolve;
139 });
140 const pending = new Updater({
141 ...item.input,
142 async onVerifiedMetadata(role) {
143 if (role === 'root') {
144 entered();
145 await gate;
146 }
147 },
148 }).refresh();
149 try {
150 await seen;
151 expect(
152 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
153 ).toBe(1);
154 expect(item.requests).toEqual(['/metadata/2.root.json']);
155 } finally {
156 release();
157 await pending;
158 }
159});
160
161for (const failedRole of roles) {
162 test(`an observer rejection for ${failedRole} aborts before that cache or a later role advances`, async () => {
163 const item = await setup(),
164 seen: string[] = [],
165 cause = new Error('protected persistence rejected');
166 const updater = new Updater({
167 ...item.input,
168 async onVerifiedMetadata(role) {
169 await Promise.resolve();
170 seen.push(role);
171 if (role === failedRole) throw cause;
172 },
173 });
174 const failure = await failureOf(updater.refresh());
175 const persistence =
176 failedRole === 'snapshot' || failedRole === 'targets'
177 ? failure.cause
178 : failure;
179 expect(persistence).toBeInstanceOf(Tuf.PersistError);
180 assert(persistence instanceof Tuf.PersistError);
181 expect(persistence.message).toBe(
182 `Failed to persist metadata ${failedRole}`
183 );
184 expect(persistence.cause).toBe(cause);
185 expect(seen).toEqual(roles.slice(0, roles.indexOf(failedRole) + 1));
186 if (failedRole === 'root')
187 expect(
188 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
189 ).toBe(1);
190 else expect(existsSync(join(item.cache, `${failedRole}.json`))).toBe(false);
191 });
192}
193
194test('a genuine SDK HTTP404 thrown by the observer cannot be mistaken for the end of root rotation', async () => {
195 const source = await setup({ rotate: false }, 'timestamp.json');
196 const downloadFailure = await failureOf(new Updater(source.input).refresh());
197 assert(downloadFailure instanceof Tuf.DownloadHTTPError);
198 expect(downloadFailure.statusCode).toBe(404);
199 const item = await setup(),
200 seen: string[] = [];
201 await expect(
202 new Updater({
203 ...item.input,
204 onVerifiedMetadata(role) {
205 seen.push(role);
206 throw downloadFailure;
207 },
208 }).refresh()
209 ).rejects.toThrow('Failed to persist metadata root');
210 expect(seen).toEqual(['root']);
211 expect(
212 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
213 ).toBe(1);
214});
215
216test('the observer owns a byte copy and cannot mutate SDK verification or cache bytes', async () => {
217 const item = await setup(),
218 observed: Record<string, Buffer> = {};
219 await new Updater({
220 ...item.input,
221 onVerifiedMetadata(role, bytes) {
222 observed[role] = Buffer.from(bytes);
223 bytes.fill(0);
224 },
225 }).refresh();
226 for (const role of roles)
227 expect(
228 (await readFile(join(item.cache, `${role}.json`))).equals(observed[role])
229 ).toBe(true);
230});
231
232test('an unauthorized root never reaches the observer and leaves the initial cache intact', async () => {
233 const item = await setup({ rotate: true, badRotation: true }),
234 seen: string[] = [];
235 await expect(
236 new Updater({
237 ...item.input,
238 onVerifiedMetadata(role) {
239 seen.push(role);
240 },
241 }).refresh()
242 ).rejects.toThrow();
243 expect(seen).toEqual([]);
244 expect(
245 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
246 ).toBe(1);
247});
248
249test('a later invalid target signature preserves only the earlier verified metadata observations', async () => {
250 const item = await setup({ rotate: true, badTargetsSignature: true }),
251 seen: string[] = [];
252 await expect(
253 new Updater({
254 ...item.input,
255 onVerifiedMetadata(role) {
256 seen.push(role);
257 },
258 }).refresh()
259 ).rejects.toThrow();
260 expect(seen).toEqual(['root', 'timestamp', 'snapshot']);
261 expect(existsSync(join(item.cache, 'targets.json'))).toBe(false);
262});
263
264test('snapshot-bound tampering is rejected before it reaches the observer', async () => {
265 const item = await setup(),
266 seen: string[] = [];
267 await writeFile(join(item.repo, 'metadata/1.targets.json'), '{}');
268 await expect(
269 new Updater({
270 ...item.input,
271 onVerifiedMetadata(role) {
272 seen.push(role);
273 },
274 }).refresh()
275 ).rejects.toThrow();
276 expect(seen).toEqual(['root', 'timestamp', 'snapshot']);
277});
278
279test('the supplied fixed start time is owned before callers can mutate it', async () => {
280 const item = await setup(),
281 time = new Date('2030-01-01T00:00:00Z');
282 const updater = new Updater({ ...item.input, referenceTime: time });
283 time.setUTCFullYear(2040);
284 await updater.refresh();
285 expect(
286 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
287 ).toBe(2);
288});
289
290test('fixed-time recovery replay is distinct from current-time authorization', async () => {
291 const past = await setup({ expires: '2025-01-01T00:00:00Z' });
292 await new Updater({
293 ...past.input,
294 referenceTime: new Date('2024-01-01T00:00:00Z'),
295 }).refresh();
296 const current = await setup({ expires: '2025-01-01T00:00:00Z' });
297 const failure = await failureOf(new Updater(current.input).refresh());
298 expect(failure).toBeInstanceOf(Tuf.MetadataVerificationError);
299 assert(failure instanceof Tuf.MetadataVerificationError);
300 expect(failure.role).toBe('timestamp');
301 expect(failure.cause).toBeInstanceOf(Error);
302 assert(failure.cause instanceof Error);
303 expect(failure.cause.message).toContain('expired');
304});
305
306test('invalid fixed time is rejected before loading even a missing bootstrap root', () => {
307 expect(
308 () =>
309 new Updater({
310 metadataDir: '/missing-drone-sdk-bootstrap',
311 metadataBaseUrl: 'http://127.0.0.1:1',
312 referenceTime: new Date(Number.NaN),
313 })
314 ).toThrow('Invalid fixed update reference time');
315});
316
317test('a fully cached trusted refresh does not emit observations for cache bytes', async () => {
318 const item = await setup();
319 await new Updater(item.input).refresh();
320 const seen: string[] = [];
321 await new Updater({
322 ...item.input,
323 onVerifiedMetadata(role) {
324 seen.push(role);
325 },
326 }).refresh();
327 expect(seen).toEqual([]);
328});
329
330test('an authenticated intermediate root is observed before final-root expiry rejects the refresh', async () => {
331 const item = await setup({
332 rotate: true,
333 metadataFields: { rotatedRoot: { expires: '2020-01-01T00:00:00Z' } },
334 }),
335 seen: string[] = [];
336 const failure = await failureOf(
337 new Updater({
338 ...item.input,
339 onVerifiedMetadata(role) {
340 seen.push(role);
341 },
342 }).refresh()
343 );
344 expect(failure).toBeInstanceOf(Tuf.MetadataVerificationError);
345 assert(failure instanceof Tuf.MetadataVerificationError);
346 expect(failure.role).toBe('timestamp');
347 expect(failure.cause).toBeInstanceOf(Error);
348 assert(failure.cause instanceof Error);
349 expect(failure.cause.message).toContain('expired');
350 expect(seen).toEqual(['root']);
351 expect(
352 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
353 ).toBe(2);
354});
355
356test('forceCache cannot swallow a rejected remote timestamp observer when its local cache is missing', async () => {
357 const item = await setup({ rotate: false }),
358 seen: string[] = [];
359 await expect(
360 new Updater({
361 ...item.input,
362 forceCache: true,
363 onVerifiedMetadata(role) {
364 seen.push(role);
365 if (role === 'timestamp')
366 throw new Error('protected timestamp persistence rejected');
367 },
368 }).refresh()
369 ).rejects.toThrow('Failed to persist metadata timestamp');
370 expect(seen).toEqual(['timestamp']);
371 expect(item.requests).toEqual(['/metadata/timestamp.json']);
372 expect((await readdir(item.cache)).sort()).toEqual(['root.json']);
373});
374
375test('a signed metadata rejection retains its original typed cause through the SDK wrapper', async () => {
376 const item = await setup({ badTargetsSignature: true });
377 const failure = await failureOf(new Updater(item.input).refresh());
378 expect(failure).toBeInstanceOf(Error);
379 expect(failure.cause).toBeInstanceOf(Error);
380});
381
382test('recovery can identify a remote metadata rejection through a public typed cause', async () => {
383 const item = await setup({ badTargetsSignature: true });
384 const failure = await failureOf(new Updater(item.input).refresh());
385 expect(Tuf.MetadataVerificationError).toEqual(expect.any(Function));
386 expect(failure.cause).toBeInstanceOf(Tuf.MetadataVerificationError);
387 assert(failure.cause instanceof Tuf.MetadataVerificationError);
388 expect(failure.cause.role).toBe('targets');
389});
390
391for (const role of roles) {
392 test(`remote ${role} verification failure has a typed role and original cause`, async () => {
393 const options: Parameters<typeof repository>[1] =
394 role === 'root'
395 ? { rotate: true, badRotation: true }
396 : { metadataFields: { [role]: { expires: '2020-01-01T00:00:00Z' } } };
397 const item = await setup(options);
398 const failure = await failureOf(new Updater(item.input).refresh());
399 const verification =
400 failure instanceof Tuf.MetadataVerificationError
401 ? failure
402 : failure.cause;
403 expect(verification).toBeInstanceOf(Tuf.MetadataVerificationError);
404 assert(verification instanceof Tuf.MetadataVerificationError);
405 expect(verification.role).toBe(role);
406 expect(verification.cause).toBeInstanceOf(Error);
407 });
408}
409
410test('observer failure stays a persistence failure and preserves its exact cause', async () => {
411 const item = await setup(),
412 cause = new Error('protected storage failure');
413 const failure = await failureOf(
414 new Updater({
415 ...item.input,
416 onVerifiedMetadata() {
417 throw cause;
418 },
419 }).refresh()
420 );
421 expect(failure).toBeInstanceOf(Tuf.PersistError);
422 expect(failure).not.toBeInstanceOf(Tuf.MetadataVerificationError);
423 expect(failure.cause).toBe(cause);
424 expect(item.requests).toEqual(['/metadata/2.root.json']);
425});
426
427test('an observer-supplied verification error is still classified as failed persistence first', async () => {
428 const item = await setup({ rotate: false }),
429 cause = new Tuf.MetadataVerificationError(
430 'timestamp',
431 'caller error, not an SDK verification verdict'
432 );
433 const failure = await failureOf(
434 new Updater({
435 ...item.input,
436 forceCache: true,
437 onVerifiedMetadata() {
438 throw cause;
439 },
440 }).refresh()
441 );
442 expect(failure).toBeInstanceOf(Tuf.PersistError);
443 expect(failure.cause).toBe(cause);
444 expect(item.requests).toEqual(['/metadata/timestamp.json']);
445});
446
447test('the public HTTP failure class matches actual missing-metadata failures', async () => {
448 const item = await setup({ rotate: false }, 'timestamp.json');
449 const failure = await failureOf(new Updater(item.input).refresh());
450 expect(failure).toBeInstanceOf(Tuf.DownloadHTTPError);
451 assert(failure instanceof Tuf.DownloadHTTPError);
452 expect(failure).toBeInstanceOf(Tuf.DownloadError);
453 expect(failure.statusCode).toBe(404);
454 expect(failure).not.toBeInstanceOf(Tuf.MetadataVerificationError);
455});
456
457test('a Symbol observer rejection remains a persistence failure under forceCache', async () => {
458 const item = await setup({ rotate: false }),
459 seen: string[] = [],
460 cause = Symbol('opaque storage failure');
461 const failure = await failureOf(
462 new Updater({
463 ...item.input,
464 forceCache: true,
465 onVerifiedMetadata(role) {
466 seen.push(role);
467 if (role === 'timestamp') {
468 // Deliberately exercise a legal opaque observer rejection.
469 // eslint-disable-next-line @typescript-eslint/only-throw-error
470 throw cause;
471 }
472 },
473 }).refresh()
474 );
475 expect(failure).toBeInstanceOf(Tuf.PersistError);
476 expect(failure.cause).toBe(cause);
477 expect(seen).toEqual(['timestamp']);
478 expect(item.requests).toEqual(['/metadata/timestamp.json']);
479 expect((await readdir(item.cache)).sort()).toEqual(['root.json']);
480});
481
482test('observer failures are never coerced while wrapping any metadata role', async () => {
483 for (const failedRole of roles) {
484 const item = await setup(),
485 seen: string[] = [];
486 let coercions = 0;
487 const cause = {
488 toString() {
489 coercions++;
490 throw new Error('Cause coercion must not run');
491 },
492 };
493 const failure = await failureOf(
494 new Updater({
495 ...item.input,
496 onVerifiedMetadata(role) {
497 seen.push(role);
498 if (role === failedRole) {
499 // Deliberately reject with an object whose coercion must never run.
500 // eslint-disable-next-line @typescript-eslint/only-throw-error
501 throw cause;
502 }
503 },
504 }).refresh()
505 );
506 const persistence =
507 failedRole === 'snapshot' || failedRole === 'targets'
508 ? failure.cause
509 : failure;
510 expect(persistence).toBeInstanceOf(Tuf.PersistError);
511 assert(persistence instanceof Tuf.PersistError);
512 expect(persistence.cause).toBe(cause);
513 expect(coercions).toBe(0);
514 expect(seen).toEqual(roles.slice(0, roles.indexOf(failedRole) + 1));
515 if (failedRole === 'root')
516 expect(
517 rootVersion(await readFile(join(item.cache, 'root.json'), 'utf8'))
518 ).toBe(1);
519 else expect(existsSync(join(item.cache, `${failedRole}.json`))).toBe(false);
520 }
521});
packages/client/tests/updater.test.tsmodified+9-2
Changes to packages/client/tests/updater.test.ts
@@ -4,6 +4,7 @@ import fs from 'fs';
44import nock from 'nock';
55import os from 'os';
66import path from 'path';
7import { MetadataVerificationError } from '../src/error';
78import { Updater, UpdaterOptions } from '../src/updater';
89
910describe('Updater', () => {
@@ -91,9 +92,15 @@ describe('Updater', () => {
9192 nock(baseURL).get('/metadata/2.root.json').reply(200, {});
9293 });
9394
94 it('resolves with no error', async () => {
95 it('rejects with the verified role and original validation cause', async () => {
9596 const updater = new Updater(options);
96 await expect(updater.refresh()).rejects.toThrow(TypeError);
97 const refresh = updater.refresh();
98 await expect(refresh).rejects.toBeInstanceOf(MetadataVerificationError);
99 await expect(refresh).rejects.toHaveProperty('role', 'root');
100 await expect(refresh).rejects.toHaveProperty(
101 'cause',
102 expect.any(TypeError)
103 );
97104 });
98105 });
99106 });