Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5051a91
Validate DAML map keys and values
HardlyDifficult Jul 10, 2026
5f44a4b
Merge branch 'codex/decoder-backed-specific-readers' into codex/stric…
HardlyDifficult Jul 10, 2026
e173019
Merge remote-tracking branch 'origin/codex/decoder-backed-specific-re…
HardlyDifficult Jul 10, 2026
4fd8d0e
Merge decoder-backed specific readers
HardlyDifficult Jul 10, 2026
0fdbb82
Merge commit '695b49e7ee03b1562c11049ed6adeb8fbc5e9108' into codex/st…
HardlyDifficult Jul 11, 2026
e197f33
fix: harden DAML map boundaries
HardlyDifficult Jul 11, 2026
c1238c3
Merge commit 'e6c50dfbbab77658d61eebaaff85b49cef00f3bd' into codex/st…
HardlyDifficult Jul 11, 2026
3090861
Merge commit '64ddcd6b9c1ee55e94c7fde878a7cb2473e73a9e' into codex/st…
HardlyDifficult Jul 11, 2026
22905e3
fix: harden CapTable map decoding boundaries
HardlyDifficult Jul 11, 2026
4c42bad
Merge commit 'a97d077c24d5970c3ee84746384274023179e064' into codex/st…
HardlyDifficult Jul 11, 2026
75b4919
Merge commit 'ffc94dd9d22ff23439aefcd97510cd18ca1278dd' into codex/st…
HardlyDifficult Jul 11, 2026
ae3120c
Auto-fix: build changes
github-actions[bot] Jul 11, 2026
b7bd9d3
Merge commit '2e097d09baaf1401d7c635dce1c27d7e509147f3' into codex/st…
HardlyDifficult Jul 11, 2026
2a036a9
Merge commit '02e89ce39bf755b65eefd31c349dcde5731b9917' into codex/st…
HardlyDifficult Jul 11, 2026
6b5778f
Merge commit 'cb32617ea8df85d05d90e87aa9882a1bd628f7f0' into codex/st…
HardlyDifficult Jul 11, 2026
bf91408
Merge commit 'd947afae49c17f94169bd3dfe17ef0a369aa8407' into codex/st…
HardlyDifficult Jul 11, 2026
d5765d5
Merge commit '3b3494ace635ef80510d485bdf84280887bb1527' into codex/st…
HardlyDifficult Jul 11, 2026
8853eaa
Merge remote-tracking branch 'origin/codex/decoder-backed-specific-re…
HardlyDifficult Jul 11, 2026
1d90f56
Merge commit '634104dcdb87576b893b0adb9a064dd6a036e00e' into codex/st…
HardlyDifficult Jul 11, 2026
5394ea3
Merge commit '818053ea8c78a25e4b3ca56fda16cd465cc56166' into codex/st…
HardlyDifficult Jul 11, 2026
00cf562
Fix lossless GenMap decoding
HardlyDifficult Jul 12, 2026
7ab9993
Merge commit '97603e8b4d0c8ccc4f30d2dc7fad2b241828df58' into codex/st…
HardlyDifficult Jul 12, 2026
c45265e
Merge commit 'c448a7eb2c915e1065a2428a50da4d67874d9208' into codex/st…
HardlyDifficult Jul 12, 2026
ed743f3
fix: harden strict DAML state decoding
HardlyDifficult Jul 12, 2026
b2183cf
Merge refreshed decoder-backed reader stack
HardlyDifficult Jul 12, 2026
1a54bcb
Merge remote-tracking branch 'origin/codex/decoder-backed-specific-re…
HardlyDifficult Jul 12, 2026
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
472 changes: 370 additions & 102 deletions src/functions/OpenCapTable/capTable/getCapTableState.ts

Large diffs are not rendered by default.

