Skip to content
Merged
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
66 changes: 58 additions & 8 deletions src/cockpit/read-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,59 @@ import {
*/
const objectFreeze = Object.freeze;
const objectHasOwn = Object.hasOwn;
const objectSetPrototypeOf = Object.setPrototypeOf;
const objectDefineProperty = Object.defineProperty;
const arrayIsArray = Array.isArray;
const numberIsInteger = Number.isInteger;

/**
* Build an accepted-snapshot **record** node that cannot inherit behaviour from
* the live `Object.prototype`, then freeze it.
*
* A hostile getter or Proxy trap that runs mid-validation can mutate the realm —
* for instance installing `Object.prototype.toJSON = () => { throw }`. The reader
* still accepts an otherwise-valid snapshot, but an ordinary `{...}` record would
* inherit that poisoned hook, so a later `JSON.stringify(snapshot)` would invoke
* it and break D1's plain-JSON round-trip promise. Giving every returned record a
* `null` prototype removes the inherited chain entirely, so no realm mutation can
* reach the accepted object graph. Own data properties are unaffected.
*/
function freezeRecord<T extends object>(record: T): Readonly<T> {
objectSetPrototypeOf(record, null);
return objectFreeze(record);
}

/**
* Freeze an accepted-snapshot **list** node so it, too, is insulated from a
* poisoned inherited `toJSON`.
*
* A list must keep `Array.prototype` — consumers iterate and map the returned
* arrays — so a `null` prototype is not usable here. Instead the inherited
* `toJSON` is shadowed by an own, non-enumerable `undefined`: `JSON.stringify`
* finds a non-callable own `toJSON`, skips it, and serialises the array itself,
* never reaching a mutated `Object.prototype.toJSON`. The shadow is
* non-enumerable, so it changes neither enumeration nor structural equality.
*/
function freezeList<T>(list: T[]): readonly T[] {
// The descriptor object itself is given a `null` prototype before it reaches
// `Object.defineProperty`. A hostile getter run earlier during validation may
// have installed `Object.prototype.get`/`.set`; an ordinary `{...}` descriptor
// would inherit those, and `ToPropertyDescriptor` — which walks the prototype
// chain — would then observe inherited accessor keys beside the own `value`/
// `writable` keys, reject the mixed descriptor, and throw, breaking this
// reader's never-throws contract. A `null` prototype removes the inherited
// chain entirely, the same insulation `freezeRecord` gives its nodes.
const descriptor: PropertyDescriptor = {
value: undefined,
enumerable: false,
writable: false,
configurable: false,
};
objectSetPrototypeOf(descriptor, null);
objectDefineProperty(list, 'toJSON', descriptor);
return objectFreeze(list);
}

/** V1 bounds. Every unbounded dimension is capped before iteration. */
export const COCKPIT_BOUNDS = objectFreeze({
/** Entries permitted in `pullRequests`. Oversize rejects the snapshot. */
Expand Down Expand Up @@ -429,7 +479,7 @@ function readCockpitList<T>(
}
append(parsed, parsedElement);
}
return objectFreeze(parsed);
return freezeList(parsed);
}

/**
Expand Down Expand Up @@ -507,7 +557,7 @@ function readPullRequestObservation(element: unknown): CockpitPullRequestObserva
if (pullRequestId === null || headSha === null || !baseRef.valid || !title.valid) {
return null;
}
return objectFreeze({
return freezeRecord({
pullRequestId,
headSha,
baseRef: baseRef.value,
Expand Down Expand Up @@ -539,7 +589,7 @@ function readEvidenceReadModel(element: unknown): CockpitEvidenceReadModel | nul
) {
return null;
}
return objectFreeze({ evidenceId, kind, source, commitSha, reference, observedAt });
return freezeRecord({ evidenceId, kind, source, commitSha, reference, observedAt });
}

/** Read one finding read model, or `null` when malformed. */
Expand Down Expand Up @@ -574,7 +624,7 @@ function readFindingReadModel(element: unknown): CockpitFindingReadModel | null
) {
return null;
}
return objectFreeze({
return freezeRecord({
findingId,
pullRequestId,
reviewedCommitSha,
Expand Down Expand Up @@ -614,7 +664,7 @@ function readRepairJobReadModel(element: unknown): CockpitRepairJobReadModel | n
) {
return null;
}
return objectFreeze({
return freezeRecord({
jobId,
parentPullRequestId,
findingId,
Expand Down Expand Up @@ -743,14 +793,14 @@ export function readCockpitSnapshot(value: unknown): CockpitSnapshotReadResult {
}

return objectFreeze({
snapshot: objectFreeze({
snapshot: freezeRecord<CockpitSnapshot>({
schemaVersion: COCKPIT_SNAPSHOT_SCHEMA_VERSION,
repository: objectFreeze({
repository: freezeRecord({
repositoryId,
observedHeadSha,
defaultBranchRef: defaultBranchRef.value,
}),
provenance: objectFreeze({ collectorId, observedAt }),
provenance: freezeRecord({ collectorId, observedAt }),
pullRequests,
evidence,
findings,
Expand Down
Loading
Loading