diff --git a/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.mjs b/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.mjs index 795ae1dceaf..df567a823cd 100644 --- a/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.mjs +++ b/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.mjs @@ -8,6 +8,8 @@ const MAX_SECTIONS = 96 const MAX_DEBUG_ENTRIES = 32 const MAX_DIFF_RANGES = 128 const MAX_HEADER_DIFFERENCES = 128 +const MAX_RANGE_SAMPLE_BYTES = 32 +const MAX_REGION_SAMPLES = 8 const READ_CHUNK_BYTES = 64 * 1024 const DIAGNOSTIC_TIMEOUT_MS = 60_000 @@ -146,11 +148,76 @@ function parsePeHeader(buffer) { start: section.headerOffset, end: section.headerOffset + 40, label: `section header ${section.name}` - })) + })), + ...sections + .filter((section) => section.rawSize > 0) + .map((section) => ({ + start: section.rawPointer, + end: section.rawPointer + section.rawSize, + label: `section ${section.name}` + })) ] return { header, regions, sections } } +function regionAt(regions, offset) { + const matches = regions.filter((region) => offset >= region.start && offset < region.end) + matches.sort((left, right) => left.end - left.start - (right.end - right.start)) + return matches[0]?.label ?? 'unmapped file data' +} + +function bytesHex(bytes) { + return bytes + .map((byte) => (byte === undefined ? '--' : byte.toString(16).padStart(2, '0'))) + .join('') +} + +function rangeRecord(state, firstRegions, secondRegions) { + const firstRegion = regionAt(firstRegions, state.start) + const secondRegion = regionAt(secondRegions, state.start) + return { + start: state.start, + endExclusive: state.end, + startHex: hex(state.start), + endExclusiveHex: hex(state.end), + length: state.end - state.start, + firstRegion, + secondRegion, + firstBytes: bytesHex(state.firstBytes), + secondBytes: bytesHex(state.secondBytes), + bytesTruncated: state.end - state.start > state.firstBytes.length + } +} + +function addRegionSummary(state, range) { + const key = `${range.firstRegion}\0${range.secondRegion}` + let summary = state.regionSummaries.get(key) + if (!summary) { + summary = { + firstRegion: range.firstRegion, + secondRegion: range.secondRegion, + differingBytes: 0, + rangeCount: 0, + firstOffset: range.start, + lastEndExclusive: range.endExclusive, + samples: [] + } + state.regionSummaries.set(key, summary) + } + summary.differingBytes += range.length + summary.rangeCount += 1 + summary.lastEndExclusive = range.endExclusive + if (summary.samples.length < MAX_REGION_SAMPLES) { + summary.samples.push({ + start: range.start, + endExclusive: range.endExclusive, + firstBytes: range.firstBytes, + secondBytes: range.secondBytes, + bytesTruncated: range.bytesTruncated + }) + } +} + function rvaToFileOffset(rva, header, sections) { if (rva < header.optional.sizeOfHeaders) { return rva @@ -275,27 +342,29 @@ function finishRange(state, firstRegions, secondRegions) { return } state.rangeCount += 1 + const range = rangeRecord(state, firstRegions, secondRegions) + addRegionSummary(state, range) if (state.ranges.length < MAX_DIFF_RANGES) { - const regionAt = (regions) => - regions.find((region) => state.start >= region.start && state.start < region.end)?.label ?? - 'unmapped file data' - state.ranges.push({ - start: state.start, - endExclusive: state.end, - startHex: hex(state.start), - endExclusiveHex: hex(state.end), - length: state.end - state.start, - firstRegion: regionAt(firstRegions), - secondRegion: regionAt(secondRegions) - }) + state.ranges.push(range) } state.start = undefined + state.firstBytes = [] + state.secondBytes = [] } async function compareBytes(first, second, signal) { const firstHash = createHash('sha256') const secondHash = createHash('sha256') - const state = { differingBytes: 0, end: 0, rangeCount: 0, ranges: [], start: undefined } + const state = { + differingBytes: 0, + end: 0, + firstBytes: [], + rangeCount: 0, + ranges: [], + regionSummaries: new Map(), + secondBytes: [], + start: undefined + } const length = Math.max(first.metadata.size, second.metadata.size) for (let offset = 0; offset < length; offset += READ_CHUNK_BYTES) { signal.throwIfAborted() @@ -313,7 +382,15 @@ async function compareBytes(first, second, signal) { index >= firstLength || index >= secondLength || firstBytes[index] !== secondBytes[index] if (different) { state.differingBytes += 1 - state.start ??= offset + index + if (state.start === undefined) { + state.start = offset + index + state.firstBytes = [] + state.secondBytes = [] + } + if (state.firstBytes.length < MAX_RANGE_SAMPLE_BYTES) { + state.firstBytes.push(index < firstLength ? firstBytes[index] : undefined) + state.secondBytes.push(index < secondLength ? secondBytes[index] : undefined) + } state.end = offset + index + 1 } else { finishRange(state, first.parsed.regions, second.parsed.regions) @@ -327,7 +404,8 @@ async function compareBytes(first, second, signal) { differingBytes: state.differingBytes, rangeCount: state.rangeCount, ranges: state.ranges, - rangesTruncated: state.rangeCount > state.ranges.length + rangesTruncated: state.rangeCount > state.ranges.length, + regionSummaries: [...state.regionSummaries.values()] } } @@ -390,6 +468,7 @@ export async function diagnoseWindowsPeMismatch({ rangeCount: comparison.rangeCount, ranges: comparison.ranges, rangesTruncated: comparison.rangesTruncated, + regionSummaries: comparison.regionSummaries, headerDifferences: headerState.differences, headerDifferencesTruncated: headerState.truncated } diff --git a/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.test.mjs b/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.test.mjs index c11ac8d0b46..5e2944f7f42 100644 --- a/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.test.mjs +++ b/config/scripts/ssh-relay-runtime-windows-pe-diagnostic.test.mjs @@ -74,10 +74,10 @@ async function fixturePair(firstBytes, secondBytes) { describe('SSH relay Windows PE mismatch diagnostic', () => { it('reports bounded byte ranges and relevant header differences without file paths', async () => { - const fixture = await fixturePair( - peFixture(), - peFixture({ identifierByte: 0x22, timestamp: 0x87654321 }) - ) + const first = peFixture() + const second = peFixture({ identifierByte: 0x22, timestamp: 0x87654321 }) + second[0x300] = 0xff + const fixture = await fixturePair(first, second) const result = await diagnoseWindowsPeMismatch(fixture) @@ -90,8 +90,8 @@ describe('SSH relay Windows PE mismatch diagnostic', () => { sha256: expect.stringMatching(/^sha256:/) }), difference: expect.objectContaining({ - differingBytes: 24, - rangeCount: 3, + differingBytes: 25, + rangeCount: 4, rangesTruncated: false }) }) @@ -101,8 +101,22 @@ describe('SSH relay Windows PE mismatch diagnostic', () => { expect(result.difference.ranges.map((range) => range.firstRegion)).toEqual([ 'COFF header', 'debug directory', - 'CodeView data 0' + 'CodeView data 0', + 'section .rdata' ]) + expect(result.difference.regionSummaries).toEqual([ + expect.objectContaining({ firstRegion: 'COFF header', differingBytes: 4, rangeCount: 1 }), + expect.objectContaining({ firstRegion: 'debug directory', differingBytes: 4, rangeCount: 1 }), + expect.objectContaining({ + firstRegion: 'CodeView data 0', + differingBytes: 16, + rangeCount: 1 + }), + expect.objectContaining({ firstRegion: 'section .rdata', differingBytes: 1, rangeCount: 1 }) + ]) + expect(result.difference.regionSummaries[3].samples[0]).toEqual( + expect.objectContaining({ firstBytes: '00', secondBytes: 'ff', bytesTruncated: false }) + ) expect(result.difference.headerDifferences.map((entry) => entry.field)).toEqual([ 'coff.timeDateStamp', 'debugDirectory.0.codeView.identifier', @@ -131,6 +145,34 @@ describe('SSH relay Windows PE mismatch diagnostic', () => { ).rejects.toThrow('aborted') }) + it('caps per-region samples and per-range byte excerpts', async () => { + const first = peFixture() + const second = peFixture() + for (let index = 0; index < 10; index += 1) { + second[0x280 + index * 2] = 0xff + } + second.fill(0xff, 0x340, 0x368) + const result = await diagnoseWindowsPeMismatch(await fixturePair(first, second)) + + expect(result.difference.regionSummaries).toEqual([ + expect.objectContaining({ + firstRegion: 'section .rdata', + differingBytes: 50, + rangeCount: 11, + samples: expect.any(Array) + }) + ]) + expect(result.difference.regionSummaries[0].samples).toHaveLength(8) + expect(result.difference.ranges.at(-1)).toEqual( + expect.objectContaining({ + length: 40, + bytesTruncated: true, + firstBytes: '00'.repeat(32), + secondBytes: 'ff'.repeat(32) + }) + ) + }) + it('parses only the two explicit CLI inputs', () => { expect( parseWindowsPeDiagnosticArguments(['--first-pe', 'first.node', '--second-pe', 'second.node'])