From b2b4360b3cb95c24bc7f9adcdb96bba424f719ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:53:03 +0200 Subject: [PATCH 1/2] feat(model): add versioned contracts and validation (Task 1.1) Add schema version 1 records with validators that never throw on bad input and report every problem with a path and a stable code: candidate, project, requirement, report, exception and upload provenance. Validators reject unknown or future schema versions (without inspecting the rest of the record), unknown fields, malformed hashes, duplicate artifacts, requirements, suites and attempts, path traversal in file names and paths (including Windows device names, drive prefixes and alternate data streams), empty required fields, and references that fall outside a supplied candidate or requirement set. Built test-first: 169 tests, watched failing against always-rejecting stubs, and each rule then mutation-checked so a test fails when the rule is broken. Co-Authored-By: Claude Sonnet 5 --- packages/qa/src/index.ts | 19 +- packages/qa/src/model/candidate.ts | 71 +++++ packages/qa/src/model/context.ts | 24 ++ packages/qa/src/model/exception.ts | 39 +++ packages/qa/src/model/project.ts | 105 +++++++ packages/qa/src/model/requirement.ts | 38 +++ packages/qa/src/model/result.ts | 145 +++++++++ packages/qa/src/model/validate.ts | Bin 0 -> 11707 bytes packages/qa/test/fixtures/records.ts | 110 +++++++ packages/qa/test/model/contracts.test.ts | 368 +++++++++++++++++++++++ 10 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 packages/qa/src/model/candidate.ts create mode 100644 packages/qa/src/model/context.ts create mode 100644 packages/qa/src/model/exception.ts create mode 100644 packages/qa/src/model/project.ts create mode 100644 packages/qa/src/model/requirement.ts create mode 100644 packages/qa/src/model/result.ts create mode 100644 packages/qa/src/model/validate.ts create mode 100644 packages/qa/test/fixtures/records.ts create mode 100644 packages/qa/test/model/contracts.test.ts diff --git a/packages/qa/src/index.ts b/packages/qa/src/index.ts index d846d61..e635da2 100644 --- a/packages/qa/src/index.ts +++ b/packages/qa/src/index.ts @@ -1,2 +1,19 @@ -/** Public entry point of the Release QA package. Real modules arrive with Stage 1. */ +/** Public entry point of the Release QA package. */ export const toolName = 'release-qa'; + +export { parseCandidate, type Artifact, type Candidate } from './model/candidate.ts'; +export { parseException, type Exception } from './model/exception.ts'; +export { parseProject, type EnvironmentProfile, type Project, type Suite } from './model/project.ts'; +export { parseRequirement, type ExecutionMode, type Requirement, type RequirementKey } from './model/requirement.ts'; +export { + parseReport, + parseUploadProvenance, + type Attempt, + type MeasuredEnvironment, + type Outcome, + type Readiness, + type ReferenceContext, + type Report, + type UploadProvenance, +} from './model/result.ts'; +export { ValidationError, type IssueCode, type ParseResult, type ValidationIssue } from './model/validate.ts'; diff --git a/packages/qa/src/model/candidate.ts b/packages/qa/src/model/candidate.ts new file mode 100644 index 0000000..1cb58f0 --- /dev/null +++ b/packages/qa/src/model/candidate.ts @@ -0,0 +1,71 @@ +import { at, Collector, item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +export interface Artifact { + profile: string; + name: string; + sha256: string; + assetId: number; + actionsArtifactId: number; +} + +export interface Candidate { + schemaVersion: 1; + id: string; + repositoryId: number; + pullRequest: number; + sourceSha: string; + baseSha: string; + sourceTreeSha: string; + testRevision: string; + policyDigest: string; + build: { workflowPath: string; runId: number; attempt: number }; + artifacts: Artifact[]; +} + +const SPEC: FieldSpec = { + required: ['id', 'repositoryId', 'pullRequest', 'sourceSha', 'baseSha', 'sourceTreeSha', 'testRevision', 'policyDigest', 'build', 'artifacts'], +}; +const BUILD_SPEC: FieldSpec = { required: ['workflowPath', 'runId', 'attempt'] }; +const ARTIFACT_SPEC: FieldSpec = { required: ['profile', 'name', 'sha256', 'assetId', 'actionsArtifactId'] }; + +export function parseCandidate(input: unknown): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const build = c.record(rec.build, 'build', BUILD_SPEC); + const artifactValues = c.array(rec.artifacts, 'artifacts', { min: 1 }) ?? []; + const artifacts = artifactValues.map((value, i) => readArtifact(c, value, item('artifacts', i))); + + // An asset is one downloadable file; a profile cannot ship two files of the same name. + c.unique(artifacts.map((a, i) => ({ value: a?.assetId === undefined ? undefined : String(a.assetId), path: at(item('artifacts', i), 'assetId') }))); + c.unique(artifacts.map((a, i) => ({ value: a?.profile === undefined || a.name === undefined ? undefined : `${a.profile}/${a.name}`, path: at(item('artifacts', i), 'name') }))); + + return { + schemaVersion: 1, + id: c.id(rec.id, 'id'), + repositoryId: c.int(rec.repositoryId, 'repositoryId'), + pullRequest: c.int(rec.pullRequest, 'pullRequest'), + sourceSha: c.gitSha(rec.sourceSha, 'sourceSha'), + baseSha: c.gitSha(rec.baseSha, 'baseSha'), + sourceTreeSha: c.gitSha(rec.sourceTreeSha, 'sourceTreeSha'), + testRevision: c.gitSha(rec.testRevision, 'testRevision'), + policyDigest: c.sha256(rec.policyDigest, 'policyDigest'), + build: build && { + workflowPath: c.relativePath(build.workflowPath, 'build.workflowPath'), + runId: c.int(build.runId, 'build.runId'), + attempt: c.int(build.attempt, 'build.attempt'), + }, + artifacts, + } as Candidate; + }); +} + +function readArtifact(c: Collector, value: unknown, path: string): Artifact | undefined { + const rec = c.record(value, path, ARTIFACT_SPEC); + if (rec === undefined) return undefined; + return { + profile: c.profileId(rec.profile, at(path, 'profile')), + name: c.fileName(rec.name, at(path, 'name')), + sha256: c.sha256(rec.sha256, at(path, 'sha256')), + assetId: c.int(rec.assetId, at(path, 'assetId')), + actionsArtifactId: c.int(rec.actionsArtifactId, at(path, 'actionsArtifactId')), + } as Artifact; +} diff --git a/packages/qa/src/model/context.ts b/packages/qa/src/model/context.ts new file mode 100644 index 0000000..3231bff --- /dev/null +++ b/packages/qa/src/model/context.ts @@ -0,0 +1,24 @@ +import type { Candidate } from './candidate.ts'; +import type { RequirementKey } from './requirement.ts'; +import type { Collector } from './validate.ts'; + +/** What a record is allowed to refer to. Parsing a record alone cannot know this, so callers supply it. */ +export interface ReferenceContext { + /** When given, the record must belong to this candidate. */ + candidate?: Candidate; + /** When given, every requirement the record names must be one of these. */ + requirements?: readonly RequirementKey[]; +} + +/** A record that names a candidate must name the one it is being checked against. */ +export function checkCandidateId(c: Collector, context: ReferenceContext, candidateId: string | undefined, path: string): void { + if (context.candidate !== undefined && candidateId !== undefined && candidateId !== context.candidate.id) { + c.add('unknown-reference', path, `belongs to candidate "${candidateId}", not "${context.candidate.id}"`); + } +} + +export function checkRequirementKnown(c: Collector, context: ReferenceContext, key: RequirementKey | undefined, path: string): void { + if (context.requirements !== undefined && key !== undefined && !context.requirements.includes(key)) { + c.add('unknown-reference', path, `"${key}" is not a required check`); + } +} diff --git a/packages/qa/src/model/exception.ts b/packages/qa/src/model/exception.ts new file mode 100644 index 0000000..b8961e2 --- /dev/null +++ b/packages/qa/src/model/exception.ts @@ -0,0 +1,39 @@ +import { checkCandidateId, checkRequirementKnown, type ReferenceContext } from './context.ts'; +import type { RequirementKey } from './requirement.ts'; +import { item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +/** A request to accept named requirements as unmet for one candidate. It carries no authority by itself. */ +export interface Exception { + schemaVersion: 1; + id: string; + candidateId: string; + requirements: RequirementKey[]; + reason: string; + /** Claimed by the uploader; the maintainer's authority is verified separately. */ + actor: string; + /** ISO-8601 UTC, e.g. `2026-09-20T12:00:00Z`. */ + createdAt: string; +} + +const SPEC: FieldSpec = { required: ['id', 'candidateId', 'requirements', 'reason', 'actor', 'createdAt'] }; + +export function parseException(input: unknown, context: ReferenceContext = {}): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const candidateId = c.id(rec.candidateId, 'candidateId'); + checkCandidateId(c, context, candidateId, 'candidateId'); + + const requirements = (c.array(rec.requirements, 'requirements', { min: 1 }) ?? []).map((v, i) => c.requirementKey(v, item('requirements', i))); + c.unique(requirements.map((key, i) => ({ value: key, path: item('requirements', i) }))); + requirements.forEach((key, i) => checkRequirementKnown(c, context, key, item('requirements', i))); + + return { + schemaVersion: 1, + id: c.id(rec.id, 'id'), + candidateId, + requirements, + reason: c.text(rec.reason, 'reason', { max: 2000, multiline: true }), + actor: c.text(rec.actor, 'actor'), + createdAt: c.timestamp(rec.createdAt, 'createdAt'), + } as Exception; + }); +} diff --git a/packages/qa/src/model/project.ts b/packages/qa/src/model/project.ts new file mode 100644 index 0000000..deede2f --- /dev/null +++ b/packages/qa/src/model/project.ts @@ -0,0 +1,105 @@ +import { profileOf, readRequirement, type Requirement, type RequirementKey } from './requirement.ts'; +import { at, Collector, item, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +export interface EnvironmentProfile { + id: string; + os: 'windows' | 'linux'; + arch: 'x86_64'; +} + +export interface Suite { + id: string; + requirements: RequirementKey[]; +} + +/** The consumer's `qa/project.json`. Read as data only; discovery never executes project code. */ +export interface Project { + schemaVersion: 1; + projectId: string; + releaseBranch: string; + profiles: EnvironmentProfile[]; + requirements: Requirement[]; + suites: Suite[]; + scenarioFiles: string[]; + lifecycleModule: string; + workflows: { prepare: string; gate: string; publish: string }; + markers: { releaseNotes: string; qa: string }; +} + +const SPEC: FieldSpec = { + required: ['projectId', 'releaseBranch', 'profiles', 'requirements', 'suites', 'scenarioFiles', 'lifecycleModule', 'workflows', 'markers'], +}; +const PROFILE_SPEC: FieldSpec = { required: ['id', 'os', 'arch'] }; +const SUITE_SPEC: FieldSpec = { required: ['id', 'requirements'] }; +const WORKFLOWS_SPEC: FieldSpec = { required: ['prepare', 'gate', 'publish'] }; +const MARKERS_SPEC: FieldSpec = { required: ['releaseNotes', 'qa'] }; + +export function parseProject(input: unknown): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const profiles = (c.array(rec.profiles, 'profiles', { min: 1 }) ?? []).map((v, i) => readProfile(c, v, item('profiles', i))); + c.unique(profiles.map((p, i) => ({ value: p?.id, path: at(item('profiles', i), 'id') }))); + const profileIds = new Set(profiles.flatMap((p) => (p?.id === undefined ? [] : [p.id]))); + + const requirements = (c.array(rec.requirements, 'requirements', { min: 1 }) ?? []).map((v, i) => readRequirement(c, v, item('requirements', i))); + c.unique(requirements.map((r, i) => ({ value: r?.key, path: at(item('requirements', i), 'key') }))); + requirements.forEach((r, i) => { + if (r?.key !== undefined && !profileIds.has(profileOf(r.key))) { + c.add('unknown-reference', at(item('requirements', i), 'key'), `profile "${profileOf(r.key)}" is not defined by this project`); + } + }); + const definedKeys = new Set(requirements.flatMap((r) => (r?.key === undefined ? [] : [r.key]))); + + const suites = (c.array(rec.suites, 'suites') ?? []).map((v, i) => readSuite(c, v, item('suites', i), definedKeys)); + c.unique(suites.map((s, i) => ({ value: s?.id, path: at(item('suites', i), 'id') }))); + + const scenarioFiles = (c.array(rec.scenarioFiles, 'scenarioFiles') ?? []).map((v, i) => c.relativePath(v, item('scenarioFiles', i))); + + const workflows = c.record(rec.workflows, 'workflows', WORKFLOWS_SPEC); + const markers = c.record(rec.markers, 'markers', MARKERS_SPEC); + const releaseNotesMarker = markers && c.name(markers.releaseNotes, 'markers.releaseNotes'); + const qaMarker = markers && c.name(markers.qa, 'markers.qa'); + c.unique([ + { value: releaseNotesMarker, path: 'markers.releaseNotes' }, + { value: qaMarker, path: 'markers.qa' }, + ]); + + return { + schemaVersion: 1, + projectId: c.id(rec.projectId, 'projectId'), + releaseBranch: c.branchName(rec.releaseBranch, 'releaseBranch'), + profiles, + requirements, + suites, + scenarioFiles, + lifecycleModule: c.relativePath(rec.lifecycleModule, 'lifecycleModule'), + workflows: workflows && { + prepare: c.fileName(workflows.prepare, 'workflows.prepare'), + gate: c.fileName(workflows.gate, 'workflows.gate'), + publish: c.fileName(workflows.publish, 'workflows.publish'), + }, + markers: markers && { releaseNotes: releaseNotesMarker, qa: qaMarker }, + } as Project; + }); +} + +function readProfile(c: Collector, value: unknown, path: string): EnvironmentProfile | undefined { + const rec = c.record(value, path, PROFILE_SPEC); + if (rec === undefined) return undefined; + return { + id: c.profileId(rec.id, at(path, 'id')), + os: c.oneOf(rec.os, at(path, 'os'), ['windows', 'linux']), + arch: c.oneOf(rec.arch, at(path, 'arch'), ['x86_64']), + } as EnvironmentProfile; +} + +function readSuite(c: Collector, value: unknown, path: string, definedKeys: ReadonlySet): Suite | undefined { + const rec = c.record(value, path, SUITE_SPEC); + if (rec === undefined) return undefined; + const listPath = at(path, 'requirements'); + const keys = (c.array(rec.requirements, listPath, { min: 1 }) ?? []).map((v, i) => c.requirementKey(v, item(listPath, i))); + c.unique(keys.map((k, i) => ({ value: k, path: item(listPath, i) }))); + keys.forEach((k, i) => { + if (k !== undefined && !definedKeys.has(k)) c.add('unknown-reference', item(listPath, i), `"${k}" is not a requirement of this project`); + }); + return { id: c.id(rec.id, at(path, 'id')), requirements: keys } as Suite; +} diff --git a/packages/qa/src/model/requirement.ts b/packages/qa/src/model/requirement.ts new file mode 100644 index 0000000..252a558 --- /dev/null +++ b/packages/qa/src/model/requirement.ts @@ -0,0 +1,38 @@ +import { at, Collector, item, parseUnversioned, type FieldSpec, type ParseResult } from './validate.ts'; + +/** `/`, e.g. `windows/persistence`. */ +export type RequirementKey = `${string}/${string}`; +export type ExecutionMode = 'automated' | 'manual'; + +export interface Requirement { + key: RequirementKey; + mode: ExecutionMode; + title: string; + /** Capabilities the environment must provide, e.g. `display`, `audio`, `hardware`. */ + capabilities: string[]; +} + +const SPEC: FieldSpec = { required: ['key', 'mode', 'title', 'capabilities'] }; + +/** The profile half of a requirement key. */ +export const profileOf = (key: RequirementKey): string => key.slice(0, key.indexOf('/')); + +/** Reads a requirement nested in another record, reporting problems under `path`. */ +export function readRequirement(c: Collector, value: unknown, path: string): Requirement | undefined { + const rec = c.record(value, path, SPEC); + return rec === undefined ? undefined : readRequirementFields(c, rec, path); +} + +function readRequirementFields(c: Collector, rec: Record, path: string): Requirement { + const capabilities = (c.array(rec.capabilities, at(path, 'capabilities')) ?? []).map((v, i) => c.name(v, item(at(path, 'capabilities'), i))); + return { + key: c.requirementKey(rec.key, at(path, 'key')), + mode: c.oneOf(rec.mode, at(path, 'mode'), ['automated', 'manual']), + title: c.text(rec.title, at(path, 'title')), + capabilities, + } as Requirement; +} + +export function parseRequirement(input: unknown): ParseResult { + return parseUnversioned(input, SPEC, (c, rec) => readRequirementFields(c, rec, '')); +} diff --git a/packages/qa/src/model/result.ts b/packages/qa/src/model/result.ts new file mode 100644 index 0000000..bd2df83 --- /dev/null +++ b/packages/qa/src/model/result.ts @@ -0,0 +1,145 @@ +import type { Candidate } from './candidate.ts'; +import { checkCandidateId, checkRequirementKnown, type ReferenceContext } from './context.ts'; +import { profileOf, type RequirementKey } from './requirement.ts'; +import { at, Collector, item, parseUnversioned, parseVersioned, type FieldSpec, type ParseResult } from './validate.ts'; + +export type { ReferenceContext } from './context.ts'; + +export type Outcome = 'passed' | 'failed' | 'blocked' | 'cancelled' | 'interrupted'; +export type Readiness = 'blocked' | 'passed' | 'approved-with-exceptions'; + +export interface Attempt { + id: string; + requirement: RequirementKey; + outcome: Outcome; + /** The attempt this one retries. It may live in another report, so only self-reference is checked here. */ + retryOf?: string; + /** Relative paths of evidence files stored next to the report. */ + evidence: string[]; +} + +/** Facts the runner measured about the environment. Descriptive, never proof of authority. */ +export interface MeasuredEnvironment { + os: string; + osVersion: string; + arch: string; + capabilities: string[]; + toolVersion: string; +} + +export interface Report { + schemaVersion: 1; + id: string; + candidateId: string; + policyDigest: string; + testRevision: string; + profile: string; + /** Claimed by the uploader. Authority comes from {@link UploadProvenance}, never from this field. */ + actor: string; + machineId: string; + environment: MeasuredEnvironment; + attempts: Attempt[]; +} + +/** Recorded by the GitHub layer when it stores an upload; a report cannot attest to its own origin. */ +export interface UploadProvenance { + uploader: string; + uploadedAt: string; + assetId: number; +} + +const OUTCOMES = ['passed', 'failed', 'blocked', 'cancelled', 'interrupted'] as const; +const SPEC: FieldSpec = { + required: ['id', 'candidateId', 'policyDigest', 'testRevision', 'profile', 'actor', 'machineId', 'environment', 'attempts'], +}; +const ENVIRONMENT_SPEC: FieldSpec = { required: ['os', 'osVersion', 'arch', 'capabilities', 'toolVersion'] }; +const ATTEMPT_SPEC: FieldSpec = { required: ['id', 'requirement', 'outcome', 'evidence'], optional: ['retryOf'] }; +const PROVENANCE_SPEC: FieldSpec = { required: ['uploader', 'uploadedAt', 'assetId'] }; + +export function parseReport(input: unknown, context: ReferenceContext = {}): ParseResult { + return parseVersioned(input, SPEC, (c, rec) => { + const id = c.id(rec.id, 'id'); + const candidateId = c.id(rec.candidateId, 'candidateId'); + const policyDigest = c.sha256(rec.policyDigest, 'policyDigest'); + const testRevision = c.gitSha(rec.testRevision, 'testRevision'); + const profile = c.profileId(rec.profile, 'profile'); + const environment = readEnvironment(c, rec.environment, 'environment'); + + const attempts = (c.array(rec.attempts, 'attempts', { min: 1 }) ?? []).map((v, i) => readAttempt(c, v, item('attempts', i))); + c.unique(attempts.map((a, i) => ({ value: a?.id, path: at(item('attempts', i), 'id') }))); + attempts.forEach((a, i) => { + const path = at(item('attempts', i), 'requirement'); + if (a?.requirement !== undefined && profile !== undefined && profileOf(a.requirement) !== profile) { + c.add('mismatch', path, `requirement is for profile "${profileOf(a.requirement)}", the report is for "${profile}"`); + } + checkRequirementKnown(c, context, a?.requirement, path); + }); + + checkCandidateId(c, context, candidateId, 'candidateId'); + checkAgainstCandidate(c, context.candidate, { policyDigest, testRevision, profile }); + + return { + schemaVersion: 1, + id, + candidateId, + policyDigest, + testRevision, + profile, + actor: c.text(rec.actor, 'actor'), + machineId: c.text(rec.machineId, 'machineId'), + environment, + attempts, + } as Report; + }); +} + +function checkAgainstCandidate( + c: Collector, + candidate: Candidate | undefined, + report: { policyDigest: string | undefined; testRevision: string | undefined; profile: string | undefined }, +): void { + if (candidate === undefined) return; + if (report.policyDigest !== undefined && report.policyDigest !== candidate.policyDigest) c.add('mismatch', 'policyDigest', 'made against a different policy than the candidate'); + if (report.testRevision !== undefined && report.testRevision !== candidate.testRevision) c.add('mismatch', 'testRevision', 'made against different tests than the candidate'); + if (report.profile !== undefined && !candidate.artifacts.some((a) => a.profile === report.profile)) { + c.add('unknown-reference', 'profile', `the candidate has no artifact for profile "${report.profile}"`); + } +} + +function readEnvironment(c: Collector, value: unknown, path: string): MeasuredEnvironment | undefined { + const rec = c.record(value, path, ENVIRONMENT_SPEC); + if (rec === undefined) return undefined; + const capabilitiesPath = at(path, 'capabilities'); + return { + os: c.text(rec.os, at(path, 'os')), + osVersion: c.text(rec.osVersion, at(path, 'osVersion')), + arch: c.text(rec.arch, at(path, 'arch')), + capabilities: (c.array(rec.capabilities, capabilitiesPath) ?? []).map((v, i) => c.name(v, item(capabilitiesPath, i))), + toolVersion: c.text(rec.toolVersion, at(path, 'toolVersion')), + } as MeasuredEnvironment; +} + +function readAttempt(c: Collector, value: unknown, path: string): Attempt | undefined { + const rec = c.record(value, path, ATTEMPT_SPEC); + if (rec === undefined) return undefined; + const id = c.id(rec.id, at(path, 'id')); + const retryOf = rec.retryOf === undefined ? undefined : c.id(rec.retryOf, at(path, 'retryOf')); + if (retryOf !== undefined && retryOf === id) c.add('invalid-value', at(path, 'retryOf'), 'an attempt cannot be a retry of itself'); + const evidencePath = at(path, 'evidence'); + const attempt: Record = { + id, + requirement: c.requirementKey(rec.requirement, at(path, 'requirement')), + outcome: c.oneOf(rec.outcome, at(path, 'outcome'), OUTCOMES), + evidence: (c.array(rec.evidence, evidencePath) ?? []).map((v, i) => c.relativePath(v, item(evidencePath, i))), + }; + if (retryOf !== undefined) attempt.retryOf = retryOf; + return attempt as unknown as Attempt; +} + +export function parseUploadProvenance(input: unknown): ParseResult { + return parseUnversioned(input, PROVENANCE_SPEC, (c, rec) => ({ + uploader: c.text(rec.uploader, 'uploader'), + uploadedAt: c.timestamp(rec.uploadedAt, 'uploadedAt'), + assetId: c.int(rec.assetId, 'assetId'), + }) as UploadProvenance); +} diff --git a/packages/qa/src/model/validate.ts b/packages/qa/src/model/validate.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ad23b405d2c9cf7b60b29d2b118ed06e66bf34e GIT binary patch literal 11707 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 = {}): Artifact { + return { + profile: 'windows', + name: 'Release QA Smoke_0.1.0_x64-setup.exe', + sha256: SHA256.windowsInstaller, + assetId: 101, + actionsArtifactId: 201, + ...overrides, + }; +} + +export function candidate(overrides: Partial = {}): Candidate { + return { + schemaVersion: 1, + id: 'cand-0001', + repositoryId: 1, + pullRequest: 7, + sourceSha: SHA1.source, + baseSha: SHA1.base, + sourceTreeSha: SHA1.tree, + testRevision: SHA1.tests, + policyDigest: SHA256.policy, + build: { workflowPath: '.github/workflows/qa-prepare.yml', runId: 5000, attempt: 1 }, + artifacts: [ + artifact(), + artifact({ profile: 'linux', name: 'release-qa-smoke_0.1.0_amd64.deb', sha256: SHA256.linuxPackage, assetId: 102, actionsArtifactId: 202 }), + ], + ...overrides, + }; +} + +export function requirement(overrides: Partial = {}): Requirement { + return { + key: 'windows/persistence', + mode: 'automated', + title: 'The saved setting survives a restart', + capabilities: [], + ...overrides, + }; +} + +export function project(overrides: Partial = {}): Project { + const persistence = requirement(); + const deviceFeel = requirement({ key: 'windows/device-feel', mode: 'manual', title: 'Sliders feel right', capabilities: ['hardware'] }); + const linuxPersistence = requirement({ key: 'linux/persistence' }); + return { + schemaVersion: 1, + projectId: 'tauri-smoke', + releaseBranch: 'main', + profiles: [ + { id: 'windows', os: 'windows', arch: 'x86_64' }, + { id: 'linux', os: 'linux', arch: 'x86_64' }, + ], + requirements: [persistence, deviceFeel, linuxPersistence], + suites: [ + { id: 'smoke', requirements: [persistence.key, linuxPersistence.key] }, + { id: 'release', requirements: [persistence.key, deviceFeel.key, linuxPersistence.key] }, + ], + scenarioFiles: ['scenarios/persistence.spec.ts'], + lifecycleModule: 'lifecycle.ts', + workflows: { prepare: 'qa-prepare.yml', gate: 'qa-gate.yml', publish: 'qa-publish.yml' }, + markers: { releaseNotes: 'release-notes', qa: 'qa' }, + ...overrides, + }; +} + +export function report(overrides: Partial = {}): Report { + return { + schemaVersion: 1, + id: 'report-0001', + candidateId: 'cand-0001', + policyDigest: SHA256.policy, + testRevision: SHA1.tests, + profile: 'windows', + actor: 'tester', + machineId: 'lab-win-01', + environment: { os: 'windows', osVersion: '10.0.26200', arch: 'x86_64', capabilities: ['display', 'audio'], toolVersion: '0.0.0' }, + attempts: [{ id: 'attempt-0001', requirement: 'windows/persistence', outcome: 'passed', evidence: ['evidence/persistence.png'] }], + ...overrides, + }; +} + +export function exception(overrides: Partial = {}): Exception { + return { + schemaVersion: 1, + id: 'exception-0001', + candidateId: 'cand-0001', + requirements: ['windows/device-feel'], + reason: 'No slider hardware available in the lab', + actor: 'maintainer', + createdAt: '2026-09-20T12:00:00Z', + ...overrides, + }; +} diff --git a/packages/qa/test/model/contracts.test.ts b/packages/qa/test/model/contracts.test.ts new file mode 100644 index 0000000..de08a91 --- /dev/null +++ b/packages/qa/test/model/contracts.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, test } from 'vitest'; +import { parseCandidate } from '../../src/model/candidate.ts'; +import { parseException } from '../../src/model/exception.ts'; +import { parseProject } from '../../src/model/project.ts'; +import { parseRequirement } from '../../src/model/requirement.ts'; +import { parseReport, parseUploadProvenance } from '../../src/model/result.ts'; +import type { ParseResult } from '../../src/model/validate.ts'; +import { artifact, candidate, exception, project, report, requirement, SHA1 } from '../fixtures/records.ts'; + +/** `[path, code]` pairs of a failed parse; empty for a successful one. */ +function issues(result: ParseResult): Array<[string, string]> { + return result.ok ? [] : result.error.issues.map((i) => [i.path, i.code]); +} +function expectValid(result: ParseResult): void { + expect(result.ok, `should have been accepted, got ${JSON.stringify(issues(result))}`).toBe(true); +} +function expectIssue(result: ParseResult, path: string, code: string): void { + expect(result.ok, 'the record should have been rejected').toBe(false); + expect(issues(result)).toContainEqual([path, code]); +} +/** Overwrites a nested value on a deep copy, so a test can break exactly one field. */ +function broken(record: T, path: string, value: unknown): unknown { + const copy = structuredClone(record) as Record; + const keys = path.split('.'); + let node: Record = copy; + for (const key of keys.slice(0, -1)) node = (Array.isArray(node) ? node[Number(key)] : node[key]) as Record; + const last = keys.at(-1) as string; + if (value === undefined) delete node[last]; + else node[last] = value; + return copy; +} + +const parsers: Array<[string, (input: unknown) => ParseResult, () => unknown]> = [ + ['candidate', (i) => parseCandidate(i), () => candidate()], + ['project', (i) => parseProject(i), () => project()], + ['report', (i) => parseReport(i), () => report()], + ['exception', (i) => parseException(i), () => exception()], +]; + +describe.each(parsers)('%s: behaviour common to every record', (_name, parse, build) => { + test('accepts a valid record and returns it unchanged', () => { + const result = parse(build()); + expectValid(result); + expect(result.ok && result.value).toEqual(build()); + }); + + test.each([null, undefined, 'a string', 42, true, []])('rejects the non-object %j without throwing', (input) => { + expectIssue(parse(input), '', 'invalid-type'); + }); + + test.each([2, 0, '1', null])('rejects unknown schema version %j', (version) => { + expectIssue(parse(broken(build(), 'schemaVersion', version)), 'schemaVersion', 'unknown-schema-version'); + }); + + test('rejects a record with no schema version', () => { + expectIssue(parse(broken(build(), 'schemaVersion', undefined)), 'schemaVersion', 'missing-field'); + }); + + test('reports only the version problem for a future-schema record, whatever else it contains', () => { + const result = parse({ schemaVersion: 2, somethingNew: { nested: true } }); + expect(issues(result)).toEqual([['schemaVersion', 'unknown-schema-version']]); + }); + + test('rejects fields it does not know', () => { + expectIssue(parse({ ...(build() as object), surprise: 1 }), 'surprise', 'unknown-field'); + }); + + test('parses a report written on Windows (CRLF line endings) the same as one written on Linux', () => { + const text = JSON.stringify(build(), null, 2); + const windows = parse(JSON.parse(text.replace(/\n/g, '\r\n'))); + const linux = parse(JSON.parse(text)); + expect(windows).toEqual(linux); + expectValid(windows); + }); +}); + +describe('candidate', () => { + test.each([ + ['sha256 in upper case', 'artifacts.0.sha256', 'B'.repeat(64), 'artifacts[0].sha256'], + ['sha256 one character short', 'artifacts.0.sha256', 'b'.repeat(63), 'artifacts[0].sha256'], + ['sha256 with an algorithm prefix', 'artifacts.0.sha256', `sha256:${'b'.repeat(64)}`, 'artifacts[0].sha256'], + ['sha256 that is not hex', 'artifacts.0.sha256', 'g'.repeat(64), 'artifacts[0].sha256'], + ['source commit that is abbreviated', 'sourceSha', '1234567', 'sourceSha'], + ['base commit in upper case', 'baseSha', 'A'.repeat(40), 'baseSha'], + ['tree that is a sha256', 'sourceTreeSha', 'a'.repeat(64), 'sourceTreeSha'], + ['test revision that is empty', 'testRevision', '', 'testRevision'], + ['policy digest that is a git sha', 'policyDigest', SHA1.tests, 'policyDigest'], + ])('rejects a malformed hash: %s', (_label, path, value, issuePath) => { + expectIssue(parseCandidate(broken(candidate(), path, value)), issuePath, 'malformed-hash'); + }); + + test('rejects two artifacts with the same GitHub asset id', () => { + const result = parseCandidate(candidate({ artifacts: [artifact(), artifact({ profile: 'linux', name: 'other.deb', actionsArtifactId: 999 })] })); + expectIssue(result, 'artifacts[1].assetId', 'duplicate'); + }); + + test('rejects two artifacts with the same profile and file name', () => { + const result = parseCandidate(candidate({ artifacts: [artifact(), artifact({ assetId: 555, actionsArtifactId: 999 })] })); + expectIssue(result, 'artifacts[1].name', 'duplicate'); + }); + + test('accepts the same file name under two different profiles', () => { + const result = parseCandidate(candidate({ artifacts: [artifact(), artifact({ profile: 'linux', assetId: 555, actionsArtifactId: 999 })] })); + expectValid(result); + }); + + test.each([ + ['a parent directory', '../evil.exe'], + ['a nested parent directory', 'a/../../evil.exe'], + ['a forward slash', 'dir/setup.exe'], + ['a backslash', 'dir\\setup.exe'], + ['just dot dot', '..'], + ['just a dot', '.'], + ['a Windows drive prefix', 'C:evil.exe'], + ['an NTFS alternate data stream', 'setup.exe:stream'], + ['a NUL character', 'setup\u0000.exe'], + ['a Windows reserved device name', 'CON'], + ['a reserved device name with an extension', 'nul.txt'], + ['a trailing dot', 'setup.exe.'], + ['a trailing space', 'setup.exe '], + ['a name longer than 255 characters', `${'a'.repeat(256)}.exe`], + ])('rejects an artifact file name containing %s', (_label, name) => { + expectIssue(parseCandidate(candidate({ artifacts: [artifact({ name })] })), 'artifacts[0].name', 'unsafe-path'); + }); + + test('accepts an installer name with spaces, as produced by real packagers', () => { + expectValid(parseCandidate(candidate({ artifacts: [artifact({ name: 'Release QA Smoke_0.1.0_x64-setup.exe' })] }))); + }); + + test.each([['../workflow.yml'], ['/etc/workflow.yml'], ['.github\\workflows\\x.yml'], ['C:/x.yml'], ['a//b.yml']])( + 'rejects the unsafe build workflow path %j', + (workflowPath) => { + expectIssue(parseCandidate(candidate({ build: { workflowPath, runId: 1, attempt: 1 } })), 'build.workflowPath', 'unsafe-path'); + }, + ); + + test('rejects an empty build workflow path', () => { + expectIssue(parseCandidate(candidate({ build: { workflowPath: '', runId: 1, attempt: 1 } })), 'build.workflowPath', 'empty'); + }); + + test.each(['', ' ', '\t'])('rejects the empty candidate id %j', (id) => { + expectIssue(parseCandidate(candidate({ id })), 'id', 'empty'); + }); + + test('rejects an id that could be used to escape a directory', () => { + expectIssue(parseCandidate(candidate({ id: '../cand' })), 'id', 'malformed-id'); + }); + + test('rejects a candidate with no artifacts', () => { + expectIssue(parseCandidate(candidate({ artifacts: [] })), 'artifacts', 'empty'); + }); + + test.each([ + ['zero', 0, 'out-of-range'], + ['negative', -3, 'out-of-range'], + ['fractional', 1.5, 'out-of-range'], + ['a string', '7', 'invalid-type'], + ['not a number', Number.NaN, 'invalid-type'], + ])('rejects a pull request number that is %s', (_label, value, code) => { + expectIssue(parseCandidate(broken(candidate(), 'pullRequest', value)), 'pullRequest', code); + }); + + test('reports every problem in one pass, not just the first', () => { + const result = parseCandidate(candidate({ id: '', sourceSha: 'nope', pullRequest: 0 })); + expect(issues(result)).toEqual(expect.arrayContaining([['id', 'empty'], ['sourceSha', 'malformed-hash'], ['pullRequest', 'out-of-range']])); + }); +}); + +describe('requirement', () => { + test.each([['windows'], ['a/b/c'], ['/x'], ['x/'], ['Windows/Persistence'], ['win dows/x'], ['']])('rejects the malformed key %j', (key) => { + expectIssue(parseRequirement(requirement({ key: key as never })), 'key', 'malformed-key'); + }); + + test('rejects an unknown execution mode', () => { + expectIssue(parseRequirement(broken(requirement(), 'mode', 'semi-automatic')), 'mode', 'invalid-value'); + }); + + test('rejects an empty title', () => { + expectIssue(parseRequirement(requirement({ title: ' ' })), 'title', 'empty'); + }); + + test('accepts a valid requirement', () => { + expectValid(parseRequirement(requirement())); + }); +}); + +describe('project', () => { + test('rejects two profiles with the same id', () => { + const p = project(); + expectIssue(parseProject({ ...p, profiles: [...p.profiles, p.profiles[0]] }), 'profiles[2].id', 'duplicate'); + }); + + test('rejects two requirements with the same key', () => { + const p = project(); + expectIssue(parseProject({ ...p, requirements: [...p.requirements, requirement()] }), 'requirements[3].key', 'duplicate'); + }); + + test('rejects two suites with the same id', () => { + const p = project(); + expectIssue(parseProject({ ...p, suites: [...p.suites, p.suites[0]] }), 'suites[2].id', 'duplicate'); + }); + + test('rejects a suite that lists a requirement the project does not define', () => { + const p = project(); + expectIssue(parseProject({ ...p, suites: [{ id: 'smoke', requirements: ['windows/missing'] }] }), 'suites[0].requirements[0]', 'unknown-reference'); + }); + + test('rejects a requirement for a profile the project does not define', () => { + const p = project(); + expectIssue(parseProject({ ...p, requirements: [requirement({ key: 'macos/persistence' })], suites: [] }), 'requirements[0].key', 'unknown-reference'); + }); + + test('rejects a suite that lists the same requirement twice', () => { + const p = project(); + expectIssue(parseProject({ ...p, suites: [{ id: 'smoke', requirements: ['windows/persistence', 'windows/persistence'] }] }), 'suites[0].requirements[1]', 'duplicate'); + }); + + test.each([['../scenario.spec.ts'], ['/abs/scenario.spec.ts'], ['scenarios\\a.spec.ts'], ['scenarios/../../x.ts']])( + 'rejects the unsafe scenario file %j', + (file) => { + expectIssue(parseProject({ ...project(), scenarioFiles: [file] }), 'scenarioFiles[0]', 'unsafe-path'); + }, + ); + + test('rejects an unsafe lifecycle module path', () => { + expectIssue(parseProject({ ...project(), lifecycleModule: '../lifecycle.ts' }), 'lifecycleModule', 'unsafe-path'); + }); + + test('rejects two markers with the same name', () => { + expectIssue(parseProject({ ...project(), markers: { releaseNotes: 'qa', qa: 'qa' } }), 'markers.qa', 'duplicate'); + }); + + test('rejects a workflow name that is a path', () => { + expectIssue(parseProject({ ...project(), workflows: { ...project().workflows, gate: '../qa-gate.yml' } }), 'workflows.gate', 'unsafe-path'); + }); + + test('rejects an unsupported operating system', () => { + expectIssue(parseProject(broken(project(), 'profiles.0.os', 'macos')), 'profiles[0].os', 'invalid-value'); + }); + + test.each([['feature branch'], ['a..b'], ['a//b'], ['-x'], ['x/']])('rejects the release branch %j', (releaseBranch) => { + expectIssue(parseProject({ ...project(), releaseBranch }), 'releaseBranch', 'invalid-value'); + }); + + test('rejects an empty release branch', () => { + expectIssue(parseProject({ ...project(), releaseBranch: '' }), 'releaseBranch', 'empty'); + }); +}); + +describe('report', () => { + test('rejects two attempts with the same id', () => { + const attempt = report().attempts[0]!; + expectIssue(parseReport(report({ attempts: [attempt, { ...attempt }] })), 'attempts[1].id', 'duplicate'); + }); + + test('rejects an attempt that is a retry of itself', () => { + const attempt = report().attempts[0]!; + expectIssue(parseReport(report({ attempts: [{ ...attempt, retryOf: attempt.id }] })), 'attempts[0].retryOf', 'invalid-value'); + }); + + test('accepts a retry of an attempt that lives in another report', () => { + const attempt = report().attempts[0]!; + expectValid(parseReport(report({ attempts: [{ ...attempt, retryOf: 'attempt-from-elsewhere' }] }))); + }); + + test('rejects a report with no attempts', () => { + expectIssue(parseReport(report({ attempts: [] })), 'attempts', 'empty'); + }); + + test('rejects an attempt whose requirement belongs to a different profile than the report', () => { + const attempt = { ...report().attempts[0]!, requirement: 'linux/persistence' as const }; + expectIssue(parseReport(report({ attempts: [attempt] })), 'attempts[0].requirement', 'mismatch'); + }); + + test('rejects an outcome it does not know', () => { + expectIssue(parseReport(broken(report(), 'attempts.0.outcome', 'kinda-passed')), 'attempts[0].outcome', 'invalid-value'); + }); + + test.each([['../secret.png'], ['/etc/passwd'], ['evidence\\a.png'], ['a/../../b.png']])('rejects the unsafe evidence path %j', (path) => { + const attempt = { ...report().attempts[0]!, evidence: [path] }; + expectIssue(parseReport(report({ attempts: [attempt] })), 'attempts[0].evidence[0]', 'unsafe-path'); + }); + + test('rejects a report with no measured environment', () => { + expectIssue(parseReport(broken(report(), 'environment', undefined)), 'environment', 'missing-field'); + }); + + test.each([['actor'], ['machineId']])('rejects an empty %s', (field) => { + expectIssue(parseReport(broken(report(), field, '')), field, 'empty'); + }); + + test('keeps the claimed actor as plain data', () => { + const result = parseReport(report({ actor: 'someone-else' })); + expect(result.ok && result.value.actor).toBe('someone-else'); + }); + + describe('against a candidate', () => { + test('accepts a report that belongs to the candidate', () => { + expectValid(parseReport(report(), { candidate: candidate() })); + }); + + test('rejects a report for a different candidate', () => { + expectIssue(parseReport(report({ candidateId: 'cand-9999' }), { candidate: candidate() }), 'candidateId', 'unknown-reference'); + }); + + test('rejects a report for a profile the candidate has no artifact for', () => { + const linuxOnly = candidate({ artifacts: [artifact({ profile: 'linux', name: 'x.deb' })] }); + expectIssue(parseReport(report(), { candidate: linuxOnly }), 'profile', 'unknown-reference'); + }); + + test('rejects a report made against a different policy', () => { + expectIssue(parseReport(report({ policyDigest: 'd'.repeat(64) }), { candidate: candidate() }), 'policyDigest', 'mismatch'); + }); + + test('rejects a report made against different tests', () => { + expectIssue(parseReport(report({ testRevision: '9'.repeat(40) }), { candidate: candidate() }), 'testRevision', 'mismatch'); + }); + + test('rejects an attempt for a requirement outside the required set', () => { + expectIssue(parseReport(report(), { requirements: ['windows/device-feel'] }), 'attempts[0].requirement', 'unknown-reference'); + }); + }); +}); + +describe('exception', () => { + test('rejects an exception that names no requirements', () => { + expectIssue(parseException(exception({ requirements: [] })), 'requirements', 'empty'); + }); + + test('rejects an exception that names a requirement twice', () => { + expectIssue(parseException(exception({ requirements: ['windows/device-feel', 'windows/device-feel'] })), 'requirements[1]', 'duplicate'); + }); + + test.each(['', ' '])('rejects the empty reason %j', (reason) => { + expectIssue(parseException(exception({ reason })), 'reason', 'empty'); + }); + + test.each([['yesterday'], ['2026-09-20'], ['2026-09-20T12:00:00'], ['2026-09-20T12:00:00+02:00'], ['2026-13-40T12:00:00Z']])( + 'rejects the malformed timestamp %j', + (createdAt) => { + expectIssue(parseException(exception({ createdAt })), 'createdAt', 'malformed-timestamp'); + }, + ); + + test('accepts a timestamp with fractional seconds', () => { + expectValid(parseException(exception({ createdAt: '2026-09-20T12:00:00.123Z' }))); + }); + + test('rejects an exception for a different candidate than the one supplied', () => { + expectIssue(parseException(exception({ candidateId: 'cand-9999' }), { candidate: candidate() }), 'candidateId', 'unknown-reference'); + }); +}); + +describe('upload provenance', () => { + const provenance = { uploader: 'octocat', uploadedAt: '2026-09-20T12:00:00Z', assetId: 42 }; + + test('accepts valid provenance', () => { + expectValid(parseUploadProvenance(provenance)); + }); + + test('rejects a malformed upload time', () => { + expectIssue(parseUploadProvenance({ ...provenance, uploadedAt: 'later' }), 'uploadedAt', 'malformed-timestamp'); + }); + + test('rejects an empty uploader', () => { + expectIssue(parseUploadProvenance({ ...provenance, uploader: '' }), 'uploader', 'empty'); + }); +}); From b0a372bb27c2b404411d6abbe170c523e7dbff29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B8yland?= <81354124+Andreas-Froyland@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:39:02 +0200 Subject: [PATCH 2/2] fix(model): reject Windows-unsafe paths in every segment and never throw relativePath and fileName now share one segment check that rejects Windows reserved characters (< > : " | ? * and both separators), device names, trailing dots or spaces and control characters, so evidence such as "trace:secret" (an NTFS alternate data stream) is refused. Arrays are copied densely so holes in a sparse array are validated instead of skipped. Parsing is wrapped so a schema version that JSON.stringify cannot serialize (bigint, cyclic) or an object whose getters throw yields a validation failure, honouring the never-throws contract. Control characters are detected by character code so the source holds no raw control bytes. The CRLF round-trip test could never fail (JSON.parse ignores line endings) and is replaced by tests that put real line breaks into text fields. Co-Authored-By: Claude Sonnet 5 --- packages/qa/src/model/validate.ts | Bin 11707 -> 13063 bytes packages/qa/test/model/contracts.test.ts | 59 ++++++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/qa/src/model/validate.ts b/packages/qa/src/model/validate.ts index 5ad23b405d2c9cf7b60b29d2b118ed06e66bf34e..778e5c0f5329d5205158dacf6ce5cd34814edf35 100644 GIT binary patch delta 2233 zcmZ`)&2QX96jzE!rGR>%6jVyXWBal8X5&o@5wPi|Y!erPss!zZ_K-yBtUcZx;vHKv z<7`(=oi#7E z(?$bU21F1~Q6b?8<9;-fAZU}RUP6mkog&4iCXH?c86-1$}EM_mEBLEhF zWzLt;)3dt^aQk+y|6wcDl1~ELpu%`Uk1fti7qdm%+*zyD0*%_iK@l9kk6}A0Jp-G4 zB3fz^6jUXGgA#fCLlhPwgUr&*A=02$gAI;FXCVc|#%F?hNWL*}GNeF|tO|w+Leaz0 zlK_=C(0z&uGKu3zDC)yT*MW*t0fM}#IQWtVgoTDJO5hvTY2J?%h$N&A)EdXNO}O^i z&AY%Vx}mJ?nbH-xTjp+sYEd~@hP#W4h?zO-xGDC)=@xL1m8uM2mzw8ND^KP~r?KV0 z&dtX84xX&7uWmf)?ykCz*H+vk9nekf$T(!msybDBYhefO--l||#srZ*hm#i}>H?G5&)2Q&&+BivMYfHq?Z{|Itz)oS%jKLhiBzkaoM&PepiTB>#}`sfs*WL_ z#6jm%F7>>K$8|kJ2H1?n2$VSK@Q4UO#`vUpzC=?Bi8o|YB6GAbF&0u$g0~5fidruB zSR7N|5DIc9meHO31$U-q+uExWh-jEgwabL>@o2=icI?Tm%1^JSuu7#TSP$J3KF%YV z5rNZ!3W5DFq*9VTg%MMOC{c#`NNf>}bP86F^%++NZg1>`%qz3);S}0~ueuxSPClqu zFt$%~-=}FFJkPILW{0%TP0S=x*lBuj9rf;MpSi8A*`>xYMQ{uRdfps@N0c`>ZT*xK zw?s{qq6OT1e$w#psWB}_LkG)jc9p3Uwzr-r1}rtu0H^^$Or%F*y^v&r;Sgp-FY0}Z za|A|%2n*b&%IradTOHS*iqPpkSw(gdGvoEVNW_`p&`*f)sef;C*)l|Ff@K4e@oUYR z4wcv*c$WQZy>sh7C>aCGOzI;XvU+Vc`{Byvg(h}ly(}$$=(T2GLzX6?k1NfLJ1n7J zg+nCE%PO6^S~Ojem6hkBOOsu;gZIk4QEm>OdLdHUhgNoAfBd#aRsZzTD37w2_O)!j zrryF0Z&-MAVrReB7B6XiJ+$*@_D9Ws4PPhAbJJ6w{%(^b delta 898 zcmbVLQESss6t){1QpaQu13}o1+guV{?%k$CX-(^B1FOt-v}IM>TDs)64a<_5d+Ro> z>_zZR#Ea}r@gE3z690qfAMn*TAARz{n=HX1donM{cfNbR^PO{UUUoj$KfWD5#ux~- zEr+S5McX?zQ5|x~R1qQIxT@hg%yVtkBVBTs6o@j&m5SAJX%jTS^>ga%aTBML|0^?J z+HC3fbNW`bRof_3^~%Zq`Ep@9q#xDS)rW2MX)}e>{?BA% z{EqDIGfVhGdwrW(q5o`b^^6j!m{SlVl*yFOPEY%E|s_8QD`Zj?bV!H<@3F0}m+IMKXLqH2d02JB8>9QVVV0CQ`27@S$ z>$uTKHF4xZfq@ZI(VYJ>F@fs{nhQfIF;OcN^QBs)mDjfmIXy~5bl3O1gW*VPN!#vCC&cK8_+(?1IJ$SW?nwbSMs3j$!vrLS?DZ zkVFpy2sbrkJs$UPD6WC1yGLYMKn1i+U z^nWb88uPy|JxK5}GDl%?k$*)uZow5HRmpAW_tsn6dbv_46%}|k)JMndi&blIw^Ax1 z$~>`Gt8*mtZ|M4z63$ETq$B e2at1Zn;6XXWHC7YVdWbCvXbGGt5x1xeg6mK0T(&| diff --git a/packages/qa/test/model/contracts.test.ts b/packages/qa/test/model/contracts.test.ts index de08a91..cb78f1b 100644 --- a/packages/qa/test/model/contracts.test.ts +++ b/packages/qa/test/model/contracts.test.ts @@ -65,12 +65,21 @@ describe.each(parsers)('%s: behaviour common to every record', (_name, parse, bu expectIssue(parse({ ...(build() as object), surprise: 1 }), 'surprise', 'unknown-field'); }); - test('parses a report written on Windows (CRLF line endings) the same as one written on Linux', () => { - const text = JSON.stringify(build(), null, 2); - const windows = parse(JSON.parse(text.replace(/\n/g, '\r\n'))); - const linux = parse(JSON.parse(text)); - expect(windows).toEqual(linux); - expectValid(windows); + // The contract is "never throws", so values that JSON.stringify or property access cannot handle must not escape. + const circular: Record = {}; + circular.self = circular; + const hostile = new Proxy({}, { has: () => { throw new Error('boom'); }, get: () => { throw new Error('boom'); }, ownKeys: () => { throw new Error('boom'); } }); + test.each([ + ['a bigint', { schemaVersion: 1n }], + ['a circular object', { schemaVersion: circular }], + ])('names the unsupported version precisely when the schema version is %s', (_label, input) => { + expect(() => parse(input)).not.toThrow(); + expectIssue(parse(input), 'schemaVersion', 'unknown-schema-version'); + }); + + test('does not throw for an object that throws when it is read', () => { + expect(() => parse(hostile)).not.toThrow(); + expectIssue(parse(hostile), '', 'invalid-type'); }); }); @@ -123,6 +132,10 @@ describe('candidate', () => { expectIssue(parseCandidate(candidate({ artifacts: [artifact({ name })] })), 'artifacts[0].name', 'unsafe-path'); }); + test.each(['<', '>', '"', '|', '?', '*'])('rejects an artifact file name containing the Windows-reserved character %j', (ch) => { + expectIssue(parseCandidate(candidate({ artifacts: [artifact({ name: `setup${ch}.exe` })] })), 'artifacts[0].name', 'unsafe-path'); + }); + test('accepts an installer name with spaces, as produced by real packagers', () => { expectValid(parseCandidate(candidate({ artifacts: [artifact({ name: 'Release QA Smoke_0.1.0_x64-setup.exe' })] }))); }); @@ -175,6 +188,10 @@ describe('requirement', () => { expectIssue(parseRequirement(broken(requirement(), 'mode', 'semi-automatic')), 'mode', 'invalid-value'); }); + test('validates every slot of a sparse capabilities array', () => { + expectIssue(parseRequirement(requirement({ capabilities: new Array(2) })), 'capabilities[0]', 'invalid-type'); + }); + test('rejects an empty title', () => { expectIssue(parseRequirement(requirement({ title: ' ' })), 'title', 'empty'); }); @@ -263,6 +280,15 @@ describe('report', () => { expectValid(parseReport(report({ attempts: [{ ...attempt, retryOf: 'attempt-from-elsewhere' }] }))); }); + test('validates every slot of a sparse attempts array', () => { + expectIssue(parseReport(report({ attempts: new Array(1) as never })), 'attempts[0]', 'invalid-type'); + }); + + test('rejects a line break in a single-line text field, on any platform', () => { + expectIssue(parseReport(report({ actor: 'x\ry' })), 'actor', 'invalid-characters'); + expectIssue(parseReport(report({ machineId: 'x\ny' })), 'machineId', 'invalid-characters'); + }); + test('rejects a report with no attempts', () => { expectIssue(parseReport(report({ attempts: [] })), 'attempts', 'empty'); }); @@ -276,7 +302,22 @@ describe('report', () => { expectIssue(parseReport(broken(report(), 'attempts.0.outcome', 'kinda-passed')), 'attempts[0].outcome', 'invalid-value'); }); - test.each([['../secret.png'], ['/etc/passwd'], ['evidence\\a.png'], ['a/../../b.png']])('rejects the unsafe evidence path %j', (path) => { + test.each([ + ['../secret.png'], + ['/etc/passwd'], + ['evidence\\a.png'], + ['a/../../b.png'], + ['evidence/trace:secret'], // NTFS alternate data stream + ['evidence/CON/x.png'], // Windows device name as a directory + ['evidence/nul.txt'], + ['evidence/x./y.png'], // trailing dot in a directory + ['evidence/x /y.png'], // trailing space in a directory + ['evidence/a?b.png'], + ['evidence/a*b.png'], + ['evidence/a.png'], + ['evidence/a|b.png'], + ['evidence/a"b.png'], + ])('rejects the unsafe evidence path %j', (path) => { const attempt = { ...report().attempts[0]!, evidence: [path] }; expectIssue(parseReport(report({ attempts: [attempt] })), 'attempts[0].evidence[0]', 'unsafe-path'); }); @@ -342,6 +383,10 @@ describe('exception', () => { }, ); + test('accepts a multi-line reason with Windows or Unix line endings', () => { + expectValid(parseException(exception({ reason: 'first line\r\nsecond line\nthird line' }))); + }); + test('accepts a timestamp with fractional seconds', () => { expectValid(parseException(exception({ createdAt: '2026-09-20T12:00:00.123Z' }))); });