fix: 保留 PaC YAML 标量空白
仅规范结构性 mapping 容器头,并以完整 YAML round-trip 校验失败闭合。\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> = {};
|
||||
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", () => {
|
||||
|
||||
@@ -1032,12 +1032,33 @@ function parseYamlRecord(text: string, label: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
export function pacSourceArtifactYaml(value: Record<string, unknown>): 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 {
|
||||
|
||||
Reference in New Issue
Block a user