Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/system-record-protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,14 @@ jobs:
- name: Test protocol evidence
run: pnpm test:issue-2052:system-records

- name: Test system-record protocol core
run: pnpm --filter @origintrail-official/dkg-core test:system-record

- name: Smoke-test published system-record subpath
run: pnpm --filter @origintrail-official/dkg-core test:system-record-export

- name: Test exhaustive system-record inventory publication
run: pnpm --filter @origintrail-official/dkg-core test:system-record-exhaustive

- name: Characterize committed evidence
run: pnpm characterize:issue-2052:system-records
108 changes: 72 additions & 36 deletions docs/adr/0002-system-record-sync-v1.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@
"import": "./dist/project-ontology.js",
"default": "./dist/project-ontology.js"
},
"./system-record-v1": {
Comment thread
Jurij89 marked this conversation as resolved.
"types": "./dist/system-record-v1.d.ts",
"import": "./dist/system-record-v1.js",
"default": "./dist/system-record-v1.js"
},
"./dist/*": "./dist/*",
"./package.json": "./package.json"
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"test": "pnpm run test:baseline && pnpm run test:system-record",
"test:baseline": "vitest run --exclude 'test/system-record-*.test.ts' --maxWorkers=4",
"test:system-record": "vitest run test/system-record-applied-state-v1.test.ts test/system-record-golden-v1.test.ts test/system-record-inventory-v1.test.ts test/system-record-limits-v1.test.ts test/system-record-objects-v1.test.ts test/system-record-wire-v1.test.ts --maxWorkers=1 --no-file-parallelism",
"test:system-record-export": "node test/system-record-package-export-v1.mjs",
"test:system-record-exhaustive": "DKG_SYSTEM_RECORD_EXHAUSTIVE=1 vitest run test/system-record-inventory-v1.test.ts test/system-record-objects-v1.test.ts -t \"complete height-three|exactly 128 MiB\" --maxWorkers=1 --no-file-parallelism",
"test:coverage": "vitest run --coverage",
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
Expand All @@ -43,6 +52,7 @@
"@libp2p/websockets": "^10.1.13",
"@libp2p/yamux": "^8.0.1",
"@multiformats/multiaddr": "^13.0.3",
"@noble/curves": "2.2.0",
"@noble/ed25519": "^3.1.0",
"@noble/hashes": "^2.2.0",
"@opentelemetry/api": "^1.9.1",
Expand All @@ -54,6 +64,7 @@
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@vitest/coverage-v8": "^4.0.18",
"multiformats": "14.0.0",
"vitest": "^4.0.18"
},
"publishConfig": {
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/canonical-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export type StrictJsonParseOptions = CanonicalJsonOptions;
export const MAX_CANONICAL_JSON_BYTES = 8 * 1024 * 1024;
export const MAX_CANONICAL_JSON_DEPTH = 64;
const UTF8 = new TextEncoder();
const TYPED_ARRAY_BYTE_LENGTH = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(Uint8Array.prototype) as object,
'byteLength',
)?.get;
// Preserve a leading BOM so the explicit wire-level rejection below can see it.
const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });

Expand Down Expand Up @@ -142,7 +146,22 @@ export function parseJsonStrict(
): CanonicalJsonValue {
const { maxBytes, maxDepth } = resolveLimits(options);

const byteLength = typeof input === 'string' ? UTF8.encode(input).byteLength : input.byteLength;
let byteLength: number;
if (typeof input === 'string') {
if (input.length > maxBytes) {
throw new CanonicalJsonError(`JSON input exceeds ${maxBytes} bytes`);
}
byteLength = UTF8.encode(input).byteLength;
} else {
if (!(input instanceof Uint8Array) || TYPED_ARRAY_BYTE_LENGTH === undefined) {
throw new CanonicalJsonError('JSON byte input must be a Uint8Array');
}
try {
byteLength = Reflect.apply(TYPED_ARRAY_BYTE_LENGTH, input, []) as number;
} catch {
throw new CanonicalJsonError('JSON byte input must be a valid Uint8Array');
}
}
if (byteLength > maxBytes) {
throw new CanonicalJsonError(`JSON input exceeds ${maxBytes} bytes`);
}
Expand Down
123 changes: 106 additions & 17 deletions packages/core/src/sync-wire-objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,118 @@ export function isPlainRecord(value: unknown): value is Record<string, unknown>
return prototype === Object.prototype || prototype === null;
}

