diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index 1b1a2b02..6891b0c6 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -11,6 +11,7 @@ import { canonicalSha256, canonicalizePacPipelineSpec, firstPacSourceArtifactDrift, + pacSourceArtifactSafeError, pacValueEvidence, pacSourceArtifactYaml, parsePacSourceArtifactOptions, @@ -284,6 +285,61 @@ describe("source artifact serialization", () => { expect(serialized.endsWith("\n\n")).toBe(false); expect(Bun.YAML.parse(serialized)).toEqual(manifest); }); + + test("multi-line script scalar preserves trailing spaces and tabs exactly", () => { + const script = [ + "#!/bin/sh ", + "printf 'mapping-looking scalar: ' ", + "metadata: ", + " {}", + "printf 'tab follows'\t", + "", + ].join("\n"); + const manifest = { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { name: "scalar-round-trip" }, + spec: { tasks: [{ name: "build", taskSpec: { steps: [{ name: "build", script }] } }] }, + }; + const serialized = pacSourceArtifactYaml(manifest); + const parsed = Bun.YAML.parse(serialized) as typeof manifest; + expect(parsed).toEqual(manifest); + expect(parsed.spec.tasks[0]?.taskSpec.steps[0]?.script).toBe(script); + expect(Buffer.from(parsed.spec.tasks[0]?.taskSpec.steps[0]?.script ?? "")).toEqual(Buffer.from(script)); + expect(serialized.endsWith("\n")).toBe(true); + expect(serialized.endsWith("\n\n")).toBe(false); + }); + + test("round-trip failure exposes only secret-safe drift evidence", () => { + const secret = "ROUND_TRIP_SCRIPT_SECRET_DO_NOT_PRINT"; + let reads = 0; + const spec: Record = {}; + Object.defineProperty(spec, "script", { + enumerable: true, + get: () => { + reads += 1; + return reads <= 2 ? secret : "changed-after-serialization"; + }, + }); + + let thrown: unknown; + try { + pacSourceArtifactYaml({ apiVersion: "tekton.dev/v1", kind: "Pipeline", spec }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const safe = pacSourceArtifactSafeError(thrown); + const serializedError = JSON.stringify({ + message: thrown instanceof Error ? thrown.message : String(thrown), + direct: thrown, + safe, + }); + expect(safe.code).toBe("source-artifact-yaml-round-trip-failed"); + expect(serializedError).not.toContain(secret); + expect(serializedError).not.toContain("changed-after-serialization"); + expect(serializedError).toContain("sha256"); + }); }); describe("runtime source-commit selection", () => { diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index bc77a0e4..1f81253d 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -1032,12 +1032,33 @@ function parseYamlRecord(text: string, label: string): Record { } export function pacSourceArtifactYaml(value: Record): string { - const serialized = Bun.YAML.stringify(value, null, 2) - .split("\n") - .map((line) => line.replace(/[\t ]+$/u, "")) - .join("\n") - .trim(); - return `${serialized}\n`; + const lines = Bun.YAML.stringify(value, null, 2).split("\n"); + let blockScalarHeaderIndent: number | null = null; + const normalized = lines.map((line) => { + const content = line.trim(); + const indent = line.length - line.trimStart().length; + if (blockScalarHeaderIndent !== null) { + if (content.length === 0 || indent > blockScalarHeaderIndent) return line; + blockScalarHeaderIndent = null; + } + + const structuralLine = line.replace(/:[\t ]+$/u, ":"); + if (/(?:^|:[\t ]+|-[\t ]+)[|>](?:[1-9][+-]?|[+-][1-9]?|[+-])?[\t ]*(?:#.*)?$/u.test(structuralLine)) { + blockScalarHeaderIndent = indent; + } + return structuralLine; + }).join("\n"); + const withSingleEofNewline = `${normalized.replace(/\n+$/u, "")}\n`; + const roundTrip = parseYamlRecord(withSingleEofNewline, "serialized source artifact"); + const roundTripDrift = firstCanonicalDrift(value, roundTrip, "$"); + if (roundTripDrift !== null) { + throw new PacSourceArtifactError("source-artifact-yaml-round-trip-failed", { + driftPath: roundTripDrift.path, + expectedEvidence: roundTripDrift.expected, + actualEvidence: roundTripDrift.actual, + }); + } + return withSingleEofNewline; } function requiredParam(binding: PacSourceArtifactBinding, key: string): string {