diff --git a/.changeset/fresh-kiwis-doubt.md b/.changeset/fresh-kiwis-doubt.md new file mode 100644 index 00000000..beb44c31 --- /dev/null +++ b/.changeset/fresh-kiwis-doubt.md @@ -0,0 +1,7 @@ +--- +'@contentauth/c2pa-utilities': minor +'@contentauth/c2pa-node': minor +'@contentauth/c2pa-web': minor +--- + +Introduce new c2pa-utilities package, and update c2pa-web and c2pa-node to use it. diff --git a/packages/c2pa-node/js-src/Settings.spec.ts b/packages/c2pa-node/js-src/Settings.spec.ts index b82db824..6a380e48 100644 --- a/packages/c2pa-node/js-src/Settings.spec.ts +++ b/packages/c2pa-node/js-src/Settings.spec.ts @@ -4,230 +4,13 @@ // or the MIT license (http://opensource.org/licenses/MIT), // at your option. -import { - createTrustSettings, - createCawgTrustSettings, - createVerifySettings, - mergeSettings, - settingsToJson, - loadSettingsFromFile, - loadSettingsFromUrl, -} from "./Settings.js"; -import type { TrustConfig, VerifyConfig, SettingsContext } from "./types.d.ts"; import * as fs from "fs-extra"; import * as path from "path"; import * as os from "os"; -import { vi } from "vitest"; -// Mock node-fetch -vi.mock("node-fetch", () => ({ - default: vi.fn(), -})); +import { loadSettingsFromFile } from "./Settings.js"; describe("Settings", () => { - it("creates trust settings", () => { - const trustConfig: TrustConfig = { - verifyTrustList: true, - userAnchors: "test", - allowedList: "allowed", - }; - - const settings = createTrustSettings(trustConfig); - expect(settings.trust).toBeDefined(); - expect(settings.trust?.verifyTrustList).toBe(true); - expect(settings.trust?.userAnchors).toBe("test"); - expect(settings.trust?.allowedList).toBe("allowed"); - }); - - it("creates CAWG trust settings", () => { - const trustConfig: TrustConfig = { - verifyTrustList: false, - trustAnchors: "anchors", - }; - - const settings = createCawgTrustSettings(trustConfig); - expect(settings.cawgTrust).toBeDefined(); - expect(settings.cawgTrust?.verifyTrustList).toBe(false); - expect(settings.cawgTrust?.trustAnchors).toBe("anchors"); - }); - - it("creates verify settings", () => { - const verifyConfig: VerifyConfig = { - verifyAfterReading: true, - verifyAfterSign: false, - verifyTrust: true, - verifyTimestampTrust: false, - ocspFetch: true, - remoteManifestFetch: false, - skipIngredientConflictResolution: true, - strictV1Validation: false, - }; - - const settings = createVerifySettings(verifyConfig); - expect(settings.verify).toBeDefined(); - expect(settings.verify?.verifyAfterReading).toBe(true); - expect(settings.verify?.verifyAfterSign).toBe(false); - expect(settings.verify?.verifyTrust).toBe(true); - expect(settings.verify?.ocspFetch).toBe(true); - }); - - it("creates verify settings with partial config", () => { - const settings = createVerifySettings({ - verifyAfterReading: false, - }); - - expect(settings.verify).toBeDefined(); - expect(settings.verify?.verifyAfterReading).toBe(false); - expect(settings.verify?.verifyAfterSign).toBeUndefined(); - expect(settings.verify?.verifyTrust).toBeUndefined(); - }); - - it("merges multiple settings", () => { - const trustSettings = createTrustSettings({ - verifyTrustList: true, - userAnchors: "test", - }); - - const verifySettings = createVerifySettings({ - verifyAfterReading: false, - verifyAfterSign: true, - verifyTrust: true, - verifyTimestampTrust: true, - ocspFetch: false, - remoteManifestFetch: true, - skipIngredientConflictResolution: false, - strictV1Validation: false, - }); - - const merged = mergeSettings(trustSettings, verifySettings); - expect(merged.trust).toBeDefined(); - expect(merged.verify).toBeDefined(); - expect(merged.trust?.verifyTrustList).toBe(true); - expect(merged.verify?.verifyAfterReading).toBe(false); - }); - - it("converts settings to JSON with snake_case keys", () => { - const settings = createVerifySettings({ - verifyAfterReading: true, - verifyAfterSign: true, - verifyTrust: false, - verifyTimestampTrust: true, - ocspFetch: false, - remoteManifestFetch: true, - skipIngredientConflictResolution: false, - strictV1Validation: false, - }); - - const json = settingsToJson(settings); - expect(json).toContain("verify"); - expect(json).toContain("verify_after_reading"); - - // Should be parseable with snake_case keys - const parsed = JSON.parse(json); - expect(parsed.verify.verify_after_reading).toBe(true); - }); - - it("does not include undefined values in trust settings JSON", () => { - const trustConfig: TrustConfig = { - verifyTrustList: true, - }; - - const settings = createTrustSettings(trustConfig); - const json = settingsToJson(settings); - const parsed = JSON.parse(json); - - expect(parsed.trust.verify_trust_list).toBe(true); - expect("user_anchors" in parsed.trust).toBe(false); - expect("trust_anchors" in parsed.trust).toBe(false); - expect("trust_config" in parsed.trust).toBe(false); - expect("allowed_list" in parsed.trust).toBe(false); - }); - - it("does not include undefined values in CAWG trust settings JSON", () => { - const trustConfig: TrustConfig = { - verifyTrustList: false, - }; - - const settings = createCawgTrustSettings(trustConfig); - const json = settingsToJson(settings); - const parsed = JSON.parse(json); - - expect(parsed.cawg_trust.verify_trust_list).toBe(false); - expect("user_anchors" in parsed.cawg_trust).toBe(false); - expect("trust_anchors" in parsed.cawg_trust).toBe(false); - }); - - it("does not include undefined values in verify settings JSON", () => { - const verifyConfig: VerifyConfig = { - verifyAfterReading: true, - verifyAfterSign: false, - }; - - const settings = createVerifySettings(verifyConfig); - const json = settingsToJson(settings); - const parsed = JSON.parse(json); - - expect(parsed.verify.verify_after_reading).toBe(true); - expect(parsed.verify.verify_after_sign).toBe(false); - expect("verify_trust" in parsed.verify).toBe(false); - expect("verify_timestamp_trust" in parsed.verify).toBe(false); - expect("ocsp_fetch" in parsed.verify).toBe(false); - expect("remote_manifest_fetch" in parsed.verify).toBe(false); - }); - - it("does not include undefined values when merging settings", () => { - const settings1: SettingsContext = { - trust: { - verifyTrustList: true, - userAnchors: "test", - }, - }; - - const settings2: SettingsContext = { - trust: { - verifyTrustList: true, - allowedList: undefined, - }, - verify: { - verifyAfterReading: false, - }, - }; - - const merged = mergeSettings(settings1, settings2); - const json = settingsToJson(merged); - const parsed = JSON.parse(json); - - expect(parsed.trust.verify_trust_list).toBe(true); - expect(parsed.trust.user_anchors).toBe("test"); - expect("allowed_list" in parsed.trust).toBe(false); - expect(parsed.verify.verify_after_reading).toBe(false); - }); - - it("merges settings with later values overriding earlier ones", () => { - const settings1 = createVerifySettings({ - verifyAfterReading: true, - verifyAfterSign: true, - verifyTrust: false, - verifyTimestampTrust: true, - ocspFetch: false, - remoteManifestFetch: true, - skipIngredientConflictResolution: false, - strictV1Validation: false, - }); - - const settings2: SettingsContext = { - verify: { - verifyTrust: true, - ocspFetch: true, - }, - }; - - const merged = mergeSettings(settings1, settings2); - expect(merged.verify?.verifyAfterReading).toBe(true); // from settings1 - expect(merged.verify?.verifyTrust).toBe(true); // overridden by settings2 - expect(merged.verify?.ocspFetch).toBe(true); // overridden by settings2 - }); - describe("loadSettingsFromFile", () => { let tempDir: string; @@ -274,52 +57,4 @@ verify_after_sign = false`; await expect(loadSettingsFromFile(filePath)).rejects.toThrow(); }); }); - - describe("loadSettingsFromUrl", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("loads settings from a URL", async () => { - const mockSettings = JSON.stringify({ - verify: { - verify_after_reading: true, - }, - }); - - const fetch = (await import("node-fetch")).default; - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: async () => mockSettings, - } as any); - - const loaded = await loadSettingsFromUrl( - "https://example.com/settings.json", - ); - expect(loaded).toBe(mockSettings); - expect(fetch).toHaveBeenCalledWith("https://example.com/settings.json"); - }); - - it("throws error for failed fetch", async () => { - const fetch = (await import("node-fetch")).default; - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - statusText: "Not Found", - } as any); - - await expect( - loadSettingsFromUrl("https://example.com/missing.json"), - ).rejects.toThrow("Failed to fetch settings from URL: 404 Not Found"); - }); - - it("throws error for network failure", async () => { - const fetch = (await import("node-fetch")).default; - vi.mocked(fetch).mockRejectedValue(new Error("Network error")); - - await expect( - loadSettingsFromUrl("https://example.com/settings.json"), - ).rejects.toThrow("Network error"); - }); - }); }); diff --git a/packages/c2pa-node/js-src/Settings.ts b/packages/c2pa-node/js-src/Settings.ts index e817fc44..cc9df728 100644 --- a/packages/c2pa-node/js-src/Settings.ts +++ b/packages/c2pa-node/js-src/Settings.ts @@ -12,96 +12,13 @@ // each license. import * as fs from "fs-extra"; -import fetch from "node-fetch"; - -import type { TrustConfig, VerifyConfig, SettingsContext } from "./types.d.ts"; - -type SettingsObjectType = { - [k: string]: string | boolean | undefined | SettingsObjectType; -}; - -function snakeCaseify(object: SettingsObjectType): SettingsObjectType { - return Object.entries(object).reduce( - (result, [key, val]) => { - result[snakeCase(key)] = - typeof val === "object" && val !== null ? snakeCaseify(val) : val; - return result; - }, - {} as SettingsObjectType, - ); -} - -function snakeCase(str: string): string { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); -} /** - * Create a Settings object with trust configuration. - * @param trustConfig The trust configuration - * @returns Settings object that can be passed to Reader/Builder + * This file contains only Settings functions that are unique to the Node SDK. + * + * Shared Settings-related functions and types can be found in `c2pa-utilities`, + * and are re-exported by `c2pa-node` (see `index.ts`) for convenience. */ -export function createTrustSettings(trustConfig: TrustConfig): SettingsContext { - return { trust: { ...trustConfig } }; -} - -/** - * Create a settings object with CAWG trust configuration. - * @param trustConfig The CAWG trust configuration - * @returns Settings object that can be passed to Reader/Builder - */ -export function createCawgTrustSettings( - trustConfig: TrustConfig, -): SettingsContext { - return { cawgTrust: { ...trustConfig } }; -} - -/** - * Create a settings object with verify configuration. - * @param verifyConfig The verify configuration - * @returns Settings object that can be passed to Reader/Builder - */ -export function createVerifySettings( - verifyConfig: VerifyConfig, -): SettingsContext { - return { verify: { ...verifyConfig } }; -} - -/** - * Merge multiple settings objects into one. - * Later settings override earlier ones. - * @param settings Settings objects to merge - * @returns Merged settings object - */ -export function mergeSettings(...settings: SettingsContext[]): SettingsContext { - const merged: SettingsContext = {}; - - for (const setting of settings) { - if (setting.trust) { - merged.trust = { ...merged.trust, ...setting.trust }; - } - if (setting.cawgTrust) { - merged.cawgTrust = { ...merged.cawgTrust, ...setting.cawgTrust }; - } - if (setting.verify) { - merged.verify = { ...merged.verify, ...setting.verify }; - } - if (setting.builder) { - merged.builder = { ...merged.builder, ...setting.builder }; - } - } - - return merged; -} - -/** - * Convert a settings object to a JSON string. - * Converts camelCase keys to snake_case to match the c2pa-rs settings format. - * @param settings The settings object - * @returns JSON string representation with snake_case keys - */ -export function settingsToJson(settings: SettingsContext): string { - return JSON.stringify(snakeCaseify(settings as SettingsObjectType)); -} /** * Load settings from a TOML or JSON file. @@ -112,18 +29,3 @@ export async function loadSettingsFromFile(filePath: string): Promise { const content = await fs.readFile(filePath, "utf8"); return content; } - -/** - * Load settings from a URL. - * @param url The URL to fetch the settings from - * @returns Settings as a string - */ -export async function loadSettingsFromUrl(url: string): Promise { - const res = await fetch(url); - if (!res.ok) { - throw new Error( - `Failed to fetch settings from URL: ${res.status} ${res.statusText}`, - ); - } - return await res.text(); -} diff --git a/packages/c2pa-node/js-src/index.ts b/packages/c2pa-node/js-src/index.ts index ee6432f6..779c2f26 100644 --- a/packages/c2pa-node/js-src/index.ts +++ b/packages/c2pa-node/js-src/index.ts @@ -23,3 +23,4 @@ export { export { Trustmark } from "./Trustmark.js"; export { isActionsAssertion } from "./assertions.js"; export * from "./Settings.js"; +export * from '@contentauth/c2pa-utilities'; diff --git a/packages/c2pa-node/js-src/types.d.ts b/packages/c2pa-node/js-src/types.d.ts index a194ca71..76d7eff3 100644 --- a/packages/c2pa-node/js-src/types.d.ts +++ b/packages/c2pa-node/js-src/types.d.ts @@ -195,7 +195,9 @@ export interface HashedUri { export type C2paSettings = string | object; export interface BuilderInterface { - /** An intent lets the API know what kind of manifest to create. + /** + * An intent lets the API know what kind of manifest to create. + * * Intents are `Create`, `Edit`, or `Update`. * This allows the API to check that you are doing the right thing. * It can also do things for you, like add parent ingredients from the source asset @@ -516,62 +518,26 @@ export interface TrustmarkConfig { modelPath?: string; } +import type { + Settings as _Settings, + TrustSettings as _TrustSettings, + VerifySettings as _VerifySettings +} from "@contentauth/c2pa-utilities"; + /** - * Configuration for trust settings in C2PA. - * Controls certificate trust validation and trust anchor management. + * @deprecated Use `TrustSettings` instead, which encapsulates both normal trust and CAWG trust settings. + * Kept as an alias for backwards compatibility. */ -export interface TrustConfig { - /** Whether to verify against the trust list */ - verifyTrustList: boolean; - /** User-provided trust anchors (PEM format or base64-encoded certificate hashes) */ - userAnchors?: string; - /** Trust anchors for validation (PEM format or base64-encoded certificate hashes) */ - trustAnchors?: string; - /** Trust configuration file path */ - trustConfig?: string; - /** Allowed list of certificates (PEM format or base64-encoded certificate hashes) */ - allowedList?: string; -} +export type TrustConfig = _TrustSettings; /** - * Configuration for verification settings in C2PA. - * Controls various verification behaviors and options. + * @deprecated Use `VerifySettings` instead. + * Kept as an alias for backwards compatibility. */ -export interface VerifyConfig { - /** Whether to verify after reading a manifest */ - verifyAfterReading?: boolean; - /** Whether to verify after signing a manifest */ - verifyAfterSign?: boolean; - /** Whether to verify trust during validation */ - verifyTrust?: boolean; - /** Whether to verify timestamp trust */ - verifyTimestampTrust?: boolean; - /** Whether to fetch OCSP responses */ - ocspFetch?: boolean; - /** Whether to fetch remote manifests */ - remoteManifestFetch?: boolean; - /** Whether to skip ingredient conflict resolution */ - skipIngredientConflictResolution?: boolean; - /** Whether to use strict v1 validation */ - strictV1Validation?: boolean; -} +export type VerifyConfig = _VerifySettings; /** - * Settings configuration object that can be passed to Reader and Builder constructors. - * Only trust, verify, and builder settings are configurable from the Node SDK. - * Uses snake_case internally to match the c2pa-rs settings format. + * @deprecated Use `Settings` instead. + * Kept as an alias for backwards compatibility. */ -export interface SettingsContext { - /** C2PA trust configuration */ - trust?: TrustConfig; - /** CAWG trust configuration */ - cawgTrust?: TrustConfig; - /** Verification configuration */ - verify?: VerifyConfig; - /** Builder configuration */ - builder?: { - thumbnail?: { - enabled?: boolean; - }; - }; -} +export type SettingsContext = _Settings; diff --git a/packages/c2pa-node/package.json b/packages/c2pa-node/package.json index 3013711b..f79e84c5 100644 --- a/packages/c2pa-node/package.json +++ b/packages/c2pa-node/package.json @@ -75,6 +75,7 @@ }, "homepage": "https://github.com/contentauth/c2pa-js/tree/main/packages/c2pa-node#readme", "dependencies": { + "@contentauth/c2pa-utilities": "workspace:*", "cargo-cp-artifact": "^0.1.9", "cli-progress": "^3.12.0", "debug": "^4.4.3", diff --git a/packages/c2pa-node/tsconfig.json b/packages/c2pa-node/tsconfig.json index bb85d040..ded0e442 100644 --- a/packages/c2pa-node/tsconfig.json +++ b/packages/c2pa-node/tsconfig.json @@ -21,6 +21,9 @@ "references": [ { "path": "../c2pa-types" + }, + { + "path": "../c2pa-utilities" } ] } diff --git a/packages/c2pa-utilities/package.json b/packages/c2pa-utilities/package.json new file mode 100644 index 00000000..d5212de0 --- /dev/null +++ b/packages/c2pa-utilities/package.json @@ -0,0 +1,50 @@ +{ + "name": "@contentauth/c2pa-utilities", + "version": "0.1.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/contentauth/c2pa-js" + }, + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + }, + "dependencies": { + "ts-deepmerge": "^8.0.0" + }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "msw": "^2.12.1", + "rimraf": "^6.0.1", + "vitest": "^3.0.0" + }, + "nx": { + "tags": [ + "lib" + ] + } +} diff --git a/packages/c2pa-utilities/src/caseConversion.spec.ts b/packages/c2pa-utilities/src/caseConversion.spec.ts new file mode 100644 index 00000000..ef184a3a --- /dev/null +++ b/packages/c2pa-utilities/src/caseConversion.spec.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +import { describe, expect, it } from 'vitest'; +import { snakeCaseify } from './caseConversion.js'; + +describe('snakeCaseify', () => { + it('converts camelCase keys to snake_case', () => { + const result = snakeCaseify({ verifyAfterReading: true } as any); + expect(result).toEqual({ verify_after_reading: true }); + }); + + it('recurses into nested objects', () => { + const result = snakeCaseify({ + builder: { generateC2paArchive: true, thumbnail: { enabledFlag: false } } + } as any); + expect(result).toEqual({ + builder: { + generate_c2pa_archive: true, + thumbnail: { enabled_flag: false } + } + }); + }); + + it('preserves arrays as arrays instead of flattening them into objects', () => { + // typeof [] === 'object' in JS — a naive recursion would otherwise rebuild this as + // {"0": "a", "1": "b"} via Object.entries, corrupting anything c2pa-rs expects as a + // JSON array (e.g. BuilderSettings.createdAssertionLabels: string[]). + const result = snakeCaseify({ someArrayField: ['a', 'b'] } as any); + expect(Array.isArray(result.some_array_field)).toBe(true); + expect(result.some_array_field).toEqual(['a', 'b']); + }); + + it('snake-cases keys of object elements inside an array', () => { + const result = snakeCaseify({ + someArrayField: [{ innerCamelKey: 1 }, { innerCamelKey: 2 }] + } as any); + expect(result.some_array_field).toEqual([ + { inner_camel_key: 1 }, + { inner_camel_key: 2 } + ]); + }); + + it('leaves an empty array as an empty array', () => { + const result = snakeCaseify({ someArrayField: [] } as any); + expect(result.some_array_field).toEqual([]); + }); +}); diff --git a/packages/c2pa-utilities/src/caseConversion.ts b/packages/c2pa-utilities/src/caseConversion.ts new file mode 100644 index 00000000..7cf2e54b --- /dev/null +++ b/packages/c2pa-utilities/src/caseConversion.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +/** + * Any value that can appear inside a resolved Settings object when `snakeCaseifyValue` walks + * it: the primitives Settings fields actually use (string, boolean), undefined (optional + * fields), a nested object, or an array of any of these — recursive so arbitrarily nested + * structures (including arrays of objects) get walked without losing array-ness. Deliberately + * narrower than "any JSON value" (no numbers, for instance) since no current Settings field + * needs one. We can widen this if that changes. + */ +export type SettingsValue = + | string + | boolean + | undefined + | SettingsObjectType + | SettingsValue[]; +export type SettingsObjectType = { + [k: string]: SettingsValue; +}; + +/** + * Recursively converts an object's camelCase keys to snake_case, matching the format + * the core native library expects for settings JSON. Arrays are preserved as arrays; + * only their object elements (if any) get their keys snake-cased, since `typeof [] === 'object'` + * in JS and a naive `Object.entries`-based recursion would otherwise flatten an array into a + * `{"0": ..., "1": ...}` object instead of a JSON array. + */ +export function snakeCaseify(object: SettingsObjectType): SettingsObjectType { + const formattedObject = Object.entries(object).reduce( + (formattedObject, [key, val]) => { + formattedObject[snakeCase(key)] = snakeCaseifyValue(val); + return formattedObject; + }, + {} as SettingsObjectType + ); + + return formattedObject; +} + +function snakeCaseifyValue(val: SettingsValue): SettingsValue { + if (Array.isArray(val)) { + return val.map(snakeCaseifyValue); + } + if (typeof val === 'object' && val !== null) { + return snakeCaseify(val); + } + return val; +} + +function snakeCase(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +} diff --git a/packages/c2pa-utilities/src/fetchWithRetry.spec.ts b/packages/c2pa-utilities/src/fetchWithRetry.spec.ts new file mode 100644 index 00000000..ff0899d1 --- /dev/null +++ b/packages/c2pa-utilities/src/fetchWithRetry.spec.ts @@ -0,0 +1,430 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, + vi +} from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { + fetchWithRetry, + fetchWithRetryRaw, + DEFAULT_MAX_RETRY_AFTER_MS, + DEFAULT_MAX_RESPONSE_BYTES +} from './fetchWithRetry.js'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + vi.unstubAllGlobals(); +}); +afterAll(() => server.close()); + +describe('URL validation', () => { + test('fetchWithRetry rejects a malformed URL without attempting a request', async () => { + await expect(fetchWithRetry('not a valid url')).rejects.toThrow( + 'Invalid URL: not a valid url' + ); + }); + + test('fetchWithRetryRaw rejects a malformed URL without attempting a request', async () => { + await expect(fetchWithRetryRaw('not a valid url')).rejects.toThrow( + 'Invalid URL: not a valid url' + ); + }); +}); + +describe('fetchWithRetry', () => { + test('fetches and returns response text', async () => { + server.use(http.get('http://plainText', () => HttpResponse.text('hello'))); + + const result = await fetchWithRetry('http://plainText'); + expect(result).toBe('hello'); + }); + + test('reports a meaningful error for a non-OK HTTP response with no Retry-After', async () => { + server.use( + http.get( + 'http://always429', + () => + new HttpResponse(null, { status: 429, statusText: 'Too Many Requests' }) + ) + ); + + await expect(fetchWithRetry('http://always429')).rejects.toThrow( + 'Failed to fetch http://always429: 429' + ); + }); + + test('respects an HTTP-date Retry-After header and retries', async () => { + let requestCount = 0; + server.use( + http.get('http://retryAfterDate', () => { + requestCount++; + if (requestCount === 1) { + const retryDate = new Date(Date.now() + 100).toUTCString(); + return new HttpResponse(null, { + status: 429, + headers: { 'Retry-After': retryDate } + }); + } + return HttpResponse.text('resolved after http-date retry'); + }) + ); + + const result = await fetchWithRetry('http://retryAfterDate'); + expect(result).toBe('resolved after http-date retry'); + }); + + test('falls back to generic backoff when Retry-After is unparseable', async () => { + let requestCount = 0; + server.use( + http.get('http://retryAfterInvalid', () => { + requestCount++; + if (requestCount === 1) { + return new HttpResponse(null, { + status: 429, + headers: { 'Retry-After': 'not-a-valid-value' } + }); + } + return HttpResponse.text('resolved after fallback backoff'); + }) + ); + + const result = await fetchWithRetry('http://retryAfterInvalid'); + expect(result).toBe('resolved after fallback backoff'); + }); + + test('fails immediately when Retry-After exceeds the maximum allowed delay', async () => { + const tooLongSeconds = DEFAULT_MAX_RETRY_AFTER_MS / 1000 + 1; + server.use( + http.get( + 'http://retryAfterTooLong', + () => + new HttpResponse(null, { + status: 429, + headers: { 'Retry-After': String(tooLongSeconds) } + }) + ) + ); + + await expect(fetchWithRetry('http://retryAfterTooLong')).rejects.toThrow( + 'exceeds the maximum allowed delay' + ); + }); + + test('recovers after a transient 500 by retrying', async () => { + server.use( + http.get( + 'http://transient500', + () => new HttpResponse(null, { status: 500 }), + { once: true } + ), + http.get('http://transient500', () => + HttpResponse.text('recovered after 500') + ) + ); + + const result = await fetchWithRetry('http://transient500'); + expect(result).toBe('recovered after 500'); + }); + + test('rejects after exhausting retries when server keeps returning 429 with Retry-After', async () => { + server.use( + http.get( + 'http://always429WithRetryAfter', + () => + new HttpResponse(null, { + status: 429, + headers: { 'Retry-After': '0' } + }) + ) + ); + + await expect( + fetchWithRetry('http://always429WithRetryAfter') + ).rejects.toThrow('Failed to fetch http://always429WithRetryAfter: 429'); + }); + + test('rejects after exhausting retries on repeated network errors', async () => { + server.use( + http.get('http://repeatedNetworkError', () => HttpResponse.error()) + ); + + await expect( + fetchWithRetry('http://repeatedNetworkError') + ).rejects.toThrow('Network error fetching http://repeatedNetworkError'); + }); + + test('stringifies a non-Error network rejection after exhausting retries', async () => { + // fetch() is spec'd to reject with an Error, but the code defends against a + // non-Error rejection too (e.g. a broken polyfill) — MSW's HttpResponse.error() + // always rejects with a real Error, so this is exercised with a direct stub instead. + vi.stubGlobal('fetch', vi.fn().mockRejectedValue('raw string rejection')); + + await expect(fetchWithRetry('http://nonErrorRejection')).rejects.toThrow( + 'Network error fetching http://nonErrorRejection: raw string rejection' + ); + }); + + test('applies the default 1MB cap when maxResponseBytes is omitted', async () => { + const oversizedBody = 'x'.repeat(DEFAULT_MAX_RESPONSE_BYTES + 1); + server.use( + http.get('http://defaultCap', () => HttpResponse.text(oversizedBody)) + ); + + await expect(fetchWithRetry('http://defaultCap')).rejects.toThrow( + `Response from http://defaultCap is too large. Max size is ${DEFAULT_MAX_RESPONSE_BYTES} bytes.` + ); + }); + + test('accepts a response under the default cap when maxResponseBytes is omitted', async () => { + server.use( + http.get('http://underDefaultCap', () => + HttpResponse.text('x'.repeat(10_000)) + ) + ); + + const result = await fetchWithRetry('http://underDefaultCap'); + expect(result).toHaveLength(10_000); + }); + + test('rejects a response larger than the given maxResponseBytes', async () => { + server.use( + http.get('http://overCap', () => HttpResponse.text('x'.repeat(20))) + ); + + await expect( + fetchWithRetry('http://overCap', { maxResponseBytes: 10 }) + ).rejects.toThrow('Response from http://overCap is too large. Max size is 10 bytes.'); + }); + + test('rejects based on Content-Length before reading an oversized body', async () => { + server.use( + http.get( + 'http://oversizedContentLength', + () => + new HttpResponse('small body', { + headers: { 'Content-Length': '999999999' } + }) + ) + ); + + await expect( + fetchWithRetry('http://oversizedContentLength', { maxResponseBytes: 10 }) + ).rejects.toThrow( + 'Response from http://oversizedContentLength is too large. Max size is 10 bytes.' + ); + }); + + test('falls back to checking the body when Content-Length is absent', async () => { + server.use( + http.get('http://streamedOverCap', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('x'.repeat(20))); + controller.close(); + } + }); + return new HttpResponse(stream); + }) + ); + + await expect( + fetchWithRetry('http://streamedOverCap', { maxResponseBytes: 10 }) + ).rejects.toThrow( + 'Response from http://streamedOverCap is too large. Max size is 10 bytes.' + ); + }); + + test('accepts a response at or under the given maxResponseBytes', async () => { + server.use( + http.get('http://underCap', () => HttpResponse.text('x'.repeat(10))) + ); + + const result = await fetchWithRetry('http://underCap', { + maxResponseBytes: 10 + }); + expect(result).toHaveLength(10); + }); + + test('honors a custom maxRetries, giving up sooner than the default', async () => { + let requestCount = 0; + server.use( + http.get('http://customMaxRetries', () => { + requestCount++; + return new HttpResponse(null, { status: 500 }); + }) + ); + + await expect( + fetchWithRetry('http://customMaxRetries', { maxRetries: 0 }) + ).rejects.toThrow('Failed to fetch http://customMaxRetries: 500'); + expect(requestCount).toBe(1); + }); + + test('honors a custom maxRetries, retrying more than the default', async () => { + let requestCount = 0; + server.use( + http.get('http://moreRetries', () => { + requestCount++; + if (requestCount <= 4) { + return new HttpResponse(null, { status: 500 }); + } + return HttpResponse.text('resolved after extra retries'); + }) + ); + + const result = await fetchWithRetry('http://moreRetries', { + maxRetries: 4, + initialRetryDelayMs: 1, + maxRetryDelayMs: 5 + }); + expect(result).toBe('resolved after extra retries'); + expect(requestCount).toBe(5); + }); + + test('honors a custom maxRetryAfterMs cap', async () => { + server.use( + http.get( + 'http://customRetryAfterCap', + () => + new HttpResponse(null, { + status: 429, + headers: { 'Retry-After': '2' } + }) + ) + ); + + await expect( + fetchWithRetry('http://customRetryAfterCap', { maxRetryAfterMs: 1000 }) + ).rejects.toThrow('exceeds the maximum allowed delay'); + }); + + test('honors a custom isRetryableStatus, retrying a status not retried by default', async () => { + let requestCount = 0; + server.use( + http.get('http://customRetryableStatus', () => { + requestCount++; + if (requestCount === 1) { + return new HttpResponse(null, { status: 418 }); + } + return HttpResponse.text('resolved after custom-status retry'); + }) + ); + + const result = await fetchWithRetry('http://customRetryableStatus', { + isRetryableStatus: (status) => status === 418 + }); + expect(result).toBe('resolved after custom-status retry'); + }); + + test('honors a custom isRetryableStatus, refusing to retry a status retried by default', async () => { + let requestCount = 0; + server.use( + http.get('http://noRetryOn500', () => { + requestCount++; + return new HttpResponse(null, { status: 500 }); + }) + ); + + await expect( + fetchWithRetry('http://noRetryOn500', { + isRetryableStatus: () => false + }) + ).rejects.toThrow('Failed to fetch http://noRetryOn500: 500'); + expect(requestCount).toBe(1); + }); + + test('honors a Retry-After header on a non-429 retryable status', async () => { + let requestCount = 0; + server.use( + http.get('http://retryAfterOn503', () => { + requestCount++; + if (requestCount === 1) { + return new HttpResponse(null, { + status: 503, + headers: { 'Retry-After': '0' } + }); + } + return HttpResponse.text('resolved after 503 retry-after'); + }) + ); + + const result = await fetchWithRetry('http://retryAfterOn503'); + expect(result).toBe('resolved after 503 retry-after'); + }); +}); + +describe('fetchWithRetryRaw', () => { + test('returns the raw Response for a successful request', async () => { + server.use(http.get('http://rawSuccess', () => HttpResponse.json({ a: 1 }))); + + const res = await fetchWithRetryRaw('http://rawSuccess'); + expect(res.ok).toBe(true); + await expect(res.json()).resolves.toEqual({ a: 1 }); + }); + + test('forwards method, headers, and body via RequestInit', async () => { + let receivedBody: unknown; + let receivedHeader: string | null = null; + server.use( + http.post('http://rawPost', async ({ request }) => { + receivedHeader = request.headers.get('x-test-header'); + receivedBody = await request.json(); + return HttpResponse.json({ ok: true }); + }) + ); + + const res = await fetchWithRetryRaw('http://rawPost', { + method: 'POST', + headers: { 'x-test-header': 'value', 'content-type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }) + }); + + expect(res.ok).toBe(true); + expect(receivedHeader).toBe('value'); + expect(receivedBody).toEqual({ hello: 'world' }); + }); + + test('does not enforce a response size cap', async () => { + const oversizedBody = 'x'.repeat(DEFAULT_MAX_RESPONSE_BYTES + 1); + server.use( + http.get('http://rawNoSizeCap', () => HttpResponse.text(oversizedBody)) + ); + + const res = await fetchWithRetryRaw('http://rawNoSizeCap'); + const text = await res.text(); + expect(text).toHaveLength(DEFAULT_MAX_RESPONSE_BYTES + 1); + }); + + test('still retries on a retryable status using the same policy options', async () => { + server.use( + http.get( + 'http://rawRetry', + () => { + return new HttpResponse(null, { status: 500 }); + }, + { once: true } + ), + http.get('http://rawRetry', () => HttpResponse.text('recovered')) + ); + + const res = await fetchWithRetryRaw('http://rawRetry'); + await expect(res.text()).resolves.toBe('recovered'); + }); +}); diff --git a/packages/c2pa-utilities/src/fetchWithRetry.ts b/packages/c2pa-utilities/src/fetchWithRetry.ts new file mode 100644 index 00000000..1106002e --- /dev/null +++ b/packages/c2pa-utilities/src/fetchWithRetry.ts @@ -0,0 +1,270 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +/** + * This file implements a generic HTTP fetch-with-retry mechanism, using exponential backoff and + * `Retry-After` handling. The retry mechanism itself (the loop, backoff calculation, and + * `Retry-After` handling) is fixed; the policy around it — retry count, backoff timing, which + * statuses are retryable, and the maximum honored `Retry-After` delay — is configurable per + * caller via {@link FetchWithRetryOptions}, so different consumers (browser vs. server, or + * different Adobe-internal call sites) can inject their own policy without forking the + * mechanism. + */ + +/** + * Default maximum response size, in bytes, when {@link FetchWithRetryOptions.maxResponseBytes} + * isn't specified. Only enforced by {@link fetchWithRetry}'s text response handling — + * {@link fetchWithRetryRaw} returns the raw `Response` and does not enforce a size cap. + */ +export const DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024; // 1MB + +/** + * Default fetch-with-retry policy values, used whenever the corresponding + * {@link FetchWithRetryOptions} field is omitted. + */ +export const DEFAULT_MAX_RETRIES = 2; +export const DEFAULT_INITIAL_RETRY_DELAY_MS = 200; +export const DEFAULT_MAX_RETRY_DELAY_MS = 2_000; +export const DEFAULT_MAX_RETRY_AFTER_MS = 30_000; + +/** + * @param status An HTTP response status code. + * @returns Whether the given status is retryable, by default: `429` or any `5xx` status. + */ +export function defaultIsRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +/** + * Options to configure the fetch-with-retry mechanism ({@link fetchWithRetry} and + * {@link fetchWithRetryRaw}). All fields are optional and fall back to the module's documented + * defaults, so existing callers are unaffected by omitting them. + */ +export interface FetchWithRetryOptions { + /** + * Maximum allowed response size, in bytes, for {@link fetchWithRetry}'s text response. + * Defaults to {@link DEFAULT_MAX_RESPONSE_BYTES} when omitted. Not enforced by + * {@link fetchWithRetryRaw}. + */ + maxResponseBytes?: number; + + /** + * Maximum number of retry attempts for a retryable network error or HTTP response. + * Defaults to {@link DEFAULT_MAX_RETRIES} when omitted. + */ + maxRetries?: number; + + /** + * Initial delay, in milliseconds, before the first retry's exponential backoff. + * Defaults to {@link DEFAULT_INITIAL_RETRY_DELAY_MS} when omitted. + */ + initialRetryDelayMs?: number; + + /** + * Maximum delay, in milliseconds, that the exponential backoff (before jitter) can reach. + * Defaults to {@link DEFAULT_MAX_RETRY_DELAY_MS} when omitted. + */ + maxRetryDelayMs?: number; + + /** + * Maximum `Retry-After` delay, in milliseconds, that will be honored. A `Retry-After` value + * exceeding this throws immediately rather than waiting. Defaults to + * {@link DEFAULT_MAX_RETRY_AFTER_MS} when omitted. + */ + maxRetryAfterMs?: number; + + /** + * Predicate deciding whether a given HTTP response status should be retried. Defaults to + * {@link defaultIsRetryableStatus} (`429` or any `5xx`) when omitted. Network errors are + * always retried regardless of this predicate. + */ + isRetryableStatus?: (status: number) => boolean; +} + +/** + * @param attempt The current attempt number. + * @param initialDelayMs Initial backoff delay, in milliseconds. + * @param maxDelayMs Maximum backoff delay (before jitter), in milliseconds. + * @returns The backoff time for the current attempt number in milliseconds, + * in accordance with an exponential backoff policy. + */ +function calculateBackoffMs( + attempt: number, + initialDelayMs: number, + maxDelayMs: number +): number { + const backoff = Math.min(initialDelayMs * 2 ** attempt, maxDelayMs); + const jitter = Math.floor(Math.random() * 200); + return Math.min(backoff + jitter, maxDelayMs); // jitter, capped +} + +/** + * Parses a `Retry-After` header value, which per HTTP spec is either a number of + * seconds or an HTTP date. + * @param value The `Retry-After` header value. + * @returns the delay in milliseconds, or null if the header is absent or unparseable. + */ +function parseRetryAfterMs(value: string | null): number | null { + if (!value) { + return null; + } + + const seconds = Number(value); + if (!Number.isNaN(seconds)) { + return seconds * 1000; + } + + const dateMs = Date.parse(value); + if (Number.isNaN(dateMs)) { + return null; + } + + return dateMs - Date.now(); +} + +/** + * @param input A URL string to validate. + * @throws if `input` is not a well-formed, absolute URL. + */ +function assertValidUrl(input: string): void { + try { + new URL(input); + } catch { + throw new Error(`Invalid URL: ${input}`); + } +} + +/** + * Fetches `input`, retrying on network errors and on responses whose status is deemed + * retryable (see {@link FetchWithRetryOptions.isRetryableStatus}), with exponential backoff + * (respecting a `Retry-After` header when present). Returns the raw, successful `Response` — + * callers are responsible for reading and validating the body themselves (e.g. `.json()`, + * `.text()`, streaming, or their own size cap), which makes this suitable for arbitrary + * requests (custom methods, headers, bodies) rather than just simple GETs. + * + * @param input The URL to fetch. Validated up front — a malformed URL throws immediately + * rather than being retried, since it can never succeed. + * @param init Standard `fetch` request options (method, headers, body, signal, etc.). + * @param options Options for configuring the retry policy. + * @returns The successful response. + */ +export async function fetchWithRetryRaw( + input: string, + init?: RequestInit, + options?: FetchWithRetryOptions +): Promise { + assertValidUrl(input); + + const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; + const initialRetryDelayMs = + options?.initialRetryDelayMs ?? DEFAULT_INITIAL_RETRY_DELAY_MS; + const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS; + const maxRetryAfterMs = options?.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS; + const isRetryableStatus = options?.isRetryableStatus ?? defaultIsRetryableStatus; + + const backoff = (attempt: number) => + calculateBackoffMs(attempt, initialRetryDelayMs, maxRetryDelayMs); + + for (let attempt = 0; ; attempt++) { + let res: Response; + try { + res = await fetch(input, init); + } catch (e) { + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, backoff(attempt))); + continue; + } + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Network error fetching ${input}: ${message}`, { + cause: e + }); + } + + if (!res.ok) { + const retryable = isRetryableStatus(res.status); + + if (retryable) { + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + if (retryAfterMs !== null) { + if (retryAfterMs > maxRetryAfterMs) { + throw new Error( + `Failed to fetch ${input}: server requested a Retry-After delay of ` + + `${Math.ceil(retryAfterMs / 1000)}s, which exceeds the maximum allowed delay of ` + + `${maxRetryAfterMs / 1000}s` + ); + } + + if (attempt < maxRetries) { + await new Promise((resolve) => + setTimeout(resolve, Math.max(retryAfterMs, 0)) + ); + continue; + } + + throw new Error( + `Failed to fetch ${input}: ${res.status} ${res.statusText}` + ); + } + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, backoff(attempt))); + continue; + } + } + + throw new Error( + `Failed to fetch ${input}: ${res.status} ${res.statusText}` + ); + } + + return res; + } +} + +/** + * Fetches `url` as text, retrying on network errors, `429`, and `5xx` responses with + * exponential backoff (respecting a `Retry-After` header when present), and enforcing a + * maximum response size. A thin convenience wrapper around {@link fetchWithRetryRaw} for the + * common GET-and-read-as-text case; use {@link fetchWithRetryRaw} directly for other methods, + * request bodies, or response handling. + * + * The size cap is checked against the `Content-Length` header first, before reading the body, + * to reject an oversized response without buffering it into memory. `Content-Length` is + * optional, though (e.g. chunked transfer-encoding omits it), so the body's actual length is + * still checked afterward as a fallback for responses that didn't send an (accurate) header. + * + * @param url The URL to fetch. + * @param options Options for configuring the fetch. + * @returns The response body. + */ +export async function fetchWithRetry( + url: string, + options?: FetchWithRetryOptions +): Promise { + const maxResponseBytes = options?.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; + + const res = await fetchWithRetryRaw(url, undefined, options); + + const contentLengthHeader = res.headers.get('content-length'); + if (contentLengthHeader !== null && Number(contentLengthHeader) > maxResponseBytes) { + throw new Error( + `Response from ${url} is too large. Max size is ${maxResponseBytes} bytes.` + ); + } + + const text = await res.text(); + + if (text.length > maxResponseBytes) { + throw new Error( + `Response from ${url} is too large. Max size is ${maxResponseBytes} bytes.` + ); + } + + return text; +} diff --git a/packages/c2pa-utilities/src/index.ts b/packages/c2pa-utilities/src/index.ts new file mode 100644 index 00000000..9c50b26e --- /dev/null +++ b/packages/c2pa-utilities/src/index.ts @@ -0,0 +1,12 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +export * from './settings.js'; +export * from './fetchWithRetry.js'; +export * from './caseConversion.js'; diff --git a/packages/c2pa-utilities/src/settings.spec.ts b/packages/c2pa-utilities/src/settings.spec.ts new file mode 100644 index 00000000..ff29f7a7 --- /dev/null +++ b/packages/c2pa-utilities/src/settings.spec.ts @@ -0,0 +1,672 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, + vi +} from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { + resolveSettings, + createTrustSettings, + createCawgTrustSettings, + createVerifySettings, + mergeSettings, + settingsToJson, + loadSettingsFromUrl, + type TrustSettings, + type VerifySettings, + type Settings +} from './settings.js'; +import { DEFAULT_MAX_RESPONSE_BYTES } from './fetchWithRetry.js'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + vi.unstubAllGlobals(); +}); +afterAll(() => server.close()); + +describe('settings', () => { + describe('resolveSettings', () => { + describe('general behavior', () => { + test('should return undefined when neither argument is provided', async () => { + const result = await resolveSettings(undefined, undefined); + expect(result).toBeUndefined(); + }); + + test('should serialize base settings when only base is provided', async () => { + const result = await resolveSettings({ verify: { verifyTrust: false } }, undefined); + expect(result).toEqual( + JSON.stringify({ builder: { generate_c2pa_archive: true }, verify: { verify_trust: false } }) + ); + }); + + test('should serialize override settings when only override is provided', async () => { + const result = await resolveSettings(undefined, { verify: { verifyTrust: false } }); + expect(result).toEqual( + JSON.stringify({ builder: { generate_c2pa_archive: true }, verify: { verify_trust: false } }) + ); + }); + + test('should accept an empty object as override', async () => { + const result = await resolveSettings(undefined, {}); + expect(result).toEqual( + JSON.stringify({ builder: { generate_c2pa_archive: true } }) + ); + }); + + test('should merge override settings on top of base settings', async () => { + const base = { + verify: { verifyTrust: true, verifyAfterReading: true } + }; + const override = { + verify: { verifyTrust: false } + }; + + const result = await resolveSettings(base, override); + + // verifyTrust from override wins; verifyAfterReading from base is preserved + expect(result).toEqual( + JSON.stringify({ + builder: { generate_c2pa_archive: true }, + verify: { verify_trust: false, verify_after_reading: true } + }) + ); + }); + + test('should preserve base settings keys not present in override', async () => { + const base: Settings = { + verify: { verifyAfterReading: false }, + builder: { generateC2paArchive: true } + }; + const override = { + verify: { verifyTrust: true } + }; + + const result = await resolveSettings(base, override); + + expect(result).toEqual( + JSON.stringify({ + builder: { generate_c2pa_archive: true }, + verify: { verify_after_reading: false, verify_trust: true } + }) + ); + }); + + test('should not throw when a settings value is null', async () => { + // typeof null === 'object' in JS — without a null guard this crashes + const result = await resolveSettings(undefined, { verify: null as any }); + expect(result).toEqual( + JSON.stringify({ builder: { generate_c2pa_archive: true }, verify: null }) + ); + }); + + test('should not throw when a nested settings value is null', async () => { + const result = await resolveSettings(undefined, { trust: { userAnchors: null as any } }); + expect(result).toEqual( + JSON.stringify({ + builder: { generate_c2pa_archive: true }, + trust: { user_anchors: null } + }) + ); + }); + }); + + describe('trust', () => { + test('should pass through a non-url value', async () => { + const result = await resolveSettings(undefined, { + trust: { + userAnchors: 'foo', + trustAnchors: 'bar', + allowedList: 'baz', + trustConfig: 'qux' + }, + cawgTrust: { + userAnchors: 'cawg foo', + trustAnchors: 'cawg bar', + allowedList: 'cawg baz', + trustConfig: 'cawg qux' + } + }); + + expect(result).toEqual( + JSON.stringify({ + builder: { generate_c2pa_archive: true }, + trust: { + user_anchors: 'foo', + trust_anchors: 'bar', + allowed_list: 'baz', + trust_config: 'qux' + }, + cawg_trust: { + user_anchors: 'cawg foo', + trust_anchors: 'cawg bar', + allowed_list: 'cawg baz', + trust_config: 'cawg qux' + } + }) + ); + }); + + test('should fetch URL trust values', async () => { + server.use( + http.get('http://userAnchors', () => + HttpResponse.text( + '-----BEGIN CERTIFICATE-----foo-----END CERTIFICATE-----' + ) + ), + http.get('http://trustAnchors', () => + HttpResponse.text( + '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----' + ) + ), + http.get('http://allowedList', () => HttpResponse.text('allowed')), + http.get('http://trustConfig', () => HttpResponse.text('config')) + ); + + const result = await resolveSettings(undefined, { + trust: { + userAnchors: 'http://userAnchors', + trustAnchors: 'http://trustAnchors', + allowedList: 'http://allowedList', + trustConfig: 'http://trustConfig' + }, + cawgTrust: { + userAnchors: 'http://userAnchors', + trustAnchors: 'http://trustAnchors', + allowedList: 'http://allowedList', + trustConfig: 'http://trustConfig' + } + }); + + expect(result).toEqual( + JSON.stringify({ + builder: { generate_c2pa_archive: true }, + trust: { + user_anchors: + '-----BEGIN CERTIFICATE-----foo-----END CERTIFICATE-----', + trust_anchors: + '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----', + allowed_list: 'allowed', + trust_config: 'config' + }, + cawg_trust: { + user_anchors: + '-----BEGIN CERTIFICATE-----foo-----END CERTIFICATE-----', + trust_anchors: + '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----', + allowed_list: 'allowed', + trust_config: 'config' + } + }) + ); + }); + + test('should fetch URL trust values from base settings', async () => { + server.use( + http.get('http://baseTrustAnchors', () => + HttpResponse.text( + '-----BEGIN CERTIFICATE-----base-----END CERTIFICATE-----' + ) + ) + ); + + // URL in base settings should be fetched even when override is also present. + const result = await resolveSettings( + { trust: { trustAnchors: 'http://baseTrustAnchors' } }, + { verify: { verifyTrust: true } } + ); + + expect(result).toContain('-----BEGIN CERTIFICATE-----base-----END CERTIFICATE-----'); + }); + + test('should concatenate the fetched results of URLs when given as an array', async () => { + server.use( + http.get('http://userAnchorsConcat', () => + HttpResponse.text( + '-----BEGIN CERTIFICATE-----qux-----END CERTIFICATE-----' + ) + ) + ); + + const result = await resolveSettings(undefined, { + trust: { + userAnchors: [ + 'http://userAnchorsConcat', + 'http://userAnchorsConcat' + ] + } + }); + + expect(result).toEqual( + JSON.stringify({ + builder: { generate_c2pa_archive: true }, + trust: { + user_anchors: + '-----BEGIN CERTIFICATE-----qux-----END CERTIFICATE----------BEGIN CERTIFICATE-----qux-----END CERTIFICATE-----' + } + }) + ); + }); + + test('should report an error when fetching a URL without a certificate', async () => { + server.use( + http.get('http://userAnchorsShouldFail', () => + HttpResponse.text('invalid') + ) + ); + + const resultPromise = resolveSettings(undefined, { + trust: { + userAnchors: 'http://userAnchorsShouldFail' + } + }); + + await expect(resultPromise).rejects.toThrow( + 'Failed to resolve trust settings.' + ); + }); + + test('should not fetch URLs for unknown keys not defined in TrustSettings', async () => { + let unknownKeyFetched = false; + server.use( + http.get('http://unknownKey', () => { + unknownKeyFetched = true; + return HttpResponse.text('should not be fetched'); + }), + http.get('http://trustAnchors', () => + HttpResponse.text( + '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----' + ) + ) + ); + + const result = await resolveSettings(undefined, { + trust: { + trustAnchors: 'http://trustAnchors', + ...(({ unknownKey: 'http://unknownKey' }) as any) + } + }); + + expect(unknownKeyFetched).toBe(false); + expect(result).toContain('trust_anchors'); + }); + + test('should not crash when a cawgTrust boolean field is present', async () => { + const resultPromise = resolveSettings(undefined, { + cawgTrust: { + verifyTrustList: true + } + }); + + await expect(resultPromise).resolves.not.toThrow(); + }); + + test('should throw when a fetched response exceeds the default size limit', async () => { + const oversizedBody = 'x'.repeat(DEFAULT_MAX_RESPONSE_BYTES + 1); + server.use( + http.get('http://oversized', () => HttpResponse.text(oversizedBody)) + ); + + const resultPromise = resolveSettings(undefined, { + trust: { + trustConfig: 'http://oversized' + } + }); + + await expect(resultPromise).rejects.toThrow( + 'Failed to resolve trust settings.' + ); + }); + + test('should respect a per-call maxResponseBytes override', async () => { + // Body is larger than a small custom cap, but well within the default 1MB — + // this only fails if the injected option is actually being honored. + const body = 'x'.repeat(2048); + server.use( + http.get('http://customCap', () => HttpResponse.text(body)) + ); + + const resultPromise = resolveSettings( + undefined, + { trust: { trustConfig: 'http://customCap' } }, + { maxResponseBytes: 1024 } + ); + + await expect(resultPromise).rejects.toThrow( + 'Failed to resolve trust settings.' + ); + }); + + test('should reject when an array item is not a string', async () => { + const resultPromise = resolveSettings(undefined, { + trust: { + userAnchors: [123 as unknown as string] + } + }); + + await expect(resultPromise).rejects.toThrow( + 'Failed to resolve trust settings.' + ); + }); + + test('should reject when a fetched array item fails PEM validation', async () => { + server.use( + http.get('http://userAnchorsArrayInvalid', () => + HttpResponse.text('not a cert') + ) + ); + + const resultPromise = resolveSettings(undefined, { + trust: { + userAnchors: ['http://userAnchorsArrayInvalid'] + } + }); + + await expect(resultPromise).rejects.toThrow( + 'Failed to resolve trust settings.' + ); + }); + + }); + }); +}); + +describe('createTrustSettings / createCawgTrustSettings / createVerifySettings', () => { + it('creates trust settings', () => { + // Note: verifyTrustList is intentionally not part of the base TrustSettings type — + // c2pa-rs documents it as CAWG-only, even though it reuses one struct for both. See + // "creates CAWG trust settings" below for verifyTrustList coverage. + const trustConfig: TrustSettings = { + userAnchors: 'test', + allowedList: 'allowed' + }; + + const settings = createTrustSettings(trustConfig); + expect(settings.trust).toBeDefined(); + expect(settings.trust?.userAnchors).toBe('test'); + expect(settings.trust?.allowedList).toBe('allowed'); + }); + + it('creates CAWG trust settings', () => { + const trustConfig: TrustSettings = { + verifyTrustList: false, + trustAnchors: 'anchors' + }; + + const settings = createCawgTrustSettings(trustConfig); + expect(settings.cawgTrust).toBeDefined(); + expect(settings.cawgTrust?.verifyTrustList).toBe(false); + expect(settings.cawgTrust?.trustAnchors).toBe('anchors'); + }); + + it('creates verify settings', () => { + const verifyConfig: VerifySettings = { + verifyAfterReading: true, + verifyAfterSign: false, + verifyTrust: true, + verifyTimestampTrust: false, + ocspFetch: true, + remoteManifestFetch: false, + skipIngredientConflictResolution: true, + strictV1Validation: false + }; + + const settings = createVerifySettings(verifyConfig); + expect(settings.verify).toBeDefined(); + expect(settings.verify?.verifyAfterReading).toBe(true); + expect(settings.verify?.verifyAfterSign).toBe(false); + expect(settings.verify?.verifyTrust).toBe(true); + expect(settings.verify?.ocspFetch).toBe(true); + }); + + it('creates verify settings with partial config', () => { + const settings = createVerifySettings({ + verifyAfterReading: false + }); + + expect(settings.verify).toBeDefined(); + expect(settings.verify?.verifyAfterReading).toBe(false); + expect(settings.verify?.verifyAfterSign).toBeUndefined(); + expect(settings.verify?.verifyTrust).toBeUndefined(); + }); +}); + +describe('mergeSettings', () => { + it('merges multiple settings', () => { + const trustSettings = createTrustSettings({ + userAnchors: 'test' + }); + + const verifySettings = createVerifySettings({ + verifyAfterReading: false, + verifyAfterSign: true, + verifyTrust: true, + verifyTimestampTrust: true, + ocspFetch: false, + remoteManifestFetch: true, + skipIngredientConflictResolution: false, + strictV1Validation: false + }); + + const merged = mergeSettings(trustSettings, verifySettings); + expect(merged.trust).toBeDefined(); + expect(merged.verify).toBeDefined(); + expect(merged.trust?.userAnchors).toBe('test'); + expect(merged.verify?.verifyAfterReading).toBe(false); + }); + + it('merges settings with later values overriding earlier ones', () => { + const settings1 = createVerifySettings({ + verifyAfterReading: true, + verifyAfterSign: true, + verifyTrust: false, + verifyTimestampTrust: true, + ocspFetch: false, + remoteManifestFetch: true, + skipIngredientConflictResolution: false, + strictV1Validation: false + }); + + const settings2: Settings = { + verify: { + verifyTrust: true, + ocspFetch: true + } + }; + + const merged = mergeSettings(settings1, settings2); + expect(merged.verify?.verifyAfterReading).toBe(true); // from settings1 + expect(merged.verify?.verifyTrust).toBe(true); // overridden by settings2 + expect(merged.verify?.ocspFetch).toBe(true); // overridden by settings2 + }); + + it('deep-merges nested fields instead of overwriting whole sections', () => { + // Two settings fragments each set a different sub-field of builder — + // a shallow per-section merge would let the second clobber the first entirely, + // losing settings1's thumbnail field. + const settings1: Settings = { + builder: { generateC2paArchive: true, thumbnail: { enabled: true } } + }; + const settings2: Settings = { + builder: { generateC2paArchive: true } + }; + + const merged = mergeSettings(settings1, settings2); + expect(merged.builder?.thumbnail?.enabled).toBe(true); + expect(merged.builder?.generateC2paArchive).toBe(true); + }); +}); + +describe('settingsToJson', () => { + it('converts settings to JSON with snake_case keys', () => { + const settings = createVerifySettings({ + verifyAfterReading: true, + verifyAfterSign: true, + verifyTrust: false, + verifyTimestampTrust: true, + ocspFetch: false, + remoteManifestFetch: true, + skipIngredientConflictResolution: false, + strictV1Validation: false + }); + + const json = settingsToJson(settings); + expect(json).toContain('verify'); + expect(json).toContain('verify_after_reading'); + + // Should be parseable with snake_case keys + const parsed = JSON.parse(json); + expect(parsed.verify.verify_after_reading).toBe(true); + }); + + it('does not include undefined values in CAWG trust settings JSON', () => { + const trustConfig: TrustSettings = { + verifyTrustList: true + }; + + const settings = createCawgTrustSettings(trustConfig); + const json = settingsToJson(settings); + const parsed = JSON.parse(json); + + expect(parsed.cawg_trust.verify_trust_list).toBe(true); + expect('user_anchors' in parsed.cawg_trust).toBe(false); + expect('trust_anchors' in parsed.cawg_trust).toBe(false); + expect('trust_config' in parsed.cawg_trust).toBe(false); + expect('allowed_list' in parsed.cawg_trust).toBe(false); + }); + + it('does not include undefined values in verify settings JSON', () => { + const verifyConfig: VerifySettings = { + verifyAfterReading: true, + verifyAfterSign: false + }; + + const settings = createVerifySettings(verifyConfig); + const json = settingsToJson(settings); + const parsed = JSON.parse(json); + + expect(parsed.verify.verify_after_reading).toBe(true); + expect(parsed.verify.verify_after_sign).toBe(false); + expect('verify_trust' in parsed.verify).toBe(false); + expect('verify_timestamp_trust' in parsed.verify).toBe(false); + expect('ocsp_fetch' in parsed.verify).toBe(false); + expect('remote_manifest_fetch' in parsed.verify).toBe(false); + }); + + it('does not include undefined values when merging settings', () => { + const settings1: Settings = { + cawgTrust: { + verifyTrustList: true, + userAnchors: 'test' + } + }; + + const settings2: Settings = { + cawgTrust: { + verifyTrustList: true, + allowedList: undefined + }, + verify: { + verifyAfterReading: false + } + }; + + const merged = mergeSettings(settings1, settings2); + const json = settingsToJson(merged); + const parsed = JSON.parse(json); + + expect(parsed.cawg_trust.verify_trust_list).toBe(true); + expect(parsed.cawg_trust.user_anchors).toBe('test'); + expect('allowed_list' in parsed.cawg_trust).toBe(false); + expect(parsed.verify.verify_after_reading).toBe(false); + }); +}); + +describe('loadSettingsFromUrl', () => { + it('loads settings from a URL', async () => { + const mockSettings = JSON.stringify({ + verify: { verify_after_reading: true } + }); + server.use( + http.get('http://settingsDoc', () => HttpResponse.text(mockSettings)) + ); + + const loaded = await loadSettingsFromUrl('http://settingsDoc'); + expect(loaded).toBe(mockSettings); + }); + + it('throws error for failed fetch', async () => { + server.use( + http.get( + 'http://settingsDocMissing', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }) + ) + ); + + await expect( + loadSettingsFromUrl('http://settingsDocMissing') + ).rejects.toThrow('Failed to fetch http://settingsDocMissing: 404 Not Found'); + }); + + it('throws error for network failure', async () => { + server.use( + http.get('http://settingsDocNetworkError', () => HttpResponse.error()) + ); + + await expect( + loadSettingsFromUrl('http://settingsDocNetworkError') + ).rejects.toThrow(); + }); + + it('retries a transient 500 and returns the settings once it recovers', async () => { + const mockSettings = JSON.stringify({ + verify: { verify_after_reading: true } + }); + server.use( + http.get( + 'http://settingsDocTransient500', + () => new HttpResponse(null, { status: 500 }), + { once: true } + ), + http.get('http://settingsDocTransient500', () => + HttpResponse.text(mockSettings) + ) + ); + + const loaded = await loadSettingsFromUrl('http://settingsDocTransient500'); + expect(loaded).toBe(mockSettings); + }); + + it('honors a custom retry policy via options', async () => { + let requestCount = 0; + server.use( + http.get('http://settingsDocCustomRetries', () => { + requestCount++; + return new HttpResponse(null, { status: 500 }); + }) + ); + + await expect( + loadSettingsFromUrl('http://settingsDocCustomRetries', { + maxRetries: 0 + }) + ).rejects.toThrow('Failed to fetch http://settingsDocCustomRetries: 500'); + expect(requestCount).toBe(1); + }); +}); diff --git a/packages/c2pa-utilities/src/settings.ts b/packages/c2pa-utilities/src/settings.ts new file mode 100644 index 00000000..052bbe32 --- /dev/null +++ b/packages/c2pa-utilities/src/settings.ts @@ -0,0 +1,371 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ + +import { merge } from 'ts-deepmerge'; +import { + fetchWithRetry, + fetchWithRetryRaw, + type FetchWithRetryOptions +} from './fetchWithRetry.js'; +import { snakeCaseify, type SettingsObjectType } from './caseConversion.js'; + +// ================================= +// Types +// ================================= + +/** + * Settings configuration for C2PA Reader and Builder operations. + * + * @example + * ```typescript + * const settings: Settings = { + * verify: { + * verifyTrust: true, + * verifyAfterReading: true + * }, + * trust: { + * trustAnchors: 'https://example.com/anchors.pem' + * } + * }; + * + * const settingsJson = await resolveSettings(settings, undefined); + * ``` + */ +export interface Settings { + /** + * Trust configuration for C2PA claim validation. + */ + trust?: TrustSettings; + /** + * Trust configuration for CAWG identity validation. + */ + cawgTrust?: TrustSettings; + /** + * Verification settings. + */ + verify?: VerifySettings; + /** + * Builder settings. + */ + builder?: BuilderSettings; +} + +export interface TrustSettings { + /** + * "User" trust anchors. Any asset validated off of this trust list will have a + * "signingCredential.trusted" result with an explanation noting the trust source is a "User" anchor. + * + * Possible values are: the text content of a .pem file, a URL to fetch a .pem file from, or an array of URLs that will be fetched and concatenated. + */ + userAnchors?: string | string[]; + /** + * "System" trust anchors. Any asset validated off of this trust list will have a + * "signingCredential.trusted" result with an explanation noting the trust source is a "System" anchor. + * + * Possible values are: the text content of a .pem file, a URL to fetch a .pem file from, or an array of URLs that will be fetched and concatenated. + */ + trustAnchors?: string | string[]; + /** + * Trust store + * + * Possible values are: the text content of a .cfg file, a URL to fetch a .cfg file from, or an array of URLs that will be fetched and concatenated. + */ + trustConfig?: string | string[]; + /** + * End-entity certificates. + * + * Possible values are: the text content of a end-entity cert file, a URL to fetch a end-entity cert file from, or an array of URLs that will be fetched and concatenated. + */ + allowedList?: string | string[]; + /** + * Enable CAWG trust validation. The default value is "true." + * + * Only has an effect when set on `cawgTrust` — mirrors `c2pa-rs`'s `Trust` struct, which is + * shared by both the `trust` and `cawg_trust` settings sections and carries this field on both. + */ + verifyTrustList?: boolean; +} + +export interface VerifySettings { + /** + * Enable trust validation. The default value is "true." + */ + verifyTrust?: boolean; + /** + * Whether to verify the manifest after reading in the Reader. The default value is "true." + */ + verifyAfterReading?: boolean; + /** + * Whether to verify the manifest after signing in the Builder. The default value is "false." + */ + verifyAfterSign?: boolean; + /** + * Whether to verify the timestamp certificates against the configured trust lists. The default value is "true." + */ + verifyTimestampTrust?: boolean; + /** + * Whether to fetch the certificate's OCSP status during validation. The default value is "false." + */ + ocspFetch?: boolean; + /** + * Whether to fetch remote manifests when constructing a Reader or adding an Ingredient. The default value is "true." + */ + remoteManifestFetch?: boolean; + /** + * Whether to skip ingredient conflict resolution when multiple ingredients share a manifest identifier. Only applicable to C2PA v2 validation. The default value is "false." + */ + skipIngredientConflictResolution?: boolean; + /** + * Whether to perform strict C2PA v1 validation instead of the latest validation. The default value is "false." + */ + strictV1Validation?: boolean; +} + +export interface BuilderSettings { + /** + * Whether to generate a C2PA archive (instead of zip) when writing the manifest builder. + * + * Note: `c2pa-rs` is deprecating the zip archive path — this setting is expected to be + * removed in a future release and should always be left `true`. + */ + generateC2paArchive: true; + /** + * Settings for automatic thumbnail generation. + */ + thumbnail?: { + /** + * Whether to automatically generate a thumbnail for the asset being built, if possible. + */ + enabled?: boolean; + }; +} + +// ================================= +// Defaults +// ================================= + +const DEFAULT_SETTINGS: Settings = { + builder: { + generateC2paArchive: true + } +}; + +// ================================= +// Top-level pipeline +// ================================= + +/** + * Resolves settings by merging override settings on top of base settings, resolving any embedded + * trust list URLs on top of those, and then finally serializing the result for consumption by the core native SDK. + * + * @param baseSettings Settings established at SDK initialization time. + * @param overrideSettings Optional override settings. Keys present in overrideSettings win over keys in baseSettings. + * @param options Optional configurations for fetch-with-retry. + * @returns A JSON-serialized string containing all resolved settings values, ready to be consumed by the core native SDK. + * Returns undefined when neither argument is provided. + */ +export async function resolveSettings( + baseSettings: Settings | undefined, + overrideSettings: Settings | undefined, + options?: FetchWithRetryOptions +): Promise { + const effectiveSettings = overrideSettings + ? merge(baseSettings ?? {}, overrideSettings) + : baseSettings; + + if (!effectiveSettings) { + return undefined; + } + + const finalSettings: Settings = merge(DEFAULT_SETTINGS, effectiveSettings); + + const resolvePromises: Promise[] = []; + + if (finalSettings.trust) { + resolvePromises.push(resolveTrustSettings(finalSettings.trust, options)); + } + + if (finalSettings.cawgTrust) { + resolvePromises.push( + resolveTrustSettings(finalSettings.cawgTrust, options) + ); + } + + // Wait for all trust list resolutions to complete. + await Promise.all(resolvePromises); + + return JSON.stringify(snakeCaseify(finalSettings as SettingsObjectType)); +} + +// ================================= +// Settings construction +// ================================= + +/** + * Create a Settings object with trust configuration. + * @param trustConfig The trust configuration. + * @returns Settings object that can be passed to Reader/Builder. + */ +export function createTrustSettings(trustConfig: TrustSettings): Settings { + return { trust: { ...trustConfig } }; +} + +/** + * Create a Settings object with CAWG trust configuration. + * @param trustConfig The CAWG trust configuration. + * @returns Settings object that can be passed to Reader/Builder. + */ +export function createCawgTrustSettings( + trustConfig: TrustSettings +): Settings { + return { cawgTrust: { ...trustConfig } }; +} + +/** + * Create a Settings object with verify configuration. + * @param verifyConfig The verify configuration. + * @returns Settings object that can be passed to Reader/Builder. + */ +export function createVerifySettings(verifyConfig: VerifySettings): Settings { + return { verify: { ...verifyConfig } }; +} + +/** + * Merge multiple Settings objects into one, deep-merging nested fields (e.g. + * `builder.thumbnail`) rather than overwriting whole sections. Later settings override + * earlier ones. + * + * @param settings Settings objects to merge. + * @returns Merged settings object. + */ +export function mergeSettings(...settings: Settings[]): Settings { + return merge(...settings); +} + +// ================================= +// Serialization +// ================================= + +/** + * Convert a settings object to a JSON string. + * Converts camelCase keys to snake_case to match the c2pa-rs settings format. + * @param settings The settings object + * @returns JSON string representation with snake_case keys + */ +export function settingsToJson(settings: Settings): string { + return JSON.stringify(snakeCaseify(settings as SettingsObjectType)); +} + +// ================================= +// Loading +// ================================= + +/** + * Load settings from a URL, retrying on network errors and retryable HTTP responses (see + * {@link fetchWithRetryRaw}). Unlike {@link resolveTrustSettings}'s trust-anchor fetch, this + * does not enforce a response-size cap or validate the content — it's meant for loading an + * app's own trusted configuration file, not untrusted resources embedded in a manifest. + * + * @param url The URL to fetch the settings from + * @param options Options for configuring the retry policy. + * @returns Settings as a string + */ +export async function loadSettingsFromUrl( + url: string, + options?: FetchWithRetryOptions +): Promise { + const res = await fetchWithRetryRaw(url, undefined, options); + return await res.text(); +} + +// ================================= +// Trust-anchor resolution +// ================================= + +/** + * The URL-resolvable trust-anchor fields on `TrustSettings`. Deliberately excludes + * `verifyTrustList`, which is a plain boolean, not a fetchable resource. + */ +type TrustAnchorKey = + | 'userAnchors' + | 'trustAnchors' + | 'trustConfig' + | 'allowedList'; + +const TRUST_SETTINGS_KEYS: readonly TrustAnchorKey[] = [ + 'userAnchors', + 'trustAnchors', + 'trustConfig', + 'allowedList' +]; + +/** + * Walks a TrustSettings object and fetches trust resources if necessary, replacing URLs with + * their fetched, validated values. Mutates `settings` in place. + * + * @param settings TrustSettings object, mutated in place. + * @param options Optional configurations for fetch-with-retry. + */ +export async function resolveTrustSettings( + settings: TrustSettings, + options?: FetchWithRetryOptions +): Promise { + const shouldValidateKey = (key: string): boolean => + ['userAnchors', 'trustAnchors'].includes(key); + + const containsCerts = (content: string): boolean => + content.includes('-----BEGIN CERTIFICATE-----'); + + const isUrl = (str: string): boolean => str.startsWith('http'); + + try { + const promises = Object.entries(settings) + .filter(([key]) => + TRUST_SETTINGS_KEYS.includes(key as TrustAnchorKey) + ) + .map(async ([key, val]) => { + if (val && typeof val === 'object' && Array.isArray(val)) { + const promises = val.map(async (val) => { + if (typeof val !== 'string') { + throw new Error('Expected a string value for array item'); + } + + const text = await fetchWithRetry(val, options); + + if (shouldValidateKey(key) && !containsCerts(text)) { + throw new Error(`Error parsing PEM file at: ${val}`); + } + + return text; + }); + + const result = await Promise.all(promises); + const combined = result.join(''); + settings[key as TrustAnchorKey] = combined; + } else if (val && typeof val === 'string' && isUrl(val)) { + const text = await fetchWithRetry(val, options); + + if (shouldValidateKey(key) && !containsCerts(text)) { + throw new Error(`Error parsing PEM file at: ${val}`); + } + + settings[key as TrustAnchorKey] = text; + } else { + return val; + } + }); + + await Promise.all(promises); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Failed to resolve trust settings. ${message}`, { + cause: e + }); + } +} diff --git a/packages/c2pa-utilities/tsconfig.json b/packages/c2pa-utilities/tsconfig.json new file mode 100644 index 00000000..62ebbd94 --- /dev/null +++ b/packages/c2pa-utilities/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/c2pa-utilities/tsconfig.lib.json b/packages/c2pa-utilities/tsconfig.lib.json new file mode 100644 index 00000000..a55f8d3c --- /dev/null +++ b/packages/c2pa-utilities/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2023", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/packages/c2pa-utilities/tsconfig.spec.json b/packages/c2pa-utilities/tsconfig.spec.json new file mode 100644 index 00000000..0b4b3a51 --- /dev/null +++ b/packages/c2pa-utilities/tsconfig.spec.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": ["vitest/globals"], + "lib": ["ES2023", "DOM"], + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": [ + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/c2pa-utilities/vitest.config.ts b/packages/c2pa-utilities/vitest.config.ts new file mode 100644 index 00000000..283cf183 --- /dev/null +++ b/packages/c2pa-utilities/vitest.config.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2026 Adobe + * All Rights Reserved. + * + * NOTICE: Adobe permits you to use, modify, and distribute this file in + * accordance with the terms of the Adobe license agreement accompanying + * it. + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + exclude: ['node_modules', 'dist'] + } +}); diff --git a/packages/c2pa-web/package.json b/packages/c2pa-web/package.json index decfac40..37725920 100644 --- a/packages/c2pa-web/package.json +++ b/packages/c2pa-web/package.json @@ -55,9 +55,9 @@ }, "dependencies": { "@contentauth/c2pa-types": "workspace:*", + "@contentauth/c2pa-utilities": "workspace:*", "@contentauth/c2pa-wasm": "workspace:*", - "highgain": "^0.1.0", - "ts-deepmerge": "^8.0.0" + "highgain": "^0.1.0" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/packages/c2pa-web/src/common.ts b/packages/c2pa-web/src/common.ts index de30c658..d248f4c8 100644 --- a/packages/c2pa-web/src/common.ts +++ b/packages/c2pa-web/src/common.ts @@ -24,13 +24,5 @@ export { READER_SUPPORTED_FORMATS } from './lib/supportedFormats.js'; -export type { - Settings, - VerifySettings, - TrustSettings, - BuilderSettings, - CawgTrustSettings -} from './lib/settings.js'; - // Re-export types from c2pa-types for convenience. export type * from '@contentauth/c2pa-types'; diff --git a/packages/c2pa-web/src/index.ts b/packages/c2pa-web/src/index.ts index 6ece963b..35a64f2f 100644 --- a/packages/c2pa-web/src/index.ts +++ b/packages/c2pa-web/src/index.ts @@ -7,19 +7,7 @@ * it. */ -/** - * Creates a new instance of c2pa-web by setting up a web worker and preparing a WASM binary. - * - * @param config - SDK configuration object. - * @returns An object providing access to factory methods for creating new reader objects. - * - * @example Creating a new SDK instance and reader: - * ``` - * const c2pa = await createC2pa({ wasmSrc: 'url/hosting/wasm/binary' }); - * - * const reader = await c2pa.reader.fromBlob(imageBlob.type, imageBlob); - * ``` - */ -export { createC2pa } from './lib/c2pa.js'; +export { createC2pa } from './lib/c2pa.js'; export * from './common.js'; +export * from '@contentauth/c2pa-utilities'; diff --git a/packages/c2pa-web/src/lib/builder.spec.ts b/packages/c2pa-web/src/lib/builder.spec.ts index 18c862da..d1168a18 100644 --- a/packages/c2pa-web/src/lib/builder.spec.ts +++ b/packages/c2pa-web/src/lib/builder.spec.ts @@ -10,7 +10,7 @@ import { test, describe, expect } from 'test/methods.js'; import { ManifestDefinition, Ingredient, Action } from '@contentauth/c2pa-types'; import { getBlobForAsset, createTestSigner } from 'test/utils.js'; -import { Settings } from './settings.js'; +import { Settings } from '@contentauth/c2pa-utilities'; import { createC2pa } from './c2pa.js'; import wasmSrc from '@contentauth/c2pa-web/resources/c2pa.wasm?url'; diff --git a/packages/c2pa-web/src/lib/builder.ts b/packages/c2pa-web/src/lib/builder.ts index d93e6fba..dcee3ef8 100644 --- a/packages/c2pa-web/src/lib/builder.ts +++ b/packages/c2pa-web/src/lib/builder.ts @@ -17,7 +17,7 @@ import type { Ingredient, ManifestDefinition } from '@contentauth/c2pa-types'; -import { Settings, resolveSettings } from './settings.js'; +import { Settings, resolveSettings } from '@contentauth/c2pa-utilities'; /** * Functions that permit the creation of Builder objects. diff --git a/packages/c2pa-web/src/lib/c2pa.ts b/packages/c2pa-web/src/lib/c2pa.ts index f62f93dc..fe221822 100644 --- a/packages/c2pa-web/src/lib/c2pa.ts +++ b/packages/c2pa-web/src/lib/c2pa.ts @@ -9,7 +9,7 @@ import { createWorkerManager } from './worker/workerManager.js'; import { createReaderFactory, ReaderFactory } from './reader.js'; import { WASM_SRI } from '@contentauth/c2pa-wasm'; -import { Settings, resolveSettings } from './settings.js'; +import { Settings, resolveSettings } from '@contentauth/c2pa-utilities'; import { BuilderFactory, createBuilderFactory } from './builder.js'; export interface Config { @@ -51,6 +51,19 @@ export interface C2paSdk { dispose: () => void; } +/** + * Creates a new instance of c2pa-web by setting up a web worker and preparing a WASM binary. + * + * @param config - SDK configuration object. + * @returns An object providing access to factory methods for creating new reader objects. + * + * @example Creating a new SDK instance and reader: + * ``` + * const c2pa = await createC2pa({ wasmSrc: 'url/hosting/wasm/binary' }); + * + * const reader = await c2pa.reader.fromBlob(imageBlob.type, imageBlob); + * ``` + */ export async function createC2pa(config: Config): Promise { const { wasmSrc, workerSrc, settings } = config; diff --git a/packages/c2pa-web/src/lib/reader.spec.ts b/packages/c2pa-web/src/lib/reader.spec.ts index 5e44eac3..b46647f7 100644 --- a/packages/c2pa-web/src/lib/reader.spec.ts +++ b/packages/c2pa-web/src/lib/reader.spec.ts @@ -9,7 +9,7 @@ import { test, describe, expect } from 'test/methods.js'; import { createC2pa } from './c2pa.js'; -import { Settings } from './settings.js'; +import { Settings } from '@contentauth/c2pa-utilities'; import { getBlobForAsset } from 'test/utils.js'; import wasmSrc from '@contentauth/c2pa-web/resources/c2pa.wasm?url'; diff --git a/packages/c2pa-web/src/lib/reader.ts b/packages/c2pa-web/src/lib/reader.ts index f7d010d4..4530e1f6 100644 --- a/packages/c2pa-web/src/lib/reader.ts +++ b/packages/c2pa-web/src/lib/reader.ts @@ -11,7 +11,7 @@ import { Manifest, ManifestStore } from '@contentauth/c2pa-types'; import { AssetTooLargeError, UnsupportedFormatError } from './error.js'; import { isSupportedReaderFormat } from './supportedFormats.js'; import type { WorkerManager } from './worker/workerManager.js'; -import { Settings, resolveSettings } from './settings.js'; +import { Settings, resolveSettings } from '@contentauth/c2pa-utilities'; // 1 GB export const MAX_SIZE_IN_BYTES = 10 ** 9; diff --git a/packages/c2pa-web/src/lib/settings.spec.ts b/packages/c2pa-web/src/lib/settings.spec.ts deleted file mode 100644 index 5d817e73..00000000 --- a/packages/c2pa-web/src/lib/settings.spec.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Copyright 2025 Adobe - * All Rights Reserved. - * - * NOTICE: Adobe permits you to use, modify, and distribute this file in - * accordance with the terms of the Adobe license agreement accompanying - * it. - */ - -import { test, describe, expect } from 'test/methods.js'; -import { http, HttpResponse } from 'msw'; -import { resolveSettings, MAX_RESPONSE_SIZE, TRUST_MAX_RETRY_AFTER_MS } from './settings.js'; - -describe('settings', () => { - describe('resolveSettings', () => { - describe('general behavior', () => { - test('should return undefined when neither argument is provided', async () => { - const result = await resolveSettings(undefined, undefined); - expect(result).toBeUndefined(); - }); - - test('should serialize base settings when only base is provided', async () => { - const result = await resolveSettings({ verify: { verifyTrust: false } }, undefined); - expect(result).toEqual( - JSON.stringify({ builder: { generate_c2pa_archive: true }, verify: { verify_trust: false } }) - ); - }); - - test('should serialize override settings when only override is provided', async () => { - const result = await resolveSettings(undefined, { verify: { verifyTrust: false } }); - expect(result).toEqual( - JSON.stringify({ builder: { generate_c2pa_archive: true }, verify: { verify_trust: false } }) - ); - }); - - test('should accept an empty object as override', async () => { - const result = await resolveSettings(undefined, {}); - expect(result).toEqual( - JSON.stringify({ builder: { generate_c2pa_archive: true } }) - ); - }); - - test('should merge override settings on top of base settings', async () => { - const base = { - verify: { verifyTrust: true, verifyAfterReading: true } - }; - const override = { - verify: { verifyTrust: false } - }; - - const result = await resolveSettings(base, override); - - // verifyTrust from override wins; verifyAfterReading from base is preserved - expect(result).toEqual( - JSON.stringify({ - builder: { generate_c2pa_archive: true }, - verify: { verify_trust: false, verify_after_reading: true } - }) - ); - }); - - test('should preserve base settings keys not present in override', async () => { - const base = { - verify: { verifyAfterReading: false }, - builder: { generateC2paArchive: false } - }; - const override = { - verify: { verifyTrust: true } - }; - - const result = await resolveSettings(base, override); - - expect(result).toEqual( - JSON.stringify({ - builder: { generate_c2pa_archive: false }, - verify: { verify_after_reading: false, verify_trust: true } - }) - ); - }); - - test('should not throw when a settings value is null', async () => { - // typeof null === 'object' in JS — without a null guard this crashes - const result = await resolveSettings(undefined, { verify: null as any }); - expect(result).toEqual( - JSON.stringify({ builder: { generate_c2pa_archive: true }, verify: null }) - ); - }); - - test('should not throw when a nested settings value is null', async () => { - const result = await resolveSettings(undefined, { trust: { userAnchors: null as any } }); - expect(result).toEqual( - JSON.stringify({ - builder: { generate_c2pa_archive: true }, - trust: { user_anchors: null } - }) - ); - }); - }); - - describe('trust', () => { - test('should pass through a non-url value', async () => { - const result = await resolveSettings(undefined, { - trust: { - userAnchors: 'foo', - trustAnchors: 'bar', - allowedList: 'baz', - trustConfig: 'qux' - }, - cawgTrust: { - userAnchors: 'cawg foo', - trustAnchors: 'cawg bar', - allowedList: 'cawg baz', - trustConfig: 'cawg qux' - } - }); - - expect(result).toEqual( - JSON.stringify({ - builder: { generate_c2pa_archive: true }, - trust: { - user_anchors: 'foo', - trust_anchors: 'bar', - allowed_list: 'baz', - trust_config: 'qux' - }, - cawg_trust: { - user_anchors: 'cawg foo', - trust_anchors: 'cawg bar', - allowed_list: 'cawg baz', - trust_config: 'cawg qux' - } - }) - ); - }); - - test('should fetch URL trust values', async ({ requestMock }) => { - requestMock.use( - ...[ - http.get('http://userAnchors', () => - HttpResponse.text( - '-----BEGIN CERTIFICATE-----foo-----END CERTIFICATE-----' - ) - ), - http.get('http://trustAnchors', () => - HttpResponse.text( - '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----' - ) - ), - http.get('http://allowedList', () => HttpResponse.text('allowed')), - http.get('http://trustConfig', () => HttpResponse.text('config')) - ] - ); - - const result = await resolveSettings(undefined, { - trust: { - userAnchors: 'http://userAnchors', - trustAnchors: 'http://trustAnchors', - allowedList: 'http://allowedList', - trustConfig: 'http://trustConfig' - }, - cawgTrust: { - userAnchors: 'http://userAnchors', - trustAnchors: 'http://trustAnchors', - allowedList: 'http://allowedList', - trustConfig: 'http://trustConfig' - } - }); - - expect(result).toEqual( - JSON.stringify({ - builder: { generate_c2pa_archive: true }, - trust: { - user_anchors: - '-----BEGIN CERTIFICATE-----foo-----END CERTIFICATE-----', - trust_anchors: - '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----', - allowed_list: 'allowed', - trust_config: 'config' - }, - cawg_trust: { - user_anchors: - '-----BEGIN CERTIFICATE-----foo-----END CERTIFICATE-----', - trust_anchors: - '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----', - allowed_list: 'allowed', - trust_config: 'config' - } - }) - ); - }); - - test('should fetch URL trust values from base settings', async ({ requestMock }) => { - requestMock.use( - http.get('http://baseTrustAnchors', () => - HttpResponse.text( - '-----BEGIN CERTIFICATE-----base-----END CERTIFICATE-----' - ) - ) - ); - - // URL in base settings should be fetched even when override is also present. - const result = await resolveSettings( - { trust: { trustAnchors: 'http://baseTrustAnchors' } }, - { verify: { verifyTrust: true } } - ); - - expect(result).toContain('-----BEGIN CERTIFICATE-----base-----END CERTIFICATE-----'); - }); - - test('should concatenate the fetched results of URLs when given as an array', async ({ - requestMock - }) => { - requestMock.use( - http.get('http://userAnchorsConcat', () => - HttpResponse.text( - '-----BEGIN CERTIFICATE-----qux-----END CERTIFICATE-----' - ) - ) - ); - - const result = await resolveSettings(undefined, { - trust: { - userAnchors: [ - 'http://userAnchorsConcat', - 'http://userAnchorsConcat' - ] - } - }); - - expect(result).toEqual( - JSON.stringify({ - builder: { generate_c2pa_archive: true }, - trust: { - user_anchors: - '-----BEGIN CERTIFICATE-----qux-----END CERTIFICATE----------BEGIN CERTIFICATE-----qux-----END CERTIFICATE-----' - } - }) - ); - }); - - test('should report an error when fetching a URL without a certificate', async ({ - requestMock - }) => { - requestMock.use( - http.get('http://userAnchorsShouldFail', () => - HttpResponse.text('invalid') - ) - ); - - const resultPromise = resolveSettings(undefined, { - trust: { - userAnchors: 'http://userAnchorsShouldFail' - } - }); - - await expect(resultPromise).rejects.toThrow( - 'Failed to resolve trust settings.' - ); - }); - - test('should report a meaningful error when a trust URL returns a non-OK HTTP response', async ({ - requestMock - }) => { - requestMock.use( - http.get('http://userAnchors429', () => - new HttpResponse(null, { status: 429, statusText: 'Too Many Requests' }) - ) - ); - - const resultPromise = resolveSettings(undefined, { - trust: { - userAnchors: 'http://userAnchors429' - } - }); - - await expect(resultPromise).rejects.toThrow( - 'Failed to fetch http://userAnchors429: 429' - ); - }); - - test('should respect a reasonable Retry-After header and retry', async ({ - requestMock - }) => { - let requestCount = 0; - requestMock.use( - http.get('http://userAnchorsRetryAfter', () => { - requestCount++; - if (requestCount === 1) { - return new HttpResponse(null, { - status: 429, - headers: { 'Retry-After': '1' } - }); - } - return HttpResponse.text( - '-----BEGIN CERTIFICATE-----retryAfter-----END CERTIFICATE-----' - ); - }) - ); - - const result = await resolveSettings(undefined, { - trust: { - userAnchors: 'http://userAnchorsRetryAfter' - } - }); - - expect(result).toContain('BEGIN CERTIFICATE-----retryAfter'); - }); - - test('should fail immediately when Retry-After exceeds the maximum allowed delay', async ({ - requestMock - }) => { - const tooLongSeconds = TRUST_MAX_RETRY_AFTER_MS / 1000 + 1; - requestMock.use( - http.get( - 'http://userAnchorsRetryAfterTooLong', - () => - new HttpResponse(null, { - status: 429, - headers: { 'Retry-After': String(tooLongSeconds) } - }) - ) - ); - - const resultPromise = resolveSettings(undefined, { - trust: { - userAnchors: 'http://userAnchorsRetryAfterTooLong' - } - }); - - await expect(resultPromise).rejects.toThrow( - 'exceeds the maximum allowed delay' - ); - }); - - test('should recover after a transient 500 by retrying', async ({ - requestMock - }) => { - requestMock.use( - http.get( - 'http://userAnchorsTransient500', - () => new HttpResponse(null, { status: 500 }), - { once: true } - ), - http.get('http://userAnchorsTransient500', () => - HttpResponse.text( - '-----BEGIN CERTIFICATE-----baz-----END CERTIFICATE-----' - ) - ) - ); - - const result = await resolveSettings(undefined, { - trust: { - userAnchors: 'http://userAnchorsTransient500' - } - }); - - expect(result).toContain('BEGIN CERTIFICATE-----baz'); - }); - - test('should not fetch URLs for unknown keys not defined in TrustSettings', async ({ - requestMock - }) => { - let unknownKeyFetched = false; - requestMock.use( - http.get('http://unknownKey', () => { - unknownKeyFetched = true; - return HttpResponse.text('should not be fetched'); - }), - http.get('http://trustAnchors', () => - HttpResponse.text( - '-----BEGIN CERTIFICATE-----bar-----END CERTIFICATE-----' - ) - ) - ); - - const result = await resolveSettings(undefined, { - trust: { - trustAnchors: 'http://trustAnchors', - ...(({ unknownKey: 'http://unknownKey' }) as any) - } - }); - - expect(unknownKeyFetched).toBe(false); - expect(result).toContain('trust_anchors'); - }); - - test('should not crash when a CawgTrustSettings boolean field is present', async () => { - const resultPromise = resolveSettings(undefined, { - cawgTrust: { - verifyTrustList: true - } - }); - - await expect(resultPromise).resolves.not.toThrow(); - }); - - test('should throw when a fetched response exceeds the size limit', async ({ - requestMock - }) => { - const oversizedBody = 'x'.repeat(MAX_RESPONSE_SIZE + 1); - requestMock.use( - http.get('http://oversized', () => HttpResponse.text(oversizedBody)) - ); - - const resultPromise = resolveSettings(undefined, { - trust: { - trustConfig: 'http://oversized' - } - }); - - await expect(resultPromise).rejects.toThrow( - 'Failed to resolve trust settings.' - ); - }); - }); - }); -}); diff --git a/packages/c2pa-web/src/lib/settings.ts b/packages/c2pa-web/src/lib/settings.ts deleted file mode 100644 index 4d75846f..00000000 --- a/packages/c2pa-web/src/lib/settings.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Copyright 2025 Adobe - * All Rights Reserved. - * - * NOTICE: Adobe permits you to use, modify, and distribute this file in - * accordance with the terms of the Adobe license agreement accompanying - * it. - */ - -import { merge } from 'ts-deepmerge'; - -/** - * Settings configuration for C2PA operations. - * - * Encapsulates settings and configuration options for Reader and Builder operations. - * It provides a flexible way to configure SDK behavior including the verification configuration, - * trust configuration, and builder options. - * - * @example - * ```typescript - * const context: Context = { - * verify: { - * verifyTrust: true, - * verifyAfterReading: true - * }, - * trust: { - * trustAnchors: 'https://example.com/anchors.pem' - * } - * }; - * - * const reader = await c2pa.reader.fromBlob(blob.type, blob, JSON.stringify(context)); - * ``` - */ -export interface Settings { - /** - * Trust configuration for C2PA claim validation. - */ - trust?: TrustSettings; - /** - * Trust configuration for CAWG identity validation. - */ - cawgTrust?: CawgTrustSettings; - /** - * Verification settings. - */ - verify?: VerifySettings; - /** - * Builder settings. - */ - builder?: BuilderSettings; -} - -export interface TrustSettings { - /** - * "User" trust anchors. Any asset validated off of this trust list will will have a "signingCredential.trusted" result with an explanation noting the trust source is a "User" anchor. - * - * Possible values are: the text content of a .pem file, a URL to fetch a .pem file from, or an array of URLs that will be fetched and concatenated. - */ - userAnchors?: string | string[]; - /** - * "System" trust anchors. Any asset validated off of this trust list will will have a "signingCredential.trusted" result with an explanation noting the trust source is a "System" anchor. - * - * Possible values are: the text content of a .pem file, a URL to fetch a .pem file from, or an array of URLs that will be fetched and concatenated. - */ - trustAnchors?: string | string[]; - /** - * Trust store - * - * Possible values are: the text content of a .cfg file, a URL to fetch a .cfg file from, or an array of URLs that will be fetched and concatenated. - */ - trustConfig?: string | string[]; - /** - * End-entity certificates. - * - * Possible values are: the text content of a end-entity cert file, a URL to fetch a end-entity cert file from, or an array of URLs that will be fetched and concatenated. - */ - allowedList?: string | string[]; -} - -const TRUST_SETTINGS_KEY_MAP: Record = { - userAnchors: true, - trustAnchors: true, - trustConfig: true, - allowedList: true -}; -const TRUST_SETTINGS_KEYS = Object.keys(TRUST_SETTINGS_KEY_MAP) as (keyof TrustSettings)[]; - -export interface CawgTrustSettings extends TrustSettings { - /** - * Enable CAWG trust validation. The default value is "true." - */ - verifyTrustList?: boolean; -} - -export interface VerifySettings { - /** - * Enable trust validation. The default value is "true." - */ - verifyTrust?: boolean; - /* - * Whether to verify the manifest after reading in the Reader. The default value is "true." - */ - verifyAfterReading?: boolean; -} - -export interface BuilderSettings { - /** - * Whether to generate a C2PA archive (instead of zip) when writing the manifest builder. - */ - generateC2paArchive?: boolean; -} - -type SettingsObjectType = { - [k: string]: string | boolean | SettingsObjectType; -}; - -const DEFAULT_SETTINGS: Settings = { - builder: { - generateC2paArchive: true - } -}; - -export const MAX_RESPONSE_SIZE = 1 * 1024 * 1024; // 1MB - -/** - * Resolves settings by merging override settings on top of base settings, resolving any embedded - * trust list URLs on top of those, and then finally serializing the result for consumption by c2pa-rs. - * - * @param baseSettings Settings established at SDK initialization time. - * @param overrideSettings Optional override settings. Keys present in overrideSettings win over keys in baseSettings. - * @returns A JSON-serialized string containing all resolved settings values, ready to be consumed by c2pa-rs. - * Returns undefined when neither argument is provided. - */ -export async function resolveSettings( - baseSettings: Settings | undefined, - overrideSettings: Settings | undefined -): Promise { - const effectiveSettings = overrideSettings - ? merge(baseSettings ?? {}, overrideSettings) - : baseSettings; - - if (!effectiveSettings) { - return undefined; - } - - const finalSettings: Settings = merge(DEFAULT_SETTINGS, effectiveSettings); - - const resolvePromises: Promise[] = []; - - if (finalSettings.trust) { - resolvePromises.push(resolveTrustSettings(finalSettings.trust)); - } - - if (finalSettings.cawgTrust) { - resolvePromises.push(resolveTrustSettings(finalSettings.cawgTrust)); - } - - // Wait for all trust list resolutions to complete. - await Promise.all(resolvePromises); - - return JSON.stringify(snakeCaseify(finalSettings as SettingsObjectType)); -} - -function snakeCaseify(object: SettingsObjectType): SettingsObjectType { - const formattedObject = Object.entries(object).reduce( - (formattedObject, [key, val]) => { - formattedObject[snakeCase(key)] = - typeof val === 'object' && val !== null ? snakeCaseify(val) : val; - return formattedObject; - }, - {} as SettingsObjectType - ); - - return formattedObject; -} - -function snakeCase(str: string): string { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); -} - -/** - * Walks a TrustSettings object and fetches trust resources if necessary, replacing URLs with their fetched values. - * - * @param settings TrustSettings object - */ -async function resolveTrustSettings(settings: TrustSettings): Promise { - try { - const promises = Object.entries(settings) - .filter(([key]) => TRUST_SETTINGS_KEYS.includes(key as keyof TrustSettings)) - .map(async ([key, val]) => { - if (val && typeof val === 'object' && Array.isArray(val)) { - const promises = val.map(async (val) => { - if (typeof val !== 'string') { - throw new Error('Expected a string value for array item'); - } - - const text = await fetchResource(val); - - if (shouldValidateKey(key) && !containsCerts(text)) { - throw new Error(`Error parsing PEM file at: ${val}`); - } - - return text; - }); - - const result = await Promise.all(promises); - const combined = result.join(''); - settings[key as keyof TrustSettings] = combined; - } else if (val && typeof val === 'string' && isUrl(val)) { - const text = await fetchResource(val); - - if (shouldValidateKey(key) && !containsCerts(text)) { - throw new Error(`Error parsing PEM file at: ${val}`); - } - - settings[key as keyof TrustSettings] = text; - } else { - return val; - } - }); - - await Promise.all(promises); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - throw new Error(`Failed to resolve trust settings. ${message}`, { cause: e }); - } -} - -const shouldValidateKey = (key: string): boolean => - ['userAnchors', 'trustAnchors'].includes(key); - -const containsCerts = (content: string): boolean => - content.includes('-----BEGIN CERTIFICATE-----'); - -const isUrl = (str: string): boolean => str.startsWith('http'); - -const TRUST_FETCH_RETRIES = 2; -const TRUST_INITIAL_RETRY_DELAY_MS = 200; -const TRUST_MAX_RETRY_DELAY_MS = 2_000; -export const TRUST_MAX_RETRY_AFTER_MS = 30_000; - -function calculateBackoffMs(attempt: number): number { - const backoff = Math.min( - TRUST_INITIAL_RETRY_DELAY_MS * 2 ** attempt, - TRUST_MAX_RETRY_DELAY_MS - ); - const jitter = Math.floor(Math.random() * 200); - return Math.min(backoff + jitter, TRUST_MAX_RETRY_DELAY_MS); // jitter, capped -} - -/** - * Parses a `Retry-After` header value, which per HTTP spec is either a number of - * seconds or an HTTP date. Returns the delay in milliseconds, or null if the header - * is absent or unparseable. - */ -function parseRetryAfterMs(value: string | null): number | null { - if (!value) { - return null; - } - - const seconds = Number(value); - if (!Number.isNaN(seconds)) { - return seconds * 1000; - } - - const dateMs = Date.parse(value); - if (Number.isNaN(dateMs)) { - return null; - } - - return dateMs - Date.now(); -} - -async function fetchResource(url: string): Promise { - for (let attempt = 0; ; attempt++) { - let res: Response; - try { - res = await fetch(url); - } catch (e) { - if (attempt < TRUST_FETCH_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, calculateBackoffMs(attempt))); - continue; - } - const message = e instanceof Error ? e.message : String(e); - throw new Error(`Network error fetching ${url}: ${message}`, { cause: e }); - } - - if (!res.ok) { - const retryable = res.status === 429 || res.status >= 500; - - if (retryable) { - if (res.status === 429) { - const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); - if (retryAfterMs !== null) { - if (retryAfterMs > TRUST_MAX_RETRY_AFTER_MS) { - throw new Error( - `Failed to fetch ${url}: server requested a Retry-After delay of ` + - `${Math.ceil(retryAfterMs / 1000)}s, which exceeds the maximum allowed delay of ` + - `${TRUST_MAX_RETRY_AFTER_MS / 1000}s` - ); - } - - if (attempt < TRUST_FETCH_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfterMs, 0))); - continue; - } - - throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); - } - } - - if (attempt < TRUST_FETCH_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, calculateBackoffMs(attempt))); - continue; - } - } - - throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); - } - - const text = await res.text(); - - if (text.length > MAX_RESPONSE_SIZE) { - throw new Error(`Response from ${url} is too large. Max size is ${MAX_RESPONSE_SIZE} bytes.`); - } - - return text; - } -} diff --git a/packages/c2pa-web/tsconfig.json b/packages/c2pa-web/tsconfig.json index c3670b73..dacfa7c2 100644 --- a/packages/c2pa-web/tsconfig.json +++ b/packages/c2pa-web/tsconfig.json @@ -6,6 +6,9 @@ { "path": "../c2pa-types" }, + { + "path": "../c2pa-utilities" + }, { "path": "./tsconfig.lib.json" }, diff --git a/packages/c2pa-web/tsconfig.lib.json b/packages/c2pa-web/tsconfig.lib.json index 01f8135a..9b841b85 100644 --- a/packages/c2pa-web/tsconfig.lib.json +++ b/packages/c2pa-web/tsconfig.lib.json @@ -12,6 +12,9 @@ }, "include": ["src/**/*.ts"], "references": [ + { + "path": "../c2pa-utilities/tsconfig.lib.json" + }, { "path": "../c2pa-types" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbd2bcad..3968fc6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: packages/c2pa-node: dependencies: + '@contentauth/c2pa-utilities': + specifier: workspace:* + version: link:../c2pa-utilities cargo-cp-artifact: specifier: ^0.1.9 version: 0.1.9 @@ -220,6 +223,22 @@ importers: specifier: ^8.8.0 version: 8.8.5 + packages/c2pa-utilities: + dependencies: + ts-deepmerge: + specifier: ^8.0.0 + version: 8.0.0 + devDependencies: + msw: + specifier: ^2.12.1 + version: 2.12.10(@types/node@22.20.1)(typescript@5.9.3) + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.20.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.9.0) + packages/c2pa-wasm: {} packages/c2pa-web: @@ -227,15 +246,15 @@ importers: '@contentauth/c2pa-types': specifier: workspace:* version: link:../c2pa-types + '@contentauth/c2pa-utilities': + specifier: workspace:* + version: link:../c2pa-utilities '@contentauth/c2pa-wasm': specifier: workspace:* version: link:../c2pa-wasm highgain: specifier: ^0.1.0 version: 0.1.0 - ts-deepmerge: - specifier: ^8.0.0 - version: 8.0.0 devDependencies: '@playwright/test': specifier: ^1.60.0 @@ -11290,6 +11309,26 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@3.2.4(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(playwright@1.60.0)(vite@6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(vite@6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.20.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.9.0) + ws: 8.19.0 + optionalDependencies: + playwright: 1.60.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/coverage-v8@3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 @@ -11346,6 +11385,15 @@ snapshots: msw: 2.12.10(@types/node@22.20.1)(typescript@5.8.3) vite: 6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0) + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(vite@6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@22.20.1)(typescript@5.9.3) + vite: 6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -11375,7 +11423,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.20.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(msw@2.12.10(@types/node@22.20.1)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.20)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(msw@2.12.10(@types/node@22.19.20)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -14424,6 +14472,31 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@22.20.1) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + muggle-string@0.4.1: {} mute-stream@2.0.0: {} @@ -16049,6 +16122,51 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.20.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(vite@6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.20.1 + '@vitest/browser': 3.2.4(msw@2.12.10(@types/node@22.20.1)(typescript@5.9.3))(playwright@1.60.0)(vite@6.4.1(@types/node@22.20.1)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@3.2.4) + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 22.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vscode-uri@3.1.0: {} w3c-xmlserializer@4.0.0: diff --git a/tsconfig.json b/tsconfig.json index c0bbdc6e..aae77fd2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,9 @@ }, { "path": "./packages/c2pa-types" + }, + { + "path": "./packages/c2pa-utilities" } ] }