177 changes: 171 additions & 6 deletions src/utils/generatedDamlValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ interface GeneratedDamlCodec<T> {
interface DecodeGeneratedDamlOptions {
classification?: string;
context?: Record<string, unknown>;
/**
* Absolute field paths whose arrays represent DAML GenMaps.
*
* GenMap tuple order is not semantic and generated decoders are allowed to
* canonicalize it. Losslessness therefore compares these arrays by key while
* every ordinary array remains order-sensitive.
*/
genMapPaths?: readonly string[];
}

function boundedReceivedValue(value: unknown): unknown {
Expand Down Expand Up @@ -60,16 +68,167 @@ export function assertSafeGeneratedDamlJson(
);
}

function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): string | undefined {
/**
* Clone a value that has already passed {@link assertSafeGeneratedDamlJson}.
*
* This deliberately avoids JSON serialization: `-0`, null-prototype records,
* and dangerous-but-valid own keys such as `__proto__` must retain their exact
* JSON/DAML meaning. Defining properties also avoids invoking prototype setters.
*/
function cloneSafeGeneratedDamlJson(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;

if (Array.isArray(value)) {
const clone = new Array<unknown>(value.length);
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (descriptor === undefined || !('value' in descriptor)) {
throw new TypeError('Safe generated DAML array unexpectedly contains a non-data element');
}
Object.defineProperty(clone, String(index), {
value: cloneSafeGeneratedDamlJson(descriptor.value),
enumerable: true,
configurable: true,
writable: true,
});
}
return clone;
}

const clone = Object.create(Object.getPrototypeOf(value)) as Record<string, unknown>;
for (const key of Object.keys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor === undefined || !('value' in descriptor)) {
throw new TypeError('Safe generated DAML record unexpectedly contains a non-data property');
}
Object.defineProperty(clone, key, {
value: cloneSafeGeneratedDamlJson(descriptor.value),
enumerable: true,
configurable: true,
writable: true,
});
}
return clone;
}

function frameJsonIdentity(value: string): string {
return `${value.length}:${value}`;
}

/**
* Collision-free identity for values that already passed the strict JSON
* preflight. Object property order is deliberately ignored because it is not
* semantic JSON/DAML record state; array order remains significant.
*/
function canonicalJsonIdentity(value: unknown): string {
if (value === null) return 'null';
if (typeof value === 'string') return `string:${frameJsonIdentity(value)}`;
if (typeof value === 'boolean') return value ? 'boolean:true' : 'boolean:false';
if (typeof value === 'number') return `number:${Object.is(value, -0) ? '-0' : String(value)}`;
if (Array.isArray(value)) {
return `array:${value.length}:${value.map((item) => frameJsonIdentity(canonicalJsonIdentity(item))).join('')}`;
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
return `object:${keys.length}:${keys
.map((key) => `${frameJsonIdentity(key)}${frameJsonIdentity(canonicalJsonIdentity(record[key]))}`)
.join('')}`;
}

// assertSafeGeneratedDamlJson runs before this helper, so reaching this line
// would indicate an internal validation regression rather than bad input.
throw new TypeError(`Unsupported generated DAML JSON value: ${typeof value}`);
}

interface IndexedDamlMapEntry {
readonly index: number;
readonly key: unknown;
readonly value: unknown;
}

type IndexedDamlMap =
| { readonly entries: ReadonlyMap<string, IndexedDamlMapEntry> }
| { readonly mismatchPath: string };

function indexDamlMap(value: unknown, fieldPath: string): IndexedDamlMap {
if (!Array.isArray(value)) return { mismatchPath: fieldPath };

const entries = new Map<string, IndexedDamlMapEntry>();
for (let index = 0; index < value.length; index += 1) {
const tuple = value[index];
if (
!Array.isArray(tuple) ||
tuple.length !== 2 ||
!Object.prototype.hasOwnProperty.call(tuple, 0) ||
!Object.prototype.hasOwnProperty.call(tuple, 1)
) {
return { mismatchPath: `${fieldPath}[${index}]` };
}

const key = tuple[0];
const identity = canonicalJsonIdentity(key);
if (entries.has(identity)) {
return { mismatchPath: `${fieldPath}[${index}][0]` };
}
entries.set(identity, { index, key, value: tuple[1] });
}
return { entries };
}

