From 202bd586cd3d0cfaf17ffa8bd86ac54b0dd57523 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 20 Aug 2026 12:54:57 +0200 Subject: [PATCH 1/2] TML-2566: verify contract snapshot content against its address at the load seam The snapshot store is content-addressed, but nothing recomputed a loaded snapshot's storage hash: editing migrations/snapshots//contract.json while leaving the hash field alone made migration plan report a clean no-op. readContractSnapshotJson (and the tolerant variant) now accept a SnapshotContentVerifier that recomputes the storage hash with the target's canonicalization hooks and refuses with MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH; the aggregate loader threads it through every resolution path, and migration check reports the same state as MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/reference/error-reference.md | 8 + .../3-tooling/cli/src/control-api/client.ts | 5 + .../contract-snapshot-resolution.ts | 9 +- .../contract-space-aggregate-loader.ts | 11 ++ .../cli/src/control-api/operations/db-init.ts | 4 + .../cli/src/control-api/operations/db-run.ts | 4 + .../src/control-api/operations/db-update.ts | 4 + .../src/control-api/operations/db-verify.ts | 4 + .../cli/src/control-api/operations/migrate.ts | 4 + .../control-api/operations/migration-check.ts | 34 +++- .../control-api/operations/migration-new.ts | 3 + .../control-api/operations/migration-plan.ts | 4 + .../cli/src/control-api/operations/ref.ts | 3 +- .../3-tooling/cli/src/orm/migrate.ts | 2 + .../3-tooling/cli/src/orm/migration/check.ts | 8 +- .../3-tooling/cli/src/orm/migration/show.ts | 2 + .../utils/snapshot-content-verification.ts | 26 +++ .../orm/migration-snapshot-content.test.ts | 135 +++++++++++++ .../migration/src/aggregate/aggregate.ts | 40 +++- .../migration/src/aggregate/loader.ts | 53 ++++- .../migration/src/contract-snapshot-store.ts | 106 +++++++++- .../3-tooling/migration/src/errors.ts | 17 ++ .../src/exports/contract-snapshot-store.ts | 3 + .../1-framework/3-tooling/migration/src/io.ts | 8 + .../test/contract-snapshot-verify.test.ts | 181 ++++++++++++++++++ 25 files changed, 658 insertions(+), 20 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts create mode 100644 packages/1-framework/3-tooling/migration/test/contract-snapshot-verify.test.ts diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7c5a08fd26a3..2a38d6deee20 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -841,6 +841,10 @@ A `migration check` finding, carried as an `error` diagnostic on a completed run A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a ref file in a space's `refs/` directory cannot be read or parsed. Repair or remove the corrupt ref file. +### MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH + +A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a contract snapshot's declared `storage.storageHash` agrees with the migration's `to` hash, but the snapshot's content recomputes to a different storage hash — the file under `migrations/snapshots//` has been edited (or corrupted) since it was written. Restore `migrations/snapshots/` from version control, or re-run the command that produced the migration to regenerate its snapshot. + ### MIGRATION.CHECK_SNAPSHOT_HASH_MISMATCH A `migration check` finding, carried as an `error` diagnostic on a completed run that exits `4`: a migration declares a destination hash `to` but the contract snapshot stored for that hash has a different inner `storage.storageHash`. Re-emit the package so `migration.json` and its snapshot agree. @@ -869,6 +873,10 @@ An apply carrying consent was refused because the plan recomputed for it is not A contract JSON on disk failed to deserialize into a valid contract: either a snapshot-store entry read while migration tooling resolved a contract at a ref or hash, or the emitted `contract.json` read as the fallback source by `db sign` / `db update --to` (invalid JSON, or a value that is not a JSON object). Re-emit the owning migration package (or re-run `prisma contract emit` for the emitted contract), or restore the file from version control. Meta: `filePath`, `message`. Also raised by `migration new` when the emitted `contract.json` fails to deserialize; that site has no meta and attaches the deserialization failure as `cause`. +### MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH + +A contract snapshot loaded from `migrations/snapshots//contract.json` does not reproduce the storage hash it is addressed by: the store is content-addressed, and the file has been edited (or corrupted) since it was written. Raised at the snapshot-store load seam, so every command that resolves a contract from the store (`migration plan`, `ref set`, `db sign` / `db update --to`, aggregate contract resolution) refuses instead of treating the edited content as the recorded contract. The envelope names the file and both hashes (meta: `storageHash`, `computedHash`, `jsonPath`). Restore `migrations/snapshots/` from version control, or re-run the command that authored the referencing migration to regenerate the snapshot. + ### MIGRATION.CONTRACT_SNAPSHOT_HASH_MISMATCH While writing a contract snapshot, the contract JSON's inner `storage.storageHash` does not equal the storage hash the snapshot is being filed under — the two must agree by construction. Primarily an authoring/tooling invariant rather than something a user causes directly. Meta: `storageHash`, `actualHash`, `dir`. diff --git a/packages/1-framework/3-tooling/cli/src/control-api/client.ts b/packages/1-framework/3-tooling/cli/src/control-api/client.ts index 1a28bb930321..246347a80d04 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/client.ts @@ -30,6 +30,7 @@ import { notOk, ok } from '@internal/utils/result'; import { structuredError } from '@internal/utils/structured-error'; import { assertFrameworkComponentsCompatible } from '../utils/framework-components'; +import { snapshotVerifierFor } from '../utils/snapshot-content-verification'; import { enrichContract } from './contract-enrichment'; import { executeDbInit } from './operations/db-init'; import { executeDbUpdate } from './operations/db-update'; @@ -414,6 +415,7 @@ class ControlClientImpl implements ControlClient { migrationsDir: options.migrationsDir, targetId: this.options.target.targetId, extensions: this.options.extensions ?? [], + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), ...ifDefined('onProgress', onProgress), }); } @@ -453,6 +455,7 @@ class ControlClientImpl implements ControlClient { extensions: this.options.extensions ?? [], ...ifDefined('acceptDataLoss', options.acceptDataLoss), ...ifDefined('consent', options.consent), + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), ...ifDefined('onProgress', onProgress), }); } @@ -473,6 +476,7 @@ class ControlClientImpl implements ControlClient { mode: options.strict ? 'strict' : 'lenient', skipSchema: options.skipSchema, skipMarker: options.skipMarker, + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), ...ifDefined('onProgress', onProgress), }); } @@ -529,6 +533,7 @@ class ControlClientImpl implements ControlClient { ...ifDefined('refHash', options.refHash), ...ifDefined('refInvariants', options.refInvariants), ...ifDefined('refName', options.refName), + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), ...ifDefined('onProgress', onProgress), }); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index 52f45b79789d..136b86719117 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -23,6 +23,7 @@ import { errorUnexpected, mapRefResolutionError, } from '../../utils/cli-errors'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { buildReadAggregate } from './contract-space-aggregate-loader'; function isEnoent(error: unknown): boolean { @@ -83,7 +84,13 @@ export async function resolveContractRefToSnapshot( const contractJson = blindCast< Record, 'contract snapshot store entries are JSON objects written by writeContractSnapshot' - >(await readContractSnapshotJson(options.migrationsDir, targetHash)); + >( + await readContractSnapshotJson( + options.migrationsDir, + targetHash, + snapshotVerifierFor(options.config), + ), + ); return ok({ hash: targetHash, contractJson, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts index 11c5f19baed9..b0f1eb370ec0 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts @@ -11,12 +11,15 @@ import type { } from '@internal/migration-tools/aggregate'; import { loadContractSpaceAggregate } from '@internal/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { MigrationToolsError } from '@internal/migration-tools/errors'; import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { CliStructuredError, errorUnexpected } from '../../utils/cli-errors'; import { readContractEnvelope, resolveContractPath } from '../../utils/command-helpers'; import { toDeclaredExtensionsFromRaw } from '../../utils/extension-pack-inputs'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; const CONTRACT_SPACES_DOCS_URL = 'https://pris.ly/contract-spaces'; @@ -180,6 +183,12 @@ export interface BuildAggregateInputs>; readonly deserializeContract: (contractJson: unknown) => Contract; + /** + * Content check for contract snapshots resolved through the aggregate; + * build it with `snapshotVerifierFor(config)` so the recompute uses the + * target's canonicalization hooks. + */ + readonly verifySnapshotContent?: SnapshotContentVerifier; } function declaredExtensionsFromInputs( @@ -225,6 +234,7 @@ export async function loadContractSpaceAggregateForCli< migrationsDir: inputs.migrationsDir, deserializeContract: inputs.deserializeContract, appContract: inputs.appContract, + ...ifDefined('verifySnapshotContent', inputs.verifySnapshotContent), }); return ok(aggregate); } @@ -384,6 +394,7 @@ export async function buildReadAggregate( appContract: appContractForLoad, extensions: config.extensions ?? [], deserializeContract, + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(config)), }); if (!loaded.ok) { return loaded; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts index 1d51edad7724..597b794e349e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-init.ts @@ -7,6 +7,7 @@ import type { ControlFamilyInstance, TargetMigrationsCapability, } from '@internal/framework-components/control'; +import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { ifDefined } from '@internal/utils/defined'; import type { DbInitResult, OnControlProgress } from '../types'; import { executeRun } from './db-run'; @@ -56,6 +57,8 @@ export interface ExecuteDbInitOptions>; + /** Content check for contract snapshots the aggregate loader resolves. */ + readonly verifySnapshotContent?: SnapshotContentVerifier; /** Optional progress callback for observing operation progress */ readonly onProgress?: OnControlProgress; } @@ -83,6 +86,7 @@ export async function executeDbInit familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', options.verifySnapshotContent), }; const loaded = await buildContractSpaceAggregate(loadInputs); if (!loaded.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts index 52072b838529..139ff3dc2b7f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/db-update.ts @@ -7,6 +7,7 @@ import type { ControlFamilyInstance, TargetMigrationsCapability, } from '@internal/framework-components/control'; +import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { ifDefined } from '@internal/utils/defined'; import { notOk } from '@internal/utils/result'; import type { DbUpdateResult, OnControlProgress } from '../types'; @@ -48,6 +49,8 @@ export interface ExecuteDbUpdateOptions>; + /** Content check for contract snapshots the aggregate loader resolves. */ + readonly verifySnapshotContent?: SnapshotContentVerifier; readonly onProgress?: OnControlProgress; } @@ -75,6 +78,7 @@ export async function executeDbUpdate( appContract: options.contract, extensions: options.extensions, deserializeContract: (json) => options.familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', options.verifySnapshotContent), }; } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index 34578b795262..7ed9975da9bb 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -21,6 +21,7 @@ import { resolveRecordedPath, } from '@internal/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { errorNoInvariantPath } from '@internal/migration-tools/errors'; import { findPathWithDecision } from '@internal/migration-tools/migration-graph'; import { ifDefined } from '@internal/utils/defined'; @@ -63,6 +64,8 @@ export interface ExecuteMigrateOptions>; readonly targetId: TTargetId; + /** Content check for contract snapshots the aggregate loader resolves. */ + readonly verifySnapshotContent?: SnapshotContentVerifier; /** * Optional app-space ref override. When provided, the app space's * graph-walk targets this hash instead of `space.headRef.hash`. @@ -137,6 +140,7 @@ export async function executeMigrate familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', options.verifySnapshotContent), }; const loaded = await buildContractSpaceAggregate(loadInputs); if (!loaded.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts index 796737fcc0fb..78ea468d429f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts @@ -12,6 +12,7 @@ import type { } from '@internal/migration-tools/aggregate'; import { loadContractSpaceAggregate } from '@internal/migration-tools/aggregate'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { contractSnapshotDir, readContractSnapshotJson, @@ -52,6 +53,7 @@ import { resolveTargetPathAcrossSpaces, } from '../../utils/migration-path-target'; import { chooseAction, runCommandAction } from '../../utils/next-actions'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; function migrationPathRelative(cwd: string, dirPath: string): string { return relative(cwd, dirPath); @@ -87,7 +89,9 @@ function checkFileExists( * package dir with only `migration.json` + `ops.json` is legitimate); a * present entry whose inner `storage.storageHash` disagrees with * `pkg.metadata.to` is `MIGRATION.CHECK_SNAPSHOT_HASH_MISMATCH`; an unparseable store - * entry (or a malformed `to`) is `MIGRATION.CHECK_SNAPSHOT_UNPARSEABLE`. + * entry (or a malformed `to`) is `MIGRATION.CHECK_SNAPSHOT_UNPARSEABLE`; a parseable + * entry whose declared hash agrees but whose content recomputes to a different + * storage hash is `MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH`. */ async function checkSnapshotConsistency( space: CheckSpace, @@ -148,6 +152,24 @@ async function checkSnapshotConsistency( ], }; } + if (space.verifySnapshotContent !== undefined) { + const computedHash = space.verifySnapshotContent.recomputeStorageHash(raw); + if (computedHash !== pkg.metadata.to) { + const jsonPath = join(snapshotDir, 'contract.json'); + return { + space: spaceId, + code: 'MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH', + where: migrationPathRelative(space.cwd, jsonPath), + why: `Migration "${pkg.dirName}" is addressed by contract snapshot ${pkg.metadata.to}, but the content of ${jsonPath} recomputes to storage hash ${computedHash} — the snapshot has been edited since it was written.`, + nextActions: [ + chooseAction('Restore migrations/snapshots/ from version control'), + chooseAction( + 'Or re-run the command that produced this migration to regenerate its snapshot', + ), + ], + }; + } + } return null; } @@ -169,6 +191,12 @@ export interface CheckSpace { readonly projectMigrationsDir: string; /** Directory the command was invoked from; every `where` path is relative to it. */ readonly cwd: string; + /** + * Content check for snapshot-store entries; when present, + * `checkSnapshotConsistency` recomputes each snapshot's storage hash and + * reports `MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH` on disagreement. + */ + readonly verifySnapshotContent?: SnapshotContentVerifier; } /** @@ -183,6 +211,7 @@ export async function enumerateCheckSpaces( aggregate: ContractSpaceAggregate, projectMigrationsDir: string, cwd: string, + verifySnapshotContent?: SnapshotContentVerifier, ): Promise { const candidateDirs = await listContractSpaceDirectories(projectMigrationsDir); const onDiskSpaceIds = new Set(candidateDirs.filter(isValidSpaceId)); @@ -201,6 +230,7 @@ export async function enumerateCheckSpaces( refsDir: spaceRefsDirectory(migrationsDir), projectMigrationsDir, cwd, + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); } return spaces; @@ -347,10 +377,12 @@ export async function loadAggregateIntegrityViolations( const declaredExtensions = toDeclaredExtensionsFromRaw(config.extensions ?? []); const parsedAppContract: unknown = JSON.parse(contractJsonContent); + const verifySnapshotContent = snapshotVerifierFor(config); const aggregate = await loadContractSpaceAggregate({ migrationsDir, deserializeContract: (json: unknown) => familyInstance.deserializeContract(json), appContract: familyInstance.deserializeContract(parsedAppContract), + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); return aggregate.checkIntegrity({ declaredExtensions, checkContracts: true }); } catch { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index f53dca3cce1e..cd1399f65571 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -33,6 +33,7 @@ import { } from '../../utils/command-helpers'; import { assertFrameworkComponentsCompatible } from '../../utils/framework-components'; import { createProjectSpecifierResolver } from '../../utils/project-import-root'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { refusePackageCorruptionOnAggregate } from './contract-space-aggregate-loader'; export interface MigrationNewOptions { @@ -112,10 +113,12 @@ export async function executeMigrationNewCommand( ); } + const verifySnapshotContent = snapshotVerifierFor(config); const aggregate = await loadContractSpaceAggregate({ migrationsDir, deserializeContract: (json) => familyInstance.deserializeContract(json), appContract: toContract, + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); const packageCorruptionFailure = refusePackageCorruptionOnAggregate(aggregate, migrationsDir); if (packageCorruptionFailure) { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index c21c91e2e3f6..5f7e75f5ea36 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -44,6 +44,7 @@ import { import { toExtensionInputs } from '../../utils/extension-pack-inputs'; import { assertFrameworkComponentsCompatible } from '../../utils/framework-components'; import { createProjectSpecifierResolver } from '../../utils/project-import-root'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { buildContractSpaceAggregate, loadContractSpaceAggregateForCli, @@ -329,12 +330,14 @@ async function executeMigrationPlanCommandInner( } | null = null; let isAutoBaseline = false; + const verifySnapshotContent = snapshotVerifierFor(config); const tolerantAggregateResult = await loadContractSpaceAggregateForCli({ targetId: config.target.targetId, migrationsDir, appContract: toContract, extensions: config.extensions ?? [], deserializeContract: (json: unknown) => familyInstance.deserializeContract(json), + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); if (!tolerantAggregateResult.ok) { return notOk(tolerantAggregateResult.failure); @@ -459,6 +462,7 @@ async function executeMigrationPlanCommandInner( appContract: toContract, extensions: config.extensions ?? [], deserializeContract: (json: unknown) => familyInstance.deserializeContract(json), + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); if (!aggregateResult.ok) { return notOk(aggregateResult.failure); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts index 68e8bec8730e..ca937db55901 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref.ts @@ -32,6 +32,7 @@ import { mapRefResolutionError, } from '../../utils/cli-errors'; import { resolveMigrationPaths } from '../../utils/command-helpers'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { buildReadAggregate } from './contract-space-aggregate-loader'; export interface RefSetResult { @@ -127,7 +128,7 @@ export async function executeRefSetCommand( 'contract.json', ); try { - await readContractSnapshotJson(migrationsDir, resolvedHash); + await readContractSnapshotJson(migrationsDir, resolvedHash, snapshotVerifierFor(config)); } catch (readError) { if ( MigrationToolsError.is(readError) && diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index e0a8f19dc276..5ce298ddeeb8 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -40,6 +40,7 @@ import { createToneMigrationListStyler } from '../utils/formatters/migration-lis import { toneDrawing } from '../utils/formatters/tone-markup'; import { mapMigrateFailure } from '../utils/migrate-failure'; import { runCommandAction } from '../utils/next-actions'; +import { snapshotVerifierFor } from '../utils/snapshot-content-verification'; import { ormConfigSection } from './config-section'; import { perSpaceBlocks } from './db/migration-blocks'; import { prepareMigrationRun } from './db/prepare'; @@ -319,6 +320,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { appContract, extensions: ctx.config.extensions ?? [], deserializeContract: (json) => familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(ctx.config)), }); if (!loaded.ok) { return notOk(normalizeError(loaded.failure)); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts index a36c541ce0b8..2745f1ed9eb4 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts @@ -16,6 +16,7 @@ import { } from '../../control-api/operations/migration-check'; import { errorMigrationPackageNotFound } from '../../utils/cli-errors'; import { integrityViolationToCheckFailure } from '../../utils/integrity-violation-to-check-failure'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -133,7 +134,12 @@ export const migrationCheckCommand = defineOrmCommand({ if (!loaded.ok) { return notOk(normalizeError(loaded.failure)); } - const spaces = await enumerateCheckSpaces(loaded.value.aggregate, migrationsDir, ctx.cwd); + const spaces = await enumerateCheckSpaces( + loaded.value.aggregate, + migrationsDir, + ctx.cwd, + snapshotVerifierFor(ctx.config), + ); let document: MigrationCheckResult; let resolvedSpaceId: string | undefined; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts index c7e1644943ce..9d0338a8480e 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/show.ts @@ -33,6 +33,7 @@ import { looksLikePath, resolveAppTargetPath, } from '../../utils/migration-path-target'; +import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { ormConfigSection } from '../config-section'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -278,6 +279,7 @@ export const migrationShowCommand = defineOrmCommand({ appContract, extensions: [], deserializeContract: (json) => familyInstance.deserializeContract(json), + ...ifDefined('verifySnapshotContent', snapshotVerifierFor(ctx.config)), }); if (!loaded.ok) { return notOk(normalizeError(loaded.failure)); diff --git a/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts b/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts new file mode 100644 index 000000000000..09739f9c0021 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts @@ -0,0 +1,26 @@ +import type { + SnapshotCanonicalizationHooks, + SnapshotContentVerifier, +} from '@internal/migration-tools/contract-snapshot-store'; +import { createSnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; +import { ifDefined } from '@internal/utils/defined'; + +/** + * Build the per-command snapshot content verifier from the target's + * `ContractSerializer`, which carries the family canonicalization hooks the + * emit pipeline hashed with. Every target descriptor ships the serializer; + * the absent case exists only for structural test stand-ins, which read + * without content verification. + */ +export function snapshotVerifierFor(config: { + readonly target: { readonly contractSerializer?: SnapshotCanonicalizationHooks }; +}): SnapshotContentVerifier | undefined { + const serializer = config.target.contractSerializer; + if (serializer === undefined) { + return undefined; + } + return createSnapshotContentVerifier({ + ...ifDefined('shouldPreserveEmpty', serializer.shouldPreserveEmpty), + ...ifDefined('sortStorage', serializer.sortStorage), + }); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts new file mode 100644 index 000000000000..4702ff5ff4d1 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts @@ -0,0 +1,135 @@ +import { writeFile } from 'node:fs/promises'; +import { computeStorageHash } from '@internal/contract/hashing'; +import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; +import { createTestCli } from '@prisma/cli-engine/testing'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; +import { + createOfflineProject, + type OfflineProject, + offlineConfig, + removeOfflineProjects, + seedContractSnapshot, + seedDbRef, + seedMigrationPackage, +} from './fixtures/offline-project'; + +/** + * The one hash whose content genuinely reproduces it: the shared offline + * fixture builds contracts whose storage is `{ storageHash, namespaces: {} }`, + * so addressing them by this computed hash makes every seeded artifact + * content-consistent. + */ +const GENUINE_HASH = computeStorageHash({ + target: 'postgres', + targetFamily: 'sql', + storage: { namespaces: {} }, +}) as string; + +afterEach(removeOfflineProjects); + +function harness(project: OfflineProject) { + const config = offlineConfig({ project }); + const target = config['target'] as Record; + return createTestCli({ + commands: BIN_COMMANDS, + groups: BIN_GROUPS, + config: { orm: { ...config, target: { ...target, contractSerializer: {} } } }, + }); +} + +/** A project whose graph, snapshot store, db ref and emitted contract all sit at GENUINE_HASH. */ +async function upToDateProject(): Promise { + const project = await createOfflineProject({ storageHash: GENUINE_HASH }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: GENUINE_HASH, + }); + await seedContractSnapshot({ migrationsDir: project.migrationsDir, storageHash: GENUINE_HASH }); + await seedDbRef({ appMigrationsDir: project.appMigrationsDir, storageHash: GENUINE_HASH }); + return project; +} + +async function tamperSnapshot(project: OfflineProject): Promise { + const jsonPath = join(contractSnapshotDir(project.migrationsDir, GENUINE_HASH), 'contract.json'); + await writeFile( + jsonPath, + JSON.stringify({ + storage: { storageHash: GENUINE_HASH, namespaces: { sneaky: { entries: {} } } }, + schemaVersion: '1.0.0', + target: 'postgres', + targetFamily: 'sql', + models: {}, + }), + 'utf-8', + ); +} + +describe('contract snapshot content verification', () => { + it('plan reports a clean no-op while the snapshot store is untampered', async () => { + const project = await upToDateProject(); + + const run = await harness(project).run(['migration', 'plan', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ noOp: true }); + }); + + it('plan refuses when the snapshot content was edited under an unchanged hash field', async () => { + const project = await upToDateProject(); + await tamperSnapshot(project); + + const run = await harness(project).run(['migration', 'plan', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(2); + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ + ok: false, + error: { + code: 'MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH', + meta: { + storageHash: GENUINE_HASH, + computedHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }, + }, + }); + }); + + it('check reports the tampered snapshot as CHECK_SNAPSHOT_CONTENT_MISMATCH', async () => { + const project = await upToDateProject(); + await tamperSnapshot(project); + + const run = await harness(project).run(['migration', 'check', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(4); + const data = run.presented?.data as { + failures: readonly { code: string; why: string }[]; + }; + const failure = data.failures.find( + (f) => f.code === 'MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH', + ); + expect(failure).toBeDefined(); + expect(failure?.why).toContain(GENUINE_HASH); + }); + + it('check passes while the snapshot store is untampered', async () => { + const project = await upToDateProject(); + + const run = await harness(project).run(['migration', 'check', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + }); +}); diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts index 6e640612a294..e230d8d480fe 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/aggregate.ts @@ -3,6 +3,7 @@ import type { SchemaEntityCoordinate } from '@internal/framework-components/cont import { coordinateKey, elementCoordinates } from '@internal/framework-components/ir'; import { InternalError } from '@internal/utils/internal-error'; import { join } from 'pathe'; +import type { SnapshotContentVerifier } from '../contract-snapshot-store'; import { contractSnapshotDir, readContractSnapshotDts, @@ -54,8 +55,9 @@ async function readContractSnapshotEntry( migrationsDir: string, hash: string, deserializeContract: (raw: unknown) => Contract, + verifySnapshotContent: SnapshotContentVerifier | undefined, ): Promise<{ contractJson: unknown; contractDts: string; contract: Contract }> { - const contractJson = await readContractSnapshotJson(migrationsDir, hash); + const contractJson = await readContractSnapshotJson(migrationsDir, hash, verifySnapshotContent); const contractDts = await readContractSnapshotDts(migrationsDir, hash); const jsonPath = join(contractSnapshotDir(migrationsDir, hash), 'contract.json'); const contract = deserializeContractAtPath(jsonPath, contractJson, deserializeContract); @@ -70,8 +72,18 @@ async function resolveContractAt(args: { readonly packages: readonly OnDiskMigrationPackage[]; readonly graph: MigrationGraph; readonly deserializeContract: (raw: unknown) => Contract; + readonly verifySnapshotContent: SnapshotContentVerifier | undefined; }): Promise { - const { hash, opts, refsDir, migrationsDir, packages, graph, deserializeContract } = args; + const { + hash, + opts, + refsDir, + migrationsDir, + packages, + graph, + deserializeContract, + verifySnapshotContent, + } = args; const refName = opts?.refName; if (refName !== undefined) { @@ -91,6 +103,7 @@ async function resolveContractAt(args: { migrationsDir, refEntry.hash, deserializeContract, + verifySnapshotContent, ); return { hash: refEntry.hash, @@ -107,6 +120,7 @@ async function resolveContractAt(args: { migrationsDir, packages, deserializeContract, + verifySnapshotContent, explicitLabel: refName, }); } @@ -115,7 +129,13 @@ async function resolveContractAt(args: { } if (isGraphNode(hash, graph)) { - return resolveGraphNodeContractAt({ hash, migrationsDir, packages, deserializeContract }); + return resolveGraphNodeContractAt({ + hash, + migrationsDir, + packages, + deserializeContract, + verifySnapshotContent, + }); } throw errorHashNotInGraph(hash, graph); @@ -126,9 +146,17 @@ async function resolveGraphNodeContractAt(args: { readonly migrationsDir: string; readonly packages: readonly OnDiskMigrationPackage[]; readonly deserializeContract: (raw: unknown) => Contract; + readonly verifySnapshotContent: SnapshotContentVerifier | undefined; readonly explicitLabel?: string; }): Promise { - const { hash, migrationsDir, packages, deserializeContract, explicitLabel } = args; + const { + hash, + migrationsDir, + packages, + deserializeContract, + verifySnapshotContent, + explicitLabel, + } = args; const matchingBundle = packages.find((pkg) => pkg.metadata.to === hash); if (!matchingBundle) { throw errorBundleNotFoundForGraphNode(hash, explicitLabel); @@ -138,6 +166,7 @@ async function resolveGraphNodeContractAt(args: { migrationsDir, hash, deserializeContract, + verifySnapshotContent, ); return { hash, @@ -189,6 +218,7 @@ export function createAggregateContractSpace(args: { readonly migrationsDir: string; readonly resolveContract: () => Contract; readonly deserializeContract: (raw: unknown) => Contract; + readonly verifySnapshotContent?: SnapshotContentVerifier; }): AggregateContractSpace { const { spaceId, @@ -199,6 +229,7 @@ export function createAggregateContractSpace(args: { migrationsDir, resolveContract, deserializeContract, + verifySnapshotContent, } = args; let graphMemo: MigrationGraph | undefined; let contractMemo: Contract | undefined; @@ -234,6 +265,7 @@ export function createAggregateContractSpace(args: { packages, graph: spaceGraph(), deserializeContract, + verifySnapshotContent, }); contractAtMemo.set(key, result); return result; diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts b/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts index f28ed90a1cb7..607cef55a1fd 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts @@ -1,4 +1,5 @@ import type { Contract } from '@internal/contract/types'; +import type { SnapshotContentVerifier } from '../contract-snapshot-store'; import { readContractSnapshotJson } from '../contract-snapshot-store'; import { errorSpaceHeadRefMissing, MigrationToolsError } from '../errors'; import { readMigrationsDir } from '../io'; @@ -35,6 +36,14 @@ export interface LoadAggregateInput { readonly migrationsDir: string; readonly deserializeContract: (raw: unknown) => Contract; readonly appContract: Contract; + /** + * Content check for contract snapshots resolved from the store: every + * snapshot read through this aggregate is recomputed against the hash it + * was addressed by. Construction stays tolerant — a mismatched extension + * head snapshot surfaces at `contract()` time as `contractUnreadable`, + * and `contractAt()` throws `MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH`. + */ + readonly verifySnapshotContent?: SnapshotContentVerifier; } /** @@ -58,11 +67,20 @@ export interface LoadAggregateInput { export async function loadContractSpaceAggregate( input: LoadAggregateInput, ): Promise { - const { migrationsDir, deserializeContract, appContract } = input; + const { migrationsDir, deserializeContract, appContract, verifySnapshotContent } = input; const targetId = appContract.target; - const appState = await loadAppSpace(migrationsDir, appContract, deserializeContract); - const extensionStates = await loadExtensionSpaces(migrationsDir, deserializeContract); + const appState = await loadAppSpace( + migrationsDir, + appContract, + deserializeContract, + verifySnapshotContent, + ); + const extensionStates = await loadExtensionSpaces( + migrationsDir, + deserializeContract, + verifySnapshotContent, + ); const spaces: readonly IntegritySpaceState[] = [appState, ...extensionStates]; @@ -78,9 +96,13 @@ async function loadAppSpace( migrationsDir: string, appContract: Contract, deserializeContract: (raw: unknown) => Contract, + verifySnapshotContent: SnapshotContentVerifier | undefined, ): Promise { const spaceDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID); - const { packages, problems } = await readMigrationsDir(spaceDir, { migrationsDir }); + const { packages, problems } = await readMigrationsDir(spaceDir, { + migrationsDir, + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + }); const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir)); const space = createAggregateContractSpace({ @@ -92,6 +114,7 @@ async function loadAppSpace( migrationsDir, resolveContract: () => appContract, deserializeContract, + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); // The app head ref is synthesised from the live contract, so there is @@ -108,6 +131,7 @@ async function loadAppSpace( async function loadExtensionSpaces( migrationsDir: string, deserializeContract: (raw: unknown) => Contract, + verifySnapshotContent: SnapshotContentVerifier | undefined, ): Promise { const candidateDirs = await listContractSpaceDirectories(migrationsDir); const extensionIds = candidateDirs @@ -117,7 +141,9 @@ async function loadExtensionSpaces( const states: IntegritySpaceState[] = []; for (const spaceId of extensionIds) { - states.push(await loadExtensionSpace(migrationsDir, spaceId, deserializeContract)); + states.push( + await loadExtensionSpace(migrationsDir, spaceId, deserializeContract, verifySnapshotContent), + ); } return states; } @@ -126,13 +152,22 @@ async function loadExtensionSpace( migrationsDir: string, spaceId: string, deserializeContract: (raw: unknown) => Contract, + verifySnapshotContent: SnapshotContentVerifier | undefined, ): Promise { const spaceDir = spaceMigrationDirectory(migrationsDir, spaceId); - const { packages, problems } = await readMigrationsDir(spaceDir, { migrationsDir }); + const { packages, problems } = await readMigrationsDir(spaceDir, { + migrationsDir, + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + }); const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir)); const { headRef, problem: headRefProblem } = await readHeadRefTolerant(migrationsDir, spaceId); - const rawContract = await readRawContractDeferred(migrationsDir, spaceId, headRef); + const rawContract = await readRawContractDeferred( + migrationsDir, + spaceId, + headRef, + verifySnapshotContent, + ); const space = createAggregateContractSpace({ spaceId, @@ -143,6 +178,7 @@ async function loadExtensionSpace( migrationsDir, resolveContract: () => deserializeContract(rawContract()), deserializeContract, + ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), }); return { space, problems, refProblems, headRefProblem, isApp: false }; @@ -206,6 +242,7 @@ async function readRawContractDeferred( migrationsDir: string, spaceId: string, headRef: ContractSpaceHeadRef | null, + verifySnapshotContent: SnapshotContentVerifier | undefined, ): Promise<() => unknown> { if (headRef === null) { return () => { @@ -213,7 +250,7 @@ async function readRawContractDeferred( }; } try { - const raw = await readContractSnapshotJson(migrationsDir, headRef.hash); + const raw = await readContractSnapshotJson(migrationsDir, headRef.hash, verifySnapshotContent); return () => raw; } catch (error) { return () => { diff --git a/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts b/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts index 841f76afbbff..12c550336a83 100644 --- a/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts +++ b/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts @@ -1,10 +1,14 @@ import { randomBytes } from 'node:crypto'; import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import type { PreserveEmptyPredicate, StorageSort } from '@internal/contract/hashing'; +import { computeStorageHash } from '@internal/contract/hashing'; import { CONTRACT_SNAPSHOTS_DIRNAME, storageHashHex } from '@internal/framework-components/control'; import { canonicalizeJson } from '@internal/framework-components/utils'; import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import { join, relative } from 'pathe'; import { + errorContractSnapshotContentMismatch, errorContractSnapshotHashMismatch, errorContractSnapshotMissing, errorInvalidJson, @@ -36,6 +40,77 @@ export function contractSnapshotDir(migrationsDir: string, storageHash: string): return join(migrationsDir, CONTRACT_SNAPSHOTS_DIRNAME, storageHashHex(storageHash)); } +/** + * Family-contributed canonicalization hooks needed to reproduce the storage + * hash the emit pipeline computed. Sourced from the target's + * `ContractSerializer` (`shouldPreserveEmpty` / `sortStorage`); families + * without special-case storage paths supply neither. + */ +export interface SnapshotCanonicalizationHooks { + readonly shouldPreserveEmpty?: PreserveEmptyPredicate; + readonly sortStorage?: StorageSort; +} + +/** + * Recompute-and-compare integrity check for loaded contract snapshots, + * mirroring `verifyMigrationHash` for migration packages and + * `assertDescriptorSelfConsistency` for extension descriptors: the store is + * content-addressed, so the JSON read back for a hash must reproduce that + * hash. Verified hashes are memoised per instance, so a snapshot resolved + * repeatedly in one command run is hashed once. + */ +export interface SnapshotContentVerifier { + /** + * Throws `MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH` when + * `contractJson`'s content does not recompute to `storageHash` — the hash + * the snapshot at `jsonPath` was addressed by. + */ + assertSnapshotContentMatches(contractJson: unknown, storageHash: string, jsonPath: string): void; + /** The storage hash `contractJson`'s content canonicalizes to. */ + recomputeStorageHash(contractJson: unknown): string; +} + +export function createSnapshotContentVerifier( + hooks?: SnapshotCanonicalizationHooks, +): SnapshotContentVerifier { + const verified = new Set(); + + function recomputeStorageHash(contractJson: unknown): string { + const record = blindCast< + { target?: unknown; targetFamily?: unknown; storage?: unknown }, + 'contractJson is unknown JSON; only the identity fields the hash covers are read here' + >(contractJson ?? {}); + const storageRecord = blindCast< + Record, + 'the storage subtree is hashed as an opaque record; a non-record value simply fails the comparison' + >(record.storage ?? {}); + // The published hash was computed over a storage object that did not yet + // carry `storageHash`; strip it so the recompute sees the same shape. + const { storageHash: _addressed, ...storageWithoutHash } = storageRecord; + return computeStorageHash({ + target: typeof record.target === 'string' ? record.target : '', + targetFamily: typeof record.targetFamily === 'string' ? record.targetFamily : '', + storage: storageWithoutHash, + ...ifDefined('shouldPreserveEmpty', hooks?.shouldPreserveEmpty), + ...ifDefined('sortStorage', hooks?.sortStorage), + }); + } + + return { + recomputeStorageHash, + assertSnapshotContentMatches(contractJson, storageHash, jsonPath) { + if (verified.has(storageHash)) { + return; + } + const computedHash = recomputeStorageHash(contractJson); + if (computedHash !== storageHash) { + throw errorContractSnapshotContentMismatch({ storageHash, computedHash, jsonPath }); + } + verified.add(storageHash); + }, + }; +} + export interface ContractSnapshotInput { readonly contractJson: unknown; readonly contractDts: string; @@ -94,9 +169,16 @@ export async function writeContractSnapshot( return { written: true, dir }; } +/** + * When `verifyContent` is supplied, the parsed snapshot's content is checked + * against the hash it was addressed by before being returned — the store is + * content-addressed, and a snapshot edited in place under an unchanged hash + * would otherwise flow into planning as if it were the recorded contract. + */ export async function readContractSnapshotJson( migrationsDir: string, storageHash: string, + verifyContent?: SnapshotContentVerifier, ): Promise { const jsonPath = join(contractSnapshotDir(migrationsDir, storageHash), CONTRACT_JSON_FILE); @@ -110,11 +192,14 @@ export async function readContractSnapshotJson( throw error; } + let parsed: unknown; try { - return JSON.parse(raw); + parsed = JSON.parse(raw); } catch (e) { throw errorInvalidJson(jsonPath, e instanceof Error ? e.message : String(e)); } + verifyContent?.assertSnapshotContentMatches(parsed, storageHash, jsonPath); + return parsed; } /** @@ -125,11 +210,15 @@ export async function readContractSnapshotJson( * `readEndContractJson` (`io.ts`), which never validated the hash it was * keyed by either. Any other fs error (e.g. `EACCES` on a present-but- * unreadable file) propagates rather than silently loading a contract-less - * package. + * package. When `verifyContent` is supplied, an entry whose content does not + * recompute to its address also resolves to `undefined` — tampered content + * must not flow onward, and the strict store reads report the same file + * loudly as `MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH`. */ export async function readContractSnapshotJsonTolerant( migrationsDir: string, storageHash: string, + verifyContent?: SnapshotContentVerifier, ): Promise { let jsonPath: string; try { @@ -148,12 +237,21 @@ export async function readContractSnapshotJsonTolerant( throw error; } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (parsed === null) { + return undefined; + } try { - const parsed: unknown = JSON.parse(raw); - return parsed === null ? undefined : parsed; + verifyContent?.assertSnapshotContentMatches(parsed, storageHash, jsonPath); } catch { return undefined; } + return parsed; } export async function readContractSnapshotDts( diff --git a/packages/1-framework/3-tooling/migration/src/errors.ts b/packages/1-framework/3-tooling/migration/src/errors.ts index 9f5cd2218ffc..90c99c50380d 100644 --- a/packages/1-framework/3-tooling/migration/src/errors.ts +++ b/packages/1-framework/3-tooling/migration/src/errors.ts @@ -597,6 +597,23 @@ export function errorContractSnapshotHashMismatch( ); } +export function errorContractSnapshotContentMismatch(args: { + readonly storageHash: string; + readonly computedHash: string; + readonly jsonPath: string; +}): MigrationToolsError { + const { storageHash, computedHash, jsonPath } = args; + return new MigrationToolsError( + 'MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH', + 'Contract snapshot content does not match its hash', + { + why: `The contract snapshot at "${jsonPath}" is addressed by storage hash ${storageHash}, but its content recomputes to ${computedHash}. The file has been edited (or corrupted) since it was written.`, + fix: 'Restore migrations/snapshots/ from version control, or re-run the command that authored the migration referencing this hash to regenerate the snapshot.', + meta: { storageHash, computedHash, jsonPath }, + }, + ); +} + export function errorMigrationContractViewMissing( className: string, accessor: 'endContract' | 'startContract', diff --git a/packages/1-framework/3-tooling/migration/src/exports/contract-snapshot-store.ts b/packages/1-framework/3-tooling/migration/src/exports/contract-snapshot-store.ts index 4e3e908b06f2..63654d916ba2 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/contract-snapshot-store.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/contract-snapshot-store.ts @@ -1,9 +1,12 @@ export { type ContractSnapshotInput, contractSnapshotDir, + createSnapshotContentVerifier, readContractSnapshotDts, readContractSnapshotJson, readContractSnapshotJsonTolerant, + type SnapshotCanonicalizationHooks, + type SnapshotContentVerifier, snapshotsImportPathFrom, writeContractSnapshot, } from '../contract-snapshot-store'; diff --git a/packages/1-framework/3-tooling/migration/src/io.ts b/packages/1-framework/3-tooling/migration/src/io.ts index 2778f1569365..3e06b1cd4240 100644 --- a/packages/1-framework/3-tooling/migration/src/io.ts +++ b/packages/1-framework/3-tooling/migration/src/io.ts @@ -3,6 +3,7 @@ import type { MigrationMetadata, MigrationPackage } from '@internal/framework-co import { ifDefined } from '@internal/utils/defined'; import { type } from 'arktype'; import { basename, dirname, join, resolve } from 'pathe'; +import type { SnapshotContentVerifier } from './contract-snapshot-store'; import { readContractSnapshotJsonTolerant } from './contract-snapshot-store'; import { errorDirectoryExists, @@ -26,6 +27,12 @@ const MAX_SLUG_LENGTH = 64; export interface ReadMigrationPackageOptions { readonly migrationsDir: string; + /** + * Content check for the package's end-contract snapshot; a snapshot whose + * content does not reproduce its address is treated as absent by the + * tolerant read (the strict store reads report it loudly). + */ + readonly verifySnapshotContent?: SnapshotContentVerifier; } function hasErrnoCode(error: unknown, code: string): boolean { @@ -236,6 +243,7 @@ export async function readMigrationPackage( const endContractJson = await readContractSnapshotJsonTolerant( options.migrationsDir, metadata.to, + options.verifySnapshotContent, ); const pkg: OnDiskMigrationPackage = { dirName: basename(absoluteDir), diff --git a/packages/1-framework/3-tooling/migration/test/contract-snapshot-verify.test.ts b/packages/1-framework/3-tooling/migration/test/contract-snapshot-verify.test.ts new file mode 100644 index 000000000000..b190bb7af9ba --- /dev/null +++ b/packages/1-framework/3-tooling/migration/test/contract-snapshot-verify.test.ts @@ -0,0 +1,181 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { computeStorageHash } from '@internal/contract/hashing'; +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + contractSnapshotDir, + createSnapshotContentVerifier, + readContractSnapshotJson, + readContractSnapshotJsonTolerant, + writeContractSnapshot, +} from '../src/contract-snapshot-store'; +import { MigrationToolsError } from '../src/errors'; + +const TARGET = 'postgres'; +const TARGET_FAMILY = 'sql'; + +function genuineContract(storage: Record) { + const storageHash = computeStorageHash({ target: TARGET, targetFamily: TARGET_FAMILY, storage }); + return { + contractJson: { + storage: { ...storage, storageHash }, + target: TARGET, + targetFamily: TARGET_FAMILY, + }, + storageHash: storageHash as string, + }; +} + +describe('createSnapshotContentVerifier', () => { + it('accepts a contract whose content reproduces its storage hash', () => { + const { contractJson, storageHash } = genuineContract({ namespaces: {} }); + + const verifier = createSnapshotContentVerifier(); + + expect(() => + verifier.assertSnapshotContentMatches(contractJson, storageHash, '/store/contract.json'), + ).not.toThrow(); + }); + + it('throws CONTRACT_SNAPSHOT_CONTENT_MISMATCH naming the file and both hashes', () => { + const { contractJson, storageHash } = genuineContract({ namespaces: {} }); + const tampered = { + ...contractJson, + storage: { ...contractJson.storage, namespaces: { sneaky: { entries: {} } } }, + }; + + const verifier = createSnapshotContentVerifier(); + let thrown: unknown; + try { + verifier.assertSnapshotContentMatches(tampered, storageHash, '/store/contract.json'); + } catch (error) { + thrown = error; + } + + expect(MigrationToolsError.is(thrown)).toBe(true); + const error = thrown as MigrationToolsError; + expect(error.code).toBe('MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH'); + expect(error.why).toContain('/store/contract.json'); + expect(error.why).toContain(storageHash); + expect(error.meta).toMatchObject({ + storageHash, + computedHash: expect.stringMatching(/^[0-9a-f]{64}$/), + jsonPath: '/store/contract.json', + }); + expect(error.meta?.['computedHash']).not.toBe(storageHash); + }); + + it('recomputes each hash once per verifier instance', () => { + let hookCalls = 0; + const hooks = { + shouldPreserveEmpty: () => { + hookCalls += 1; + return false; + }, + }; + const storage = { namespaces: { a: { entries: {} } } }; + const storageHash = computeStorageHash({ + target: TARGET, + targetFamily: TARGET_FAMILY, + storage, + ...hooks, + }); + const contractJson = { + storage: { ...storage, storageHash }, + target: TARGET, + targetFamily: TARGET_FAMILY, + }; + + const verifier = createSnapshotContentVerifier(hooks); + verifier.assertSnapshotContentMatches(contractJson, storageHash, 'p'); + const callsAfterFirst = hookCalls; + verifier.assertSnapshotContentMatches(contractJson, storageHash, 'p'); + + expect(callsAfterFirst).toBeGreaterThan(0); + expect(hookCalls).toBe(callsAfterFirst); + }); +}); + +describe('readContractSnapshotJson content verification', () => { + let migrationsDir: string; + + beforeEach(async () => { + migrationsDir = await mkdtemp(join(tmpdir(), 'contract-snapshot-verify-')); + }); + + afterEach(async () => { + await rm(migrationsDir, { recursive: true, force: true }); + }); + + it('returns an untampered snapshot when a verifier is supplied', async () => { + const { contractJson, storageHash } = genuineContract({ namespaces: {} }); + await writeContractSnapshot(migrationsDir, storageHash, { + contractJson, + contractDts: 'export type Contract = {};', + }); + + const read = await readContractSnapshotJson( + migrationsDir, + storageHash, + createSnapshotContentVerifier(), + ); + + expect(read).toEqual(contractJson); + }); + + it('rejects a snapshot whose content was edited under an unchanged hash field', async () => { + const { contractJson, storageHash } = genuineContract({ namespaces: {} }); + await writeContractSnapshot(migrationsDir, storageHash, { + contractJson, + contractDts: 'export type Contract = {};', + }); + const jsonPath = join(contractSnapshotDir(migrationsDir, storageHash), 'contract.json'); + const tampered = { + ...contractJson, + storage: { ...contractJson.storage, namespaces: { sneaky: { entries: {} } } }, + }; + await writeFile(jsonPath, JSON.stringify(tampered), 'utf-8'); + + await expect( + readContractSnapshotJson(migrationsDir, storageHash, createSnapshotContentVerifier()), + ).rejects.toMatchObject({ code: 'MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH' }); + }); + + it('tolerant read resolves a tampered snapshot to undefined instead of returning it', async () => { + const { contractJson, storageHash } = genuineContract({ namespaces: {} }); + await writeContractSnapshot(migrationsDir, storageHash, { + contractJson, + contractDts: 'export type Contract = {};', + }); + const jsonPath = join(contractSnapshotDir(migrationsDir, storageHash), 'contract.json'); + const tampered = { + ...contractJson, + storage: { ...contractJson.storage, namespaces: { sneaky: { entries: {} } } }, + }; + await writeFile(jsonPath, JSON.stringify(tampered), 'utf-8'); + + await expect( + readContractSnapshotJsonTolerant(migrationsDir, storageHash, createSnapshotContentVerifier()), + ).resolves.toBeUndefined(); + await expect(readContractSnapshotJsonTolerant(migrationsDir, storageHash)).resolves.toEqual( + tampered, + ); + }); + + it('reads without verification when no verifier is supplied', async () => { + const { contractJson, storageHash } = genuineContract({ namespaces: {} }); + await writeContractSnapshot(migrationsDir, storageHash, { + contractJson, + contractDts: 'export type Contract = {};', + }); + const jsonPath = join(contractSnapshotDir(migrationsDir, storageHash), 'contract.json'); + const tampered = { + ...contractJson, + storage: { ...contractJson.storage, namespaces: { x: 1 } }, + }; + await writeFile(jsonPath, JSON.stringify(tampered), 'utf-8'); + + await expect(readContractSnapshotJson(migrationsDir, storageHash)).resolves.toEqual(tampered); + }); +}); From 2e0677e7e0537f8b39043f6134591e92d249b906 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 20 Aug 2026 14:45:30 +0200 Subject: [PATCH 2/2] TML-2566: recompute snapshot hashes with the family emit hooks, not the serializer preserve set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: ContractSerializer gains hashCanonicalizationHooks (the hooks the emit pipeline hashed with; the sql/mongo serializer bases publish their family hooks) and the verifier is built from those — the postgres serializer preserve set is broader and false-positived on untampered restrictive-policy contracts, locked by a new regression test. One shared recomputePublishedStorageHash helper now serves both the verifier and assertDescriptorSelfConsistency; one verifier per command run (client instance / check run) so the memo spans loads; the tolerant read swallows only the mismatch code; migration check derives its finding from the verifier error; inline conditional spreads swept to ifDefined; the storageHash pin on defineContract is doc-marked test-only. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/control/contract-serializer.ts | 16 +++++ .../3-tooling/cli/src/control-api/client.ts | 13 ++-- .../contract-space-aggregate-loader.ts | 11 ++- .../control-api/operations/migration-check.ts | 22 +++--- .../control-api/operations/migration-new.ts | 3 +- .../control-api/operations/migration-plan.ts | 5 +- .../3-tooling/cli/src/orm/migration/check.ts | 14 +++- .../utils/snapshot-content-verification.ts | 31 ++++---- .../orm/migration-snapshot-content.test.ts | 7 +- .../migration/src/aggregate/loader.ts | 9 +-- .../src/assert-descriptor-self-consistency.ts | 25 ++----- .../migration/src/contract-snapshot-store.ts | 69 +++++++----------- .../3-tooling/migration/src/hash.ts | 45 ++++++++++++ .../core/ir/mongo-contract-serializer-base.ts | 2 + .../contract-ts/src/contract-definition.ts | 7 ++ .../core/ir/sql-contract-serializer-base.ts | 2 + .../contract-serializer-hash-hooks.test.ts | 71 +++++++++++++++++++ 17 files changed, 253 insertions(+), 99 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/test/contract-serializer-hash-hooks.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/control/contract-serializer.ts b/packages/1-framework/1-core/framework-components/src/control/contract-serializer.ts index 1ff7d33ee19d..e16f646e2281 100644 --- a/packages/1-framework/1-core/framework-components/src/control/contract-serializer.ts +++ b/packages/1-framework/1-core/framework-components/src/control/contract-serializer.ts @@ -59,4 +59,20 @@ export interface ContractSerializer { * arrays (e.g. SQL `indexes`/`uniques`) supply this hook. */ readonly sortStorage?: StorageSort; + + /** + * The canonicalization hooks the family's emit pipeline computes storage + * hashes with. Distinct from {@link shouldPreserveEmpty} / + * {@link sortStorage}: those govern on-disk serialization and may be + * broader per target (Postgres preserves required entity-kind fields at + * default values so the persisted contract re-deserializes), while a hash + * recompute must reproduce the exact canonical form the published + * `storageHash` was derived from. Integrity checks that recompute storage + * hashes (snapshot content verification, descriptor self-consistency) + * must use these hooks, never the serialization pair. + */ + readonly hashCanonicalizationHooks?: { + readonly shouldPreserveEmpty?: PreserveEmptyPredicate; + readonly sortStorage?: StorageSort; + }; } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/client.ts b/packages/1-framework/3-tooling/cli/src/control-api/client.ts index 246347a80d04..f860995b32a3 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/client.ts @@ -24,11 +24,11 @@ import { hasSchemaView, } from '@internal/framework-components/control'; import type { PslDocumentAst } from '@internal/framework-components/psl-ast'; +import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok } from '@internal/utils/result'; import { structuredError } from '@internal/utils/structured-error'; - import { assertFrameworkComponentsCompatible } from '../utils/framework-components'; import { snapshotVerifierFor } from '../utils/snapshot-content-verification'; import { enrichContract } from './contract-enrichment'; @@ -85,10 +85,13 @@ class ControlClientImpl implements ControlClient { > | null = null; private initialized = false; private readonly defaultConnection: unknown; + /** One per client so the verified-hash memo spans operations (e.g. db update's pre-plan + consented apply). */ + private readonly snapshotVerifier: SnapshotContentVerifier | undefined; constructor(options: ControlClientOptions) { this.options = options; this.defaultConnection = options.connection; + this.snapshotVerifier = snapshotVerifierFor(options); } init(): void { @@ -415,7 +418,7 @@ class ControlClientImpl implements ControlClient { migrationsDir: options.migrationsDir, targetId: this.options.target.targetId, extensions: this.options.extensions ?? [], - ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), + ...ifDefined('verifySnapshotContent', this.snapshotVerifier), ...ifDefined('onProgress', onProgress), }); } @@ -455,7 +458,7 @@ class ControlClientImpl implements ControlClient { extensions: this.options.extensions ?? [], ...ifDefined('acceptDataLoss', options.acceptDataLoss), ...ifDefined('consent', options.consent), - ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), + ...ifDefined('verifySnapshotContent', this.snapshotVerifier), ...ifDefined('onProgress', onProgress), }); } @@ -476,7 +479,7 @@ class ControlClientImpl implements ControlClient { mode: options.strict ? 'strict' : 'lenient', skipSchema: options.skipSchema, skipMarker: options.skipMarker, - ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), + ...ifDefined('verifySnapshotContent', this.snapshotVerifier), ...ifDefined('onProgress', onProgress), }); } @@ -533,7 +536,7 @@ class ControlClientImpl implements ControlClient { ...ifDefined('refHash', options.refHash), ...ifDefined('refInvariants', options.refInvariants), ...ifDefined('refName', options.refName), - ...ifDefined('verifySnapshotContent', snapshotVerifierFor(this.options)), + ...ifDefined('verifySnapshotContent', this.snapshotVerifier), ...ifDefined('onProgress', onProgress), }); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts index b0f1eb370ec0..16c57629a49d 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-space-aggregate-loader.ts @@ -354,7 +354,11 @@ export async function loadContractRawSafely(config: { */ export async function buildReadAggregate( config: PrismaNextConfig, - options: { readonly migrationsDir: string }, + options: { + readonly migrationsDir: string; + /** Command-scoped verifier to share the verified-hash memo; defaults to one built from `config`. */ + readonly verifySnapshotContent?: SnapshotContentVerifier; + }, ): Promise< Result< { readonly aggregate: ContractSpaceAggregate; readonly contractHash: string }, @@ -394,7 +398,10 @@ export async function buildReadAggregate( appContract: appContractForLoad, extensions: config.extensions ?? [], deserializeContract, - ...ifDefined('verifySnapshotContent', snapshotVerifierFor(config)), + ...ifDefined( + 'verifySnapshotContent', + options.verifySnapshotContent ?? snapshotVerifierFor(config), + ), }); if (!loaded.ok) { return loaded; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts index 78ea468d429f..d56501c1cbea 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-check.ts @@ -53,7 +53,6 @@ import { resolveTargetPathAcrossSpaces, } from '../../utils/migration-path-target'; import { chooseAction, runCommandAction } from '../../utils/next-actions'; -import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; function migrationPathRelative(cwd: string, dirPath: string): string { return relative(cwd, dirPath); @@ -153,14 +152,21 @@ async function checkSnapshotConsistency( }; } if (space.verifySnapshotContent !== undefined) { - const computedHash = space.verifySnapshotContent.recomputeStorageHash(raw); - if (computedHash !== pkg.metadata.to) { - const jsonPath = join(snapshotDir, 'contract.json'); + const jsonPath = join(snapshotDir, 'contract.json'); + try { + space.verifySnapshotContent.assertSnapshotContentMatches(raw, pkg.metadata.to, jsonPath); + } catch (error) { + if ( + !MigrationToolsError.is(error) || + error.code !== 'MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH' + ) { + throw error; + } return { space: spaceId, code: 'MIGRATION.CHECK_SNAPSHOT_CONTENT_MISMATCH', where: migrationPathRelative(space.cwd, jsonPath), - why: `Migration "${pkg.dirName}" is addressed by contract snapshot ${pkg.metadata.to}, but the content of ${jsonPath} recomputes to storage hash ${computedHash} — the snapshot has been edited since it was written.`, + why: `Migration "${pkg.dirName}": ${error.why}`, nextActions: [ chooseAction('Restore migrations/snapshots/ from version control'), chooseAction( @@ -230,7 +236,7 @@ export async function enumerateCheckSpaces( refsDir: spaceRefsDirectory(migrationsDir), projectMigrationsDir, cwd, - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); } return spaces; @@ -370,6 +376,7 @@ export async function runMigrationCheck( export async function loadAggregateIntegrityViolations( config: PrismaNextConfig, migrationsDir: string, + verifySnapshotContent?: SnapshotContentVerifier, ): Promise { try { const contractJsonContent = await readFile(resolveContractPath(config), 'utf-8'); @@ -377,12 +384,11 @@ export async function loadAggregateIntegrityViolations( const declaredExtensions = toDeclaredExtensionsFromRaw(config.extensions ?? []); const parsedAppContract: unknown = JSON.parse(contractJsonContent); - const verifySnapshotContent = snapshotVerifierFor(config); const aggregate = await loadContractSpaceAggregate({ migrationsDir, deserializeContract: (json: unknown) => familyInstance.deserializeContract(json), appContract: familyInstance.deserializeContract(parsedAppContract), - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); return aggregate.checkIntegrity({ declaredExtensions, checkContracts: true }); } catch { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts index cd1399f65571..efe90171be2a 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-new.ts @@ -18,6 +18,7 @@ import { formatMigrationDirName, writeMigrationPackage } from '@internal/migrati import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { findLatestMigration } from '@internal/migration-tools/migration-graph'; import { writeMigrationTs } from '@internal/migration-tools/migration-ts'; +import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { join, relative } from 'pathe'; import { @@ -118,7 +119,7 @@ export async function executeMigrationNewCommand( migrationsDir, deserializeContract: (json) => familyInstance.deserializeContract(json), appContract: toContract, - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); const packageCorruptionFailure = refusePackageCorruptionOnAggregate(aggregate, migrationsDir); if (packageCorruptionFailure) { diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts index 5f7e75f5ea36..eb91e77eba4e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-plan.ts @@ -25,6 +25,7 @@ import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import { writeMigrationTs } from '@internal/migration-tools/migration-ts'; import type { ImportSpecifierResolver } from '@internal/publish-surface/import-roots'; import { castAs } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { join, relative } from 'pathe'; import { @@ -337,7 +338,7 @@ async function executeMigrationPlanCommandInner( appContract: toContract, extensions: config.extensions ?? [], deserializeContract: (json: unknown) => familyInstance.deserializeContract(json), - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); if (!tolerantAggregateResult.ok) { return notOk(tolerantAggregateResult.failure); @@ -462,7 +463,7 @@ async function executeMigrationPlanCommandInner( appContract: toContract, extensions: config.extensions ?? [], deserializeContract: (json: unknown) => familyInstance.deserializeContract(json), - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); if (!aggregateResult.ok) { return notOk(aggregateResult.failure); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts index 2745f1ed9eb4..f32a2e8e4ffe 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/check.ts @@ -130,7 +130,11 @@ export const migrationCheckCommand = defineOrmCommand({ const appMigrationsDir = appMigrationsDirFor(ctx.config, ctx.cwd); const appMigrationsRelative = displayPath(appMigrationsDir, ctx.cwd); - const loaded = await buildReadAggregate(ctx.config, { migrationsDir }); + const verifySnapshotContent = snapshotVerifierFor(ctx.config); + const loaded = await buildReadAggregate(ctx.config, { + migrationsDir, + ...ifDefined('verifySnapshotContent', verifySnapshotContent), + }); if (!loaded.ok) { return notOk(normalizeError(loaded.failure)); } @@ -138,7 +142,7 @@ export const migrationCheckCommand = defineOrmCommand({ loaded.value.aggregate, migrationsDir, ctx.cwd, - snapshotVerifierFor(ctx.config), + verifySnapshotContent, ); let document: MigrationCheckResult; @@ -175,7 +179,11 @@ export const migrationCheckCommand = defineOrmCommand({ if (!checked.ok) { return notOk(normalizeError(checked.failure)); } - const violations = await loadAggregateIntegrityViolations(ctx.config, migrationsDir); + const violations = await loadAggregateIntegrityViolations( + ctx.config, + migrationsDir, + verifySnapshotContent, + ); const scoped = spaceFilter === undefined ? violations diff --git a/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts b/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts index 09739f9c0021..661c9f3a8dab 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/snapshot-content-verification.ts @@ -3,24 +3,29 @@ import type { SnapshotContentVerifier, } from '@internal/migration-tools/contract-snapshot-store'; import { createSnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; -import { ifDefined } from '@internal/utils/defined'; /** - * Build the per-command snapshot content verifier from the target's - * `ContractSerializer`, which carries the family canonicalization hooks the - * emit pipeline hashed with. Every target descriptor ships the serializer; - * the absent case exists only for structural test stand-ins, which read - * without content verification. + * Build the per-command snapshot content verifier from the target + * serializer's `hashCanonicalizationHooks` — the hooks the family's emit + * pipeline computed the published storage hash with. The serializer's own + * `shouldPreserveEmpty` / `sortStorage` must NOT be used here: they govern + * on-disk serialization and may be broader per target (Postgres preserves + * required entity-kind fields at default values), so recomputing with them + * would reject untampered snapshots. A serializer that does not declare its + * hashing hooks (structural test stand-ins) reads without content + * verification. Build one verifier per command run and share it across + * every load, so each snapshot hash is recomputed at most once per run. */ export function snapshotVerifierFor(config: { - readonly target: { readonly contractSerializer?: SnapshotCanonicalizationHooks }; + readonly target: { + readonly contractSerializer?: { + readonly hashCanonicalizationHooks?: SnapshotCanonicalizationHooks; + }; + }; }): SnapshotContentVerifier | undefined { - const serializer = config.target.contractSerializer; - if (serializer === undefined) { + const hooks = config.target.contractSerializer?.hashCanonicalizationHooks; + if (hooks === undefined) { return undefined; } - return createSnapshotContentVerifier({ - ...ifDefined('shouldPreserveEmpty', serializer.shouldPreserveEmpty), - ...ifDefined('sortStorage', serializer.sortStorage), - }); + return createSnapshotContentVerifier(hooks); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts index 4702ff5ff4d1..d45c447f359f 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-snapshot-content.test.ts @@ -35,7 +35,12 @@ function harness(project: OfflineProject) { return createTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, - config: { orm: { ...config, target: { ...target, contractSerializer: {} } } }, + config: { + orm: { + ...config, + target: { ...target, contractSerializer: { hashCanonicalizationHooks: {} } }, + }, + }, }); } diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts b/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts index 607cef55a1fd..0eb13ee16cdf 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/loader.ts @@ -1,4 +1,5 @@ import type { Contract } from '@internal/contract/types'; +import { ifDefined } from '@internal/utils/defined'; import type { SnapshotContentVerifier } from '../contract-snapshot-store'; import { readContractSnapshotJson } from '../contract-snapshot-store'; import { errorSpaceHeadRefMissing, MigrationToolsError } from '../errors'; @@ -101,7 +102,7 @@ async function loadAppSpace( const spaceDir = spaceMigrationDirectory(migrationsDir, APP_SPACE_ID); const { packages, problems } = await readMigrationsDir(spaceDir, { migrationsDir, - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir)); @@ -114,7 +115,7 @@ async function loadAppSpace( migrationsDir, resolveContract: () => appContract, deserializeContract, - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); // The app head ref is synthesised from the live contract, so there is @@ -157,7 +158,7 @@ async function loadExtensionSpace( const spaceDir = spaceMigrationDirectory(migrationsDir, spaceId); const { packages, problems } = await readMigrationsDir(spaceDir, { migrationsDir, - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); const { refs, problems: refProblems } = await readRefsTolerant(spaceRefsDirectory(spaceDir)); const { headRef, problem: headRefProblem } = await readHeadRefTolerant(migrationsDir, spaceId); @@ -178,7 +179,7 @@ async function loadExtensionSpace( migrationsDir, resolveContract: () => deserializeContract(rawContract()), deserializeContract, - ...(verifySnapshotContent !== undefined ? { verifySnapshotContent } : {}), + ...ifDefined('verifySnapshotContent', verifySnapshotContent), }); return { space, problems, refProblems, headRefProblem, isApp: false }; diff --git a/packages/1-framework/3-tooling/migration/src/assert-descriptor-self-consistency.ts b/packages/1-framework/3-tooling/migration/src/assert-descriptor-self-consistency.ts index ba3db87c49e9..355d6fec2c21 100644 --- a/packages/1-framework/3-tooling/migration/src/assert-descriptor-self-consistency.ts +++ b/packages/1-framework/3-tooling/migration/src/assert-descriptor-self-consistency.ts @@ -1,7 +1,7 @@ import type { PreserveEmptyPredicate, StorageSort } from '@internal/contract/hashing'; -import { computeStorageHash } from '@internal/contract/hashing'; import { ifDefined } from '@internal/utils/defined'; import { errorDescriptorHeadHashMismatch } from './errors'; +import { recomputePublishedStorageHash } from './hash'; /** * Inputs the helper needs to recompute the descriptor's storage hash and @@ -46,25 +46,14 @@ export interface DescriptorSelfConsistencyInputs { * clear remediation hint without re-deriving them. */ export function assertDescriptorSelfConsistency(inputs: DescriptorSelfConsistencyInputs): void { - // The published `storage.storageHash` is the *output* of the production - // emit pipeline's `computeStorageHash` call, computed over a storage - // object that did not yet carry `storageHash`. Recomputing against the - // published storage as-is would feed the result back into its own input - // and produce a different digest. Strip `storageHash` before - // recomputing so the helper sees the same canonical shape the - // descriptor's authoring pipeline saw. - // The helper requires only a plain record-shaped storage value at - // runtime; a single cast here keeps the public input type - // family-agnostic (`unknown`) while still letting us strip the - // descriptor-published `storageHash` before re-canonicalising. - const storageRecord = inputs.storage as Record; - const { storageHash: _stripped, ...storageWithoutHash } = storageRecord; - const recomputed = computeStorageHash({ + const recomputed = recomputePublishedStorageHash({ target: inputs.target, targetFamily: inputs.targetFamily, - storage: storageWithoutHash, - ...ifDefined('shouldPreserveEmpty', inputs.shouldPreserveEmpty), - ...ifDefined('sortStorage', inputs.sortStorage), + storage: inputs.storage, + hooks: { + ...ifDefined('shouldPreserveEmpty', inputs.shouldPreserveEmpty), + ...ifDefined('sortStorage', inputs.sortStorage), + }, }); if (recomputed !== inputs.headRefHash) { throw errorDescriptorHeadHashMismatch({ diff --git a/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts b/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts index 12c550336a83..1b29630de6e8 100644 --- a/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts +++ b/packages/1-framework/3-tooling/migration/src/contract-snapshot-store.ts @@ -1,18 +1,20 @@ import { randomBytes } from 'node:crypto'; import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; -import type { PreserveEmptyPredicate, StorageSort } from '@internal/contract/hashing'; -import { computeStorageHash } from '@internal/contract/hashing'; import { CONTRACT_SNAPSHOTS_DIRNAME, storageHashHex } from '@internal/framework-components/control'; import { canonicalizeJson } from '@internal/framework-components/utils'; import { blindCast } from '@internal/utils/casts'; -import { ifDefined } from '@internal/utils/defined'; import { join, relative } from 'pathe'; import { errorContractSnapshotContentMismatch, errorContractSnapshotHashMismatch, errorContractSnapshotMissing, errorInvalidJson, + MigrationToolsError, } from './errors'; +import type { SnapshotCanonicalizationHooks } from './hash'; +import { recomputePublishedStorageHash } from './hash'; + +export type { SnapshotCanonicalizationHooks } from './hash'; const CONTRACT_JSON_FILE = 'contract.json'; const CONTRACT_DTS_FILE = 'contract.d.ts'; @@ -40,24 +42,16 @@ export function contractSnapshotDir(migrationsDir: string, storageHash: string): return join(migrationsDir, CONTRACT_SNAPSHOTS_DIRNAME, storageHashHex(storageHash)); } -/** - * Family-contributed canonicalization hooks needed to reproduce the storage - * hash the emit pipeline computed. Sourced from the target's - * `ContractSerializer` (`shouldPreserveEmpty` / `sortStorage`); families - * without special-case storage paths supply neither. - */ -export interface SnapshotCanonicalizationHooks { - readonly shouldPreserveEmpty?: PreserveEmptyPredicate; - readonly sortStorage?: StorageSort; -} - /** * Recompute-and-compare integrity check for loaded contract snapshots, * mirroring `verifyMigrationHash` for migration packages and * `assertDescriptorSelfConsistency` for extension descriptors: the store is * content-addressed, so the JSON read back for a hash must reproduce that * hash. Verified hashes are memoised per instance, so a snapshot resolved - * repeatedly in one command run is hashed once. + * repeatedly in one command run is hashed once. The recompute is coupled to + * the emit-time canonicalization: a release that changes the family hooks + * or hash canonicalization rules must regenerate (or migrate) existing + * snapshot stores, or every pre-existing snapshot reads as tampered. */ export interface SnapshotContentVerifier { /** @@ -66,8 +60,6 @@ export interface SnapshotContentVerifier { * the snapshot at `jsonPath` was addressed by. */ assertSnapshotContentMatches(contractJson: unknown, storageHash: string, jsonPath: string): void; - /** The storage hash `contractJson`'s content canonicalizes to. */ - recomputeStorageHash(contractJson: unknown): string; } export function createSnapshotContentVerifier( @@ -75,34 +67,21 @@ export function createSnapshotContentVerifier( ): SnapshotContentVerifier { const verified = new Set(); - function recomputeStorageHash(contractJson: unknown): string { - const record = blindCast< - { target?: unknown; targetFamily?: unknown; storage?: unknown }, - 'contractJson is unknown JSON; only the identity fields the hash covers are read here' - >(contractJson ?? {}); - const storageRecord = blindCast< - Record, - 'the storage subtree is hashed as an opaque record; a non-record value simply fails the comparison' - >(record.storage ?? {}); - // The published hash was computed over a storage object that did not yet - // carry `storageHash`; strip it so the recompute sees the same shape. - const { storageHash: _addressed, ...storageWithoutHash } = storageRecord; - return computeStorageHash({ - target: typeof record.target === 'string' ? record.target : '', - targetFamily: typeof record.targetFamily === 'string' ? record.targetFamily : '', - storage: storageWithoutHash, - ...ifDefined('shouldPreserveEmpty', hooks?.shouldPreserveEmpty), - ...ifDefined('sortStorage', hooks?.sortStorage), - }); - } - return { - recomputeStorageHash, assertSnapshotContentMatches(contractJson, storageHash, jsonPath) { if (verified.has(storageHash)) { return; } - const computedHash = recomputeStorageHash(contractJson); + const record = blindCast< + { target?: unknown; targetFamily?: unknown; storage?: unknown }, + 'contractJson is unknown JSON; only the identity fields the hash covers are read here' + >(contractJson ?? {}); + const computedHash = recomputePublishedStorageHash({ + target: record.target, + targetFamily: record.targetFamily, + storage: record.storage, + hooks, + }); if (computedHash !== storageHash) { throw errorContractSnapshotContentMismatch({ storageHash, computedHash, jsonPath }); } @@ -248,8 +227,14 @@ export async function readContractSnapshotJsonTolerant( } try { verifyContent?.assertSnapshotContentMatches(parsed, storageHash, jsonPath); - } catch { - return undefined; + } catch (error) { + if ( + MigrationToolsError.is(error) && + error.code === 'MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH' + ) { + return undefined; + } + throw error; } return parsed; } diff --git a/packages/1-framework/3-tooling/migration/src/hash.ts b/packages/1-framework/3-tooling/migration/src/hash.ts index 4d1edcb27b3b..86683a2f26f1 100644 --- a/packages/1-framework/3-tooling/migration/src/hash.ts +++ b/packages/1-framework/3-tooling/migration/src/hash.ts @@ -1,8 +1,53 @@ import { createHash } from 'node:crypto'; +import type { PreserveEmptyPredicate, StorageSort } from '@internal/contract/hashing'; +import { computeStorageHash } from '@internal/contract/hashing'; import { canonicalizeJson } from '@internal/framework-components/utils'; +import { blindCast } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import type { MigrationMetadata } from './metadata'; import type { MigrationOps, OnDiskMigrationPackage } from './package'; +/** + * The canonicalization hooks a family's emit pipeline computes storage + * hashes with. Sourced from the target serializer's + * `hashCanonicalizationHooks` — never from its serialization-preserve + * hooks, which may be broader (Postgres preserves required entity-kind + * fields at default values on disk that the published hash canonicalized + * away). + */ +export interface SnapshotCanonicalizationHooks { + readonly shouldPreserveEmpty?: PreserveEmptyPredicate; + readonly sortStorage?: StorageSort; +} + +/** + * Recompute the storage hash a contract's content publishes as its + * identity. The published `storage.storageHash` is the output of the emit + * pipeline's `computeStorageHash` call over a storage object that did not + * yet carry `storageHash`, so the field is stripped before recomputing. + * Shared by snapshot content verification and descriptor + * self-consistency — the one place this invariant lives. + */ +export function recomputePublishedStorageHash(args: { + readonly target: unknown; + readonly targetFamily: unknown; + readonly storage: unknown; + readonly hooks: SnapshotCanonicalizationHooks | undefined; +}): string { + const storageRecord = blindCast< + Record, + 'the storage subtree is hashed as an opaque record; a non-record value simply fails the comparison' + >(args.storage ?? {}); + const { storageHash: _published, ...storageWithoutHash } = storageRecord; + return computeStorageHash({ + target: typeof args.target === 'string' ? args.target : '', + targetFamily: typeof args.targetFamily === 'string' ? args.targetFamily : '', + storage: storageWithoutHash, + ...ifDefined('shouldPreserveEmpty', args.hooks?.shouldPreserveEmpty), + ...ifDefined('sortStorage', args.hooks?.sortStorage), + }); +} + export interface VerifyResult { readonly ok: boolean; readonly reason?: 'mismatch'; diff --git a/packages/2-mongo-family/9-family/src/core/ir/mongo-contract-serializer-base.ts b/packages/2-mongo-family/9-family/src/core/ir/mongo-contract-serializer-base.ts index 7bc2a847d3ed..b944d8a9a5f5 100644 --- a/packages/2-mongo-family/9-family/src/core/ir/mongo-contract-serializer-base.ts +++ b/packages/2-mongo-family/9-family/src/core/ir/mongo-contract-serializer-base.ts @@ -76,6 +76,8 @@ export abstract class MongoContractSerializerBase */ shouldPreserveEmpty = mongoContractCanonicalizationHooks.shouldPreserveEmpty; + hashCanonicalizationHooks = mongoContractCanonicalizationHooks; + /** * Family-shared structural validation: parse against the Mongo * contract arktype schema, then run framework-shared domain + Mongo diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index 7dbf044c65f7..8549ebd42fed 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -223,6 +223,13 @@ export interface ContractDefinition { readonly target: TargetPackRef<'sql', string>; readonly defaultControlPolicy?: ControlPolicy; readonly extensions?: Record>; + /** + * Test-fixture escape hatch: pins the emitted `storage.storageHash` + * instead of computing it from content. A pinned hash is not + * content-derived, so snapshot content verification + * (`MIGRATION.CONTRACT_SNAPSHOT_CONTENT_MISMATCH`) rejects any migration + * snapshot addressed by it — never set this in a real project. + */ readonly storageHash?: string; readonly foreignKeyDefaults?: ForeignKeyDefaultsState; readonly storageTypes?: Record; diff --git a/packages/2-sql/9-family/src/core/ir/sql-contract-serializer-base.ts b/packages/2-sql/9-family/src/core/ir/sql-contract-serializer-base.ts index d405c45f16f1..1a7ddd7fe7b2 100644 --- a/packages/2-sql/9-family/src/core/ir/sql-contract-serializer-base.ts +++ b/packages/2-sql/9-family/src/core/ir/sql-contract-serializer-base.ts @@ -92,6 +92,8 @@ export abstract class SqlContractSerializerBase { return validateSqlContractFully>( json, diff --git a/packages/3-targets/3-targets/postgres/test/contract-serializer-hash-hooks.test.ts b/packages/3-targets/3-targets/postgres/test/contract-serializer-hash-hooks.test.ts new file mode 100644 index 000000000000..7cb728eb61e3 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/contract-serializer-hash-hooks.test.ts @@ -0,0 +1,71 @@ +import { computeStorageHash } from '@internal/contract/hashing'; +import { createSnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; +import { sqlContractCanonicalizationHooks } from '@internal/sql-contract/canonicalization-hooks'; +import { describe, expect, it } from 'vitest'; +import { PostgresContractSerializer } from '../src/core/postgres-contract-serializer'; + +/** + * A storage subtree whose canonical form differs between the emit-time + * family hooks and the serializer's on-disk preserve set: a RESTRICTIVE + * policy's required `permissive: false` is canonicalized away by the family + * hooks (so it is absent from the published hash's input) but preserved on + * disk by the serializer so the contract re-deserializes. + */ +const STORAGE_WITH_RESTRICTIVE_POLICY = { + namespaces: { + public: { + entries: { + policy: { + tenant_isolation: { permissive: false, table: 'orders', using: 'true' }, + }, + table: { orders: { columns: { id: {} } } }, + }, + }, + }, +}; + +describe('PostgresContractSerializer hash canonicalization hooks', () => { + const serializer = new PostgresContractSerializer(); + + it('publishes the family emit hooks as hashCanonicalizationHooks', () => { + expect(serializer.hashCanonicalizationHooks).toBe(sqlContractCanonicalizationHooks); + }); + + it('snapshot verification recomputes the emit-time hash for a restrictive-policy contract', () => { + const emitHash = computeStorageHash({ + target: 'postgres', + targetFamily: 'sql', + storage: STORAGE_WITH_RESTRICTIVE_POLICY, + ...sqlContractCanonicalizationHooks, + }); + const contractJson = { + storage: { ...STORAGE_WITH_RESTRICTIVE_POLICY, storageHash: emitHash }, + target: 'postgres', + targetFamily: 'sql', + }; + + const verifier = createSnapshotContentVerifier(serializer.hashCanonicalizationHooks); + + expect(() => + verifier.assertSnapshotContentMatches(contractJson, emitHash, '/store/contract.json'), + ).not.toThrow(); + }); + + it('the serialization-preserve hooks would NOT reproduce the emit-time hash', () => { + const emitHash = computeStorageHash({ + target: 'postgres', + targetFamily: 'sql', + storage: STORAGE_WITH_RESTRICTIVE_POLICY, + ...sqlContractCanonicalizationHooks, + }); + const serializationHash = computeStorageHash({ + target: 'postgres', + targetFamily: 'sql', + storage: STORAGE_WITH_RESTRICTIVE_POLICY, + shouldPreserveEmpty: serializer.shouldPreserveEmpty, + sortStorage: serializer.sortStorage, + }); + + expect(serializationHash).not.toBe(emitHash); + }); +});