From d97db6ebaf0023397d8c61e423ec507cec3095ee Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:43:36 +0800 Subject: [PATCH] fix(captures): preserve canonical TShark alias fields TShark resolves selected field aliases before writing packet JSON. Keep the requested selector in Evidence while accepting the canonical field key produced for it. Fixes #6595 --- docs/packet-captures.md | 2 ++ src/captures/TsharkCaptureProvider.ts | 7 ++-- src/captures/parsePacketOutput.ts | 10 ++---- .../boundary/captures/captureProvider.test.ts | 35 +++++++++++++++++++ tests/boundary/captures/packetOutput.test.ts | 17 +++++++-- 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/docs/packet-captures.md b/docs/packet-captures.md index 077eb4384c..aad2995eaa 100644 --- a/docs/packet-captures.md +++ b/docs/packet-captures.md @@ -95,6 +95,8 @@ for field names, display filters, decode-as syntax and pass semantics. Invalid fields or filters return producer diagnostics. An empty result does not prove absence of traffic outside the selected recording, filter or read range. Dissector results are observations, not independently verified device behavior. +TShark may resolve a selected alias to its canonical field name in packet output; +the report preserves the exact requested selection and the producer's output key. Evidence includes source path/digest/size, exact selections, typed packet fields, configured and resolved executable paths/digests/full version banner, arguments and stderr. Warnings remain diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 2328f60d02..ee9fffd2bc 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -362,10 +362,9 @@ export class TsharkCaptureProvider implements PacketCapturePort { ); phase = "producer"; resourcePath = "path" in input ? input.path : command; - const matchingPackets = parsePacketOutput( - output.stdout.text, - input.fields, - ); + // TShark resolves `-e` aliases before emitting canonical field names; + // the command arguments, not output names, own field selection. + const matchingPackets = parsePacketOutput(output.stdout.text); const start = Math.min(input.match_offset, matchingPackets.length); const end = input.match_limit === null diff --git a/src/captures/parsePacketOutput.ts b/src/captures/parsePacketOutput.ts index 0a408b2162..eaa55eeb8f 100644 --- a/src/captures/parsePacketOutput.ts +++ b/src/captures/parsePacketOutput.ts @@ -6,22 +6,18 @@ import { packetFieldsSchema } from "../domain/captures/packetCapture.js"; const producerSchema = z.array( z.object({ _source: z.object({ layers: packetFieldsSchema }) }), ); -export const parsePacketOutput = (text: string, fields: readonly string[]) => { +export const parsePacketOutput = (text: string) => { const decoded: unknown = JSON.parse(text); const packets = producerSchema .parse(decoded) .map((packet) => packet._source.layers); - const selected = new Set(["frame.number", ...fields]); let previous = 0n; for (const packet of packets) { const frame = BigInt(packet["frame.number"][0]); - if ( - frame <= previous || - Object.keys(packet).some((name) => !selected.has(name)) - ) + if (frame <= previous) throw new AnalysisOutputError( "inspect_packet_capture", - "TShark returned unordered frames or unselected fields.", + "TShark returned unordered frames.", ); previous = frame; } diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index e8182c7b07..1f44a2e8bf 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -34,6 +34,7 @@ else if (${JSON.stringify(mode)} === 'overflow') process.stdout.write('x'.repeat else if (${JSON.stringify(mode)} === 'wait') { console.log('ready'); setInterval(() => {}, 1000); } else if (${JSON.stringify(mode)} === 'invalid-json') console.log('not json'); else if (${JSON.stringify(mode)} === 'many') console.log(JSON.stringify(Array.from({length:90},(_,i)=>({_source:{layers:{"frame.number":[String(i+1)],"ip.addr":["192.0.2.1"]}}})))); +else if (${JSON.stringify(mode)} === 'alias-canonical') { if (!process.argv.includes('bootp.type')) throw Error('alias selector missing'); console.log(JSON.stringify([{_source:{layers:{"frame.number":["1"],"dhcp.type":["1"]}}}])); } else if (process.argv.includes('-G')) { console.log('P\\tUniversal Serial Bus\\tusb\\nF\\tVendor ID\\tusb.idVendor\\tFT_UINT16\\tusb\\tBASE_HEX\\t0x0\\t\\nF\\tOther Vendor\\tusb.idVendor\\tFT_UINT32\\tusb\\tBASE_HEX\\t0x0\\tSecond registration\\nP\\tInternet Protocol\\tip\\nF\\tAddress\\tip.addr\\tFT_IPv4\\tip\\t\\t0x0\\tSource or destination'); console.error('glossary warning'); } else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wrong copy'); console.log(JSON.stringify([{_source:{layers:{"frame.number":["1"],"ip.addr":["192.0.2.1","192.0.2.2"]}}}])); console.error('producer warning'); } `, @@ -175,6 +176,40 @@ it("pages matching observations without changing dissection scope or frame ident } }); +it("preserves TShark canonical output for a caller-selected field alias", async () => { + const f = await fixture("alias-canonical"); + const input = { path: f.path, fields: ["bootp.type"] }; + const result = await f.service.inspect(input); + if (!result.ok) throw result.error; + const report = packetCaptureSchema.parse(result.value.normalized_result); + expect(report.selection.fields).toEqual(["bootp.type"]); + expect(report.packets).toEqual([ + { "frame.number": ["1"], "dhcp.type": ["1"] }, + ]); + expect(report.tool.arguments).toContain("bootp.type"); +}); + +it("returns canonical alias output through the public packet-capture CLI", async () => { + const f = await fixture("alias-canonical"); + const cli = Cli.create("capture-alias-test", { sync: false }); + registerCaptureCommands(cli, silentLogger, {}, f.service); + let stdout = ""; + await cli.serve( + [ + "inspect-packet-capture", + f.path, + "--fields", + "bootp.type", + "--format", + "json", + ], + { stdout: (text) => (stdout += text), exit: () => {} }, + ); + const result: unknown = JSON.parse(stdout); + expect(JSON.stringify(result)).toContain('"bootp.type"'); + expect(JSON.stringify(result)).toContain('"dhcp.type":["1"]'); +}); + it("bounds admission without queuing additional snapshots or delaying cancellation", async () => { const f = await fixture("gated"); const input = { path: f.path, fields: ["ip.addr"] }; diff --git a/tests/boundary/captures/packetOutput.test.ts b/tests/boundary/captures/packetOutput.test.ts index b51cd1f670..4b32ea6e18 100644 --- a/tests/boundary/captures/packetOutput.test.ts +++ b/tests/boundary/captures/packetOutput.test.ts @@ -15,7 +15,7 @@ it("preserves repeated field values and missing fields while discarding export i }, { _source: { layers: { "frame.number": ["7"] } } }, ]); - expect(parsePacketOutput(output, ["ip.addr"])).toEqual([ + expect(parsePacketOutput(output)).toEqual([ { "frame.number": ["2"], "ip.addr": ["192.0.2.1", "192.0.2.2"] }, { "frame.number": ["7"] }, ]); @@ -27,7 +27,6 @@ it.each( [{ "frame.number": ["1"], "ip.addr": "192.0.2.1" }], [{ "frame.number": ["0"] }], [{ "frame.number": ["1", "2"] }], - [{ "frame.number": ["1"], "unselected.field": ["hidden"] }], [{ "frame.number": ["2"] }, { "frame.number": ["2"] }], [{ "frame.number": ["2"] }, { "frame.number": ["1"] }], ].map((layers) => [layers]), @@ -35,7 +34,19 @@ it.each( expect(() => parsePacketOutput( JSON.stringify(layers.map((value) => ({ _source: { layers: value } }))), - ["ip.addr"], ), ).toThrow(); }); + +it("preserves canonical field names emitted for a selected alias", () => { + const output = JSON.stringify([ + { + _source: { + layers: { "frame.number": ["1"], "dhcp.type": ["1"] }, + }, + }, + ]); + expect(parsePacketOutput(output)).toEqual([ + { "frame.number": ["1"], "dhcp.type": ["1"] }, + ]); +});