Compare commits

..
6 changed files with 70 additions and 58 deletions

No files matched your search

+1 -4
View File
@@ -369,10 +369,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
resourcePath = "path" in input ? input.path : command;
// TShark resolves `-e` aliases before emitting canonical field names;
// the command arguments, not output names, own field selection.
const matchingPackets = parsePacketOutput(
output.stdout.text,
input.packet_limit,
);
const matchingPackets = parsePacketOutput(output.stdout.text);
const start = Math.min(input.match_offset, matchingPackets.length);
const end =
input.match_limit === null
+1 -6
View File
@@ -6,7 +6,7 @@ import { packetFieldsSchema } from "../domain/captures/packetCapture.js";
const producerSchema = z.array(
z.object({ _source: z.object({ layers: packetFieldsSchema }) }),
);
export const parsePacketOutput = (text: string, packetLimit: number | null) => {
export const parsePacketOutput = (text: string) => {
const decoded: unknown = JSON.parse(text);
const packets = producerSchema
.parse(decoded)
@@ -19,11 +19,6 @@ export const parsePacketOutput = (text: string, packetLimit: number | null) => {
"inspect_packet_capture",
"TShark returned unordered frames.",
);
if (packetLimit !== null && frame > BigInt(packetLimit))
throw new AnalysisOutputError(
"inspect_packet_capture",
"TShark returned a frame beyond the selected input packet limit.",
);
previous = frame;
}
return packets;
@@ -110,3 +110,47 @@ describe("web resource comparison identity and optional size", () => {
});
});
});
it.each([
[[128, 256], [128], [false, true], "unknown"],
[[128], [256], [true, true], "unknown"],
[[128], [128], [true, true], "unknown"],
[[128], [128, 256], [false, true], "changed"],
[[], [128], [false, true], "changed"],
[[128], [], [true, true], "unknown"],
[[128], [null], [false, true], "unknown"],
] as const)(
"uses directional resource coverage for %j vs %j (limited=%j): %s",
(leftSizes, rightSizes, [leftLimited, rightLimited], status) => {
const before = capture(leftSizes.map((size) => resource(size)));
const after = capture(rightSizes.map((size) => resource(size)));
if (leftLimited) before.completeness.truncated_sections.push("resources");
if (rightLimited) after.completeness.truncated_sections.push("resources");
for (const [left, right] of [
[before, after],
[after, before],
] as const)
expect(compareResources(left, right)).toMatchObject({
status,
total_changes: status === "changed" ? 1 : 0,
changes:
status === "changed"
? [
{
identity: expect.stringMatching(/^resg_/u),
change:
left.resources.length === 0
? "added"
: right.resources.length === 0
? "removed"
: "modified",
},
]
: [],
reason:
status === "changed"
? null
: expect.stringContaining("Resource inventory"),
});
},
);
+21 -13
View File
@@ -213,9 +213,8 @@ const compareWebCaptureDimensions = (
resources: compareResources(
before.resources,
after.resources,
sectionsComplete(before, ["resources"]) &&
sectionsComplete(after, ["resources"]),
"Resource inventory or content-size evidence was incomplete in at least one observation.",
sectionsComplete(before, ["resources"]),
sectionsComplete(after, ["resources"]),
),
network: networkDimension(before, after),
metadata: compareAggregate(
@@ -235,9 +234,12 @@ const compareWebCaptureDimensions = (
const compareResources = (
before: WebPageInspection["resources"],
after: WebPageInspection["resources"],
complete: boolean,
reason: string,
beforeComplete: boolean,
afterComplete: boolean,
): Dimension => {
const reason =
"Resource inventory or content-size evidence was incomplete in at least one observation.";
const complete = beforeComplete && afterComplete;
const left = resourceGroups(before);
const right = resourceGroups(after);
const changes: Change[] = [];
@@ -246,12 +248,14 @@ const compareResources = (
const leftSizes = left.get(identity);
const rightSizes = right.get(identity);
if (leftSizes === undefined || rightSizes === undefined) {
if (!complete) unresolved = true;
else
const absenceProven =
leftSizes === undefined ? beforeComplete : afterComplete;
if (absenceProven)
changes.push({
identity,
change: leftSizes === undefined ? "added" : "removed",
});
else unresolved = true;
continue;
}
// Stable inventories deduplicate identical observations. A null size also
@@ -261,7 +265,12 @@ const compareResources = (
unresolved = true;
continue;
}
if (!sameResourceSizes(leftSizes, rightSizes))
// A partial set can omit any size variant. Only a complete peer can
// contradict a retained variant, including when the other side is partial.
if (
(beforeComplete && !resourceSizesContained(rightSizes, leftSizes)) ||
(afterComplete && !resourceSizesContained(leftSizes, rightSizes))
)
changes.push({ identity, change: "modified" });
}
if (changes.length > 0)
@@ -292,11 +301,10 @@ const resourceGroups = (
return groups;
};
const sameResourceSizes = (
left: ReadonlySet<number | null>,
right: ReadonlySet<number | null>,
): boolean =>
left.size === right.size && [...left].every((size) => right.has(size));
const resourceSizesContained = (
observed: ReadonlySet<number | null>,
complete: ReadonlySet<number | null>,
): boolean => [...observed].every((size) => complete.has(size));
type BodyShapeSources = {
readonly request: boolean;
@@ -247,26 +247,6 @@ it("preserves TShark canonical output for a caller-selected field alias", async
expect(report.tool.arguments).toContain("bootp.type");
});
it("refuses producer frames outside the selected input read scope before paging", async () => {
const f = await fixture("many");
const request = { path: f.path, fields: ["ip.addr"], match_limit: 1 };
expect(
await f.service.inspect({ ...request, packet_limit: 10 }),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError", operation: "inspect_packet_capture" },
});
const admitted = await f.service.inspect({ ...request, packet_limit: 90 });
if (!admitted.ok) throw admitted.error;
const report = packetCaptureSchema.parse(admitted.value.normalized_result);
expect(report.output_window).toEqual({
matching_packets: 90,
returned_packets: 1,
has_more: true,
});
expect(report.packets[0]?.["frame.number"]).toEqual(["1"]);
});
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 });
+3 -15
View File
@@ -17,7 +17,7 @@ it("preserves repeated field values and missing fields while discarding export i
},
{ _source: { layers: { "frame.number": ["7"] } } },
]);
expect(parsePacketOutput(output, 7)).toEqual([
expect(parsePacketOutput(output)).toEqual([
{ "frame.number": ["2"], "ip.addr": ["192.0.2.1", "192.0.2.2"] },
{ "frame.number": ["7"] },
]);
@@ -49,7 +49,7 @@ it.each([
])("rejects malformed producer fields: $layers", ({ layers, issue }) => {
let failure: unknown;
try {
parsePacketOutput(JSON.stringify([{ _source: { layers } }]), null);
parsePacketOutput(JSON.stringify([{ _source: { layers } }]));
} catch (error) {
failure = error;
}
@@ -72,7 +72,6 @@ it.each([
_source: { layers: { "frame.number": frame } },
})),
),
null,
);
} catch (error) {
failure = error;
@@ -89,18 +88,7 @@ it("preserves canonical field names emitted for a selected alias", () => {
},
},
]);
expect(parsePacketOutput(output, null)).toEqual([
expect(parsePacketOutput(output)).toEqual([
{ "frame.number": ["1"], "dhcp.type": ["1"] },
]);
});
it("validates every frame against the input read limit while allowing filtered gaps", () => {
const output = JSON.stringify(
[2, 7].map((frame) => ({
_source: { layers: { "frame.number": [String(frame)] } },
})),
);
expect(parsePacketOutput(output, 7)).toHaveLength(2);
expect(() => parsePacketOutput(output, 6)).toThrow(AnalysisOutputError);
expect(parsePacketOutput(output, null)).toHaveLength(2);
});