function firstLossyDamlMapPath(
source: unknown,
encoded: unknown,
fieldPath: string,
genMapPaths: ReadonlySet<string>
): string | undefined {
const sourceMap = indexDamlMap(source, fieldPath);
if ('mismatchPath' in sourceMap) return sourceMap.mismatchPath;
const encodedMap = indexDamlMap(encoded, fieldPath);
if ('mismatchPath' in encodedMap) return encodedMap.mismatchPath;
if (sourceMap.entries.size !== encodedMap.entries.size) return fieldPath;

for (const [identity, sourceEntry] of sourceMap.entries) {
const encodedEntry = encodedMap.entries.get(identity);
if (encodedEntry === undefined) return `${fieldPath}[${sourceEntry.index}][0]`;

const keyMismatch = firstLossyPath(
sourceEntry.key,
encodedEntry.key,
`${fieldPath}[${sourceEntry.index}][0]`,
genMapPaths
);
if (keyMismatch !== undefined) return keyMismatch;

const valueMismatch = firstLossyPath(
sourceEntry.value,
encodedEntry.value,
`${fieldPath}[${sourceEntry.index}][1]`,
genMapPaths
);
if (valueMismatch !== undefined) return valueMismatch;
}
return undefined;
}

function firstLossyPath(
source: unknown,
encoded: unknown,
fieldPath: string,
genMapPaths: ReadonlySet<string>
): string | undefined {
rejectProxy(source, fieldPath);
rejectProxy(encoded, fieldPath);
if (genMapPaths.has(fieldPath)) {
return firstLossyDamlMapPath(source, encoded, fieldPath, genMapPaths);
}
if (source === null || typeof source !== 'object') {
return Object.is(source, encoded) ? undefined : fieldPath;
}
if (Array.isArray(source)) {
if (!Array.isArray(encoded) || source.length !== encoded.length) return fieldPath;
for (let index = 0; index < source.length; index += 1) {
const mismatch = firstLossyPath(source[index], encoded[index], `${fieldPath}[${index}]`);
const mismatch = firstLossyPath(source[index], encoded[index], `${fieldPath}[${index}]`, genMapPaths);
if (mismatch !== undefined) return mismatch;
}
return undefined;
Expand All @@ -79,24 +238,30 @@ function firstLossyPath(source: unknown, encoded: unknown, fieldPath: string): s
const encodedRecord = encoded as Record<string, unknown>;
for (const [key, child] of Object.entries(source)) {
if (!Object.prototype.hasOwnProperty.call(encodedRecord, key)) return `${fieldPath}.${key}`;
const mismatch = firstLossyPath(child, encodedRecord[key], `${fieldPath}.${key}`);
const mismatch = firstLossyPath(child, encodedRecord[key], `${fieldPath}.${key}`, genMapPaths);
if (mismatch !== undefined) return mismatch;
}
return undefined;
}

/** Decode through a generated codec and prove that encoding it cannot discard or alter source JSON. */
/**
* Decode through a generated codec and prove that encoding it cannot discard
* or alter source JSON. Configured GenMap arrays are compared by semantic key;
* all other arrays retain exact positional comparison.
*/
export function decodeGeneratedDaml<T>(
input: unknown,
codec: GeneratedDamlCodec<T>,
source: string,
options: DecodeGeneratedDamlOptions = {}
): T {
assertSafeGeneratedDamlJson(input, source);
const sourceSnapshot = cloneSafeGeneratedDamlJson(input);
const decoderInput = cloneSafeGeneratedDamlJson(input);

let decoded: T;
try {
decoded = codec.decode(input);
decoded = codec.decode(decoderInput);
} catch (error) {
const errorIsProxy =
((typeof error === 'object' && error !== null) || typeof error === 'function') && nodeUtilTypes.isProxy(error);
Expand Down Expand Up @@ -128,7 +293,7 @@ export function decodeGeneratedDaml<T>(
});
}
assertSafeGeneratedDamlJson(encoded, `${source}.__encoded`);
const lossyPath = firstLossyPath(input, encoded, source);
const lossyPath = firstLossyPath(sourceSnapshot, encoded, source, new Set(options.genMapPaths ?? []));
if (lossyPath !== undefined) {
throw new OcpParseError(`Generated DAML decoding would discard or alter ${lossyPath}`, {
source: lossyPath,
Expand Down
Loading