diff --git a/config/scripts/windows-signing-gate-toolset.test.mjs b/config/scripts/windows-signing-gate-toolset.test.mjs index 38de8182a1b..d15399658ef 100644 --- a/config/scripts/windows-signing-gate-toolset.test.mjs +++ b/config/scripts/windows-signing-gate-toolset.test.mjs @@ -14,54 +14,166 @@ function workflowSource(name) { return readFileSync(join(workflowsDir, name), 'utf8') } -// Why a scanner and not a regex: PowerShell here-docs in these gates contain -// braces inside strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so -// naive brace counting mis-pairs and every scope assertion below silently -// degrades into "some text appears somewhere in the file". -function findBlockEnd(source, openIndex) { - let depth = 0 - let i = openIndex +// Why a scanner and not a regex: PowerShell gates here contain braces inside +// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace +// counting mis-pairs and every scope assertion below silently degrades into +// "some text appears somewhere in the file". The same walk also lets assertions +// distinguish a keyword the shell executes from the same word sitting in a +// message string — downgrading `throw` to `Write-Host "...would throw..."` +// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open. + +/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */ +function scanSpans(source) { + // Here-strings use different terminator rules; refusing them beats mis-pairing silently. + expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/) + const spans = [] + let i = 0 while (i < source.length) { const char = source[i] if (char === '#') { const newline = source.indexOf('\n', i) - i = newline === -1 ? source.length : newline + const end = newline === -1 ? source.length : newline + spans.push({ start: i, end, kind: 'comment' }) + i = end continue } if (char === "'") { - const end = source.indexOf("'", i + 1) - i = end === -1 ? source.length : end + 1 + let end = i + 1 + while (end < source.length) { + if (source[end] !== "'") { + end += 1 + } else if (source[end + 1] === "'") { + end += 2 // doubled '' escapes a quote rather than closing the string + } else { + break + } + } + end = Math.min(end + 1, source.length) + spans.push({ start: i, end, kind: 'string' }) + i = end continue } if (char === '"') { - i += 1 - while (i < source.length && source[i] !== '"') { - i += source[i] === '`' ? 2 : 1 + let end = i + 1 + while (end < source.length && source[end] !== '"') { + end += source[end] === '`' ? 2 : 1 } - i += 1 + end = Math.min(end + 1, source.length) + spans.push({ start: i, end, kind: 'string' }) + i = end continue } - if (char === '{') { - depth += 1 - } else if (char === '}') { - depth -= 1 - if (depth === 0) { - return i - } + let end = i + while (end < source.length && !'#\'"'.includes(source[end])) { + end += 1 } - i += 1 + spans.push({ start: i, end, kind: 'code' }) + i = end } - throw new Error(`unbalanced block starting at ${openIndex}`) + return spans } -/** The `{ ... }` block that opens after `marker`, as `{ start, end, body }`. */ +/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */ +function blank(source, spans, kinds) { + const chars = [...source] + for (const span of spans) { + if (!kinds.includes(span.kind)) { + continue + } + for (let i = span.start; i < span.end; i += 1) { + if (chars[i] !== '\n') { + chars[i] = ' ' + } + } + } + return chars.join('') +} + +/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */ +function withoutComments(source) { + return blank(source, scanSpans(source), ['comment']) +} + +/** `source` with strings and comments blanked — for assertions about executed statements. */ +function codeOf(source) { + return blank(source, scanSpans(source), ['string', 'comment']) +} + +/** + * The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a + * keyword assertion against it can only be satisfied by a keyword the shell would execute; + * `text` keeps strings but drops comments, for assertions about literals the gate emits. + */ function blockAfter(source, marker, from = 0) { - const markerIndex = source.indexOf(marker, from) + const spans = scanSpans(source) + const code = blank(source, spans, ['string', 'comment']) + const markerIndex = code.indexOf(marker, from) expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1) - const start = source.indexOf('{', markerIndex) + const start = code.indexOf('{', markerIndex) expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1) - const end = findBlockEnd(source, start) - return { start, end, body: source.slice(start, end + 1) } + let depth = 0 + let end = -1 + for (let i = start; i < source.length && end === -1; i += 1) { + if (code[i] === '{') { + depth += 1 + } else if (code[i] === '}') { + depth -= 1 + if (depth === 0) { + end = i + } + } + } + expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1) + return { + start, + end, + code: code.slice(start, end + 1), + text: blank(source, spans, ['comment']).slice(start, end + 1) + } +} + +/** + * The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it. + * Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks + * whichever catch comes first in the file, which stopped being the gate's own once the + * persistence helpers grew their own try/catch. + */ +function blockEnclosing(source, marker) { + const spans = scanSpans(source) + const code = blank(source, spans, ['string', 'comment']) + // Located in the comment-stripped text (the marker may include a string literal), + // then paired in `code`; both blankings preserve length, so indices line up. + const markerIndex = blank(source, spans, ['comment']).indexOf(marker) + expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1) + let depth = 0 + let end = -1 + for (let i = markerIndex; i < code.length && end === -1; i += 1) { + if (code[i] === '{') { + depth += 1 + } else if (code[i] === '}') { + if (depth === 0) { + end = i + } else { + depth -= 1 + } + } + } + depth = 0 + let start = -1 + for (let i = markerIndex; i >= 0 && start === -1; i -= 1) { + if (code[i] === '}') { + depth += 1 + } else if (code[i] === '{') { + if (depth === 0) { + start = i + } else { + depth -= 1 + } + } + } + expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1) + expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1) + return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() } } describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => { @@ -81,7 +193,7 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487) it(`${name} aborts the gate when 7za cannot be resolved`, () => { const source = workflowSource(name) const guard = blockAfter(source, '$LASTEXITCODE -ne 0 -or -not (Test-Path $7za)') - expect(guard.body).toMatch(/\bthrow\b/) + expect(guard.code).toMatch(/\bthrow\b/) }) } @@ -101,14 +213,14 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487) // string 'Write-GateVerdict' appears somewhere passes even if the body is // gutted to a Write-Host, which is exactly the silent degradation this gate // exists to prevent. - function gateVerdictFunctionBody() { - return blockAfter(innerBinaryStep(), 'function Write-GateVerdict').body + function gateVerdictBlock() { + return blockAfter(innerBinaryStep(), 'function Write-GateVerdict') } it('persists the verdict to the evidence file the artifact upload collects', () => { - const body = gateVerdictFunctionBody() - expect(body).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/) - expect(body).toContain('Add-GateSummary') + const block = gateVerdictBlock() + expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/) + expect(block.code).toContain('Add-GateSummary') }) it('never lets verdict persistence itself fail a warn-only release', () => { @@ -116,15 +228,16 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487) // must not turn evidence-writing into the thing that fails the release. const step = innerBinaryStep() for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) { - const body = blockAfter(step, helper).body - expect(body, helper).toContain('-ErrorAction Stop') - expect(body, helper).toMatch(/\bcatch\b/) + const code = blockAfter(step, helper).code + expect(code, helper).toContain('-ErrorAction Stop') + expect(code, helper).toMatch(/\bcatch\b/) } - expect(gateVerdictFunctionBody()).toMatch(/\bcatch\b/) + expect(gateVerdictBlock().code).toMatch(/\bcatch\b/) }) it('records a verdict on every terminal branch of the gate', () => { - const step = innerBinaryStep() + // Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write. + const step = withoutComments(innerBinaryStep()) for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) { expect(step).toContain(verdict) } @@ -134,10 +247,13 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487) // Why: throwing inside `try` re-enters the catch, whose Set-Content // replaces the per-file report with "ERRORED — ". const step = innerBinaryStep() - const policyThrow = step.indexOf('if ($policyFailure) { throw $policyFailure }') + const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }') expect(policyThrow).toBeGreaterThan(-1) - const catchBlock = blockAfter(step, '} catch {') - expect(policyThrow).toBeGreaterThan(catchBlock.end) + // Anchored on the gate's own handler, not the first `catch` in the step: the + // persistence helpers have their own, and they sit earlier in the file. + const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED') + expect(gateCatch.keyword).toBe('catch') + expect(policyThrow).toBeGreaterThan(gateCatch.end) }) // Why: the assignment is what survives a write failure. With it after the @@ -145,7 +261,7 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487) // $policyFailure still null — required mode reports ERRORED and overwrites the // per-file report, reintroducing exactly the loss the hoist prevents. it('records the required-mode failure before attempting any evidence write', () => { - const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').body + const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code const assignment = branch.indexOf('$policyFailure = $message') expect(assignment).toBeGreaterThan(-1) for (const write of ['Add-GateEvidence', 'Add-GateSummary']) {