/** Snapshot one closed plain record without invoking accessors or re-reading fields. */
export function snapshotExactDataRecord<const Keys extends readonly string[]>(
export interface SnapshotDataRecordOptions {
/** Closed protocol objects represent absent optionals by omission, never JSON null. */
readonly rejectNullValues?: boolean;
}

export interface SnapshotDataArrayOptions {
readonly minLength?: number;
readonly maxLength: number;
}

/**
* Snapshot one bounded, dense array without invoking accessors or caller-owned methods.
*
* Protocol arrays are closed containers: only their native length and enumerable own
* data elements are accepted. The returned copy always uses the local Array prototype,
* so later iteration cannot be redirected by a caller-owned prototype or method.
*/
export function snapshotDataArray(
value: unknown,
expected: Keys,
label: string,
): Readonly<Record<Keys[number], unknown>> {
options: SnapshotDataArrayOptions,
): readonly unknown[] {
const minLength = options.minLength ?? 0;
const maxLength = options.maxLength;
if (!Number.isSafeInteger(minLength) || minLength < 0
|| !Number.isSafeInteger(maxLength) || maxLength < minLength) {
throw new Error(`${label} has invalid snapshot bounds`);
}
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);

const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
if (!lengthDescriptor || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value')
|| lengthDescriptor.enumerable === true
|| !Number.isSafeInteger(lengthDescriptor.value)
|| lengthDescriptor.value < minLength
|| lengthDescriptor.value > maxLength) {
throw new Error(`${label} length is outside its bound`);
}
const length = lengthDescriptor.value as number;
const keys = Reflect.ownKeys(value);
if (keys.length !== length + 1) throw new Error(`${label} must be a dense closed array`);

const snapshot = new Array<unknown>(length);
let elements = 0;
for (const key of keys) {
if (key === 'length') continue;
if (typeof key !== 'string') throw new Error(`${label} must not contain symbol properties`);
const index = Number(key);
if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) {
throw new Error(`${label} must not contain non-index properties`);
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
throw new Error(`${label} must contain only enumerable data elements`);
}
snapshot[index] = descriptor.value;
elements += 1;
}
if (elements !== length) throw new Error(`${label} must be dense`);
return Object.freeze(snapshot);
}

/**
* Snapshot every enumerable string data property without invoking accessors.
*
* Codecs use this once to discover which optional fields are present, then pass
* the resulting snapshot through {@link snapshotExactDataRecord}. Keeping that
* two-step boundary here prevents each codec from drifting on prototype,
* symbol, accessor, enumerability, and null handling.
*/
export function snapshotDataRecord(
value: unknown,
label: string,
options: SnapshotDataRecordOptions = {},
): Readonly<Record<string, unknown>> {
if (!isPlainRecord(value)) {
throw new Error(`${label} must be a plain data object`);
}

const actual = Reflect.ownKeys(value);
if (actual.some((key) => typeof key !== 'string')) {
throw new Error(`${label} must not contain symbol properties`);
const snapshot: Record<string, unknown> = Object.create(null);
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== 'string') {
throw new Error(`${label} must not contain symbol properties`);
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
throw new Error(`${label} fields must be enumerable data properties`);
}
if (options.rejectNullValues === true && descriptor.value === null) {
throw new Error(`${label} must omit optional fields, not use null`);
}
snapshot[key] = descriptor.value;
}
const strings = actual as string[];
return Object.freeze(snapshot);
}

/** Accessor-safe presence test for an enumerable own data property. */
export function hasOwnDataProperty(value: unknown, key: string): boolean {
if (value === null || typeof value !== 'object') return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor?.enumerable === true
&& Object.prototype.hasOwnProperty.call(descriptor, 'value');
}

/** Snapshot one closed plain record without invoking accessors or re-reading fields. */
export function snapshotExactDataRecord<const Keys extends readonly string[]>(
value: unknown,
expected: Keys,
label: string,
): Readonly<Record<Keys[number], unknown>> {
const snapshot = snapshotDataRecord(value, label);
const strings = Object.keys(snapshot);
const sortedExpected = [...expected].sort();
if (
strings.length !== sortedExpected.length
Expand All @@ -28,15 +125,7 @@ export function snapshotExactDataRecord<const Keys extends readonly string[]>(
throw new Error(`${label} has unknown or missing fields`);
}

const snapshot: Record<string, unknown> = Object.create(null);
for (const key of expected) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
throw new Error(`${label} fields must be enumerable data properties`);
}
snapshot[key] = descriptor.value;
}
return Object.freeze(snapshot) as Readonly<Record<Keys[number], unknown>>;
return snapshot as Readonly<Record<Keys[number], unknown>>;
}

/** Require one plain record to contain exactly enumerable string data fields. */
Expand Down
Loading
Loading