mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 14:56:32 +00:00
fix(pcap): preserve exact UTC capture instants
Request epoch timestamps from capinfos and normalize them once at the metadata boundary, preserving nanosecond precision and explicit unknowns. Bound numeric parsing to the producer representation and cover negative fractions, calendar limits, malformed output and MCP projection. Adapted from PR #4909. Fixes #4907. Validation: 47 passed, 24 platform-specific skips across five focused files; check:fast and test discovery passed. No live Wireshark or Linux/Windows capture execution was performed on this macOS host. Co-authored-by: Mert Kaya <47145541+mertkayacs@users.noreply.github.com>
This commit is contained in:
1 parent
f0b9ef34c4
commit
796faa0e4e
9 files changed
+130
-19
No files matched your search
@@ -48,8 +48,8 @@ const REAL_CAPINFOS = [
|
||||
"Number of packets: 50",
|
||||
"File size: 23279 bytes",
|
||||
"Capture duration: 0.373473 seconds",
|
||||
"Earliest packet time: 2026-10-11 14:13:40.608505",
|
||||
"Latest packet time: 2026-10-11 14:13:40.981978",
|
||||
"Earliest packet time: 1791728020.608505",
|
||||
"Latest packet time: 1791728020.981978",
|
||||
"SHA256: c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
|
||||
].join("\n");
|
||||
|
||||
@@ -59,8 +59,8 @@ describe("parseCapinfosOutput", () => {
|
||||
fileType: "Wireshark/tcpdump/... - pcap",
|
||||
encapsulation: "Linux cooked-mode capture v2",
|
||||
packetCount: 50,
|
||||
captureStart: "2026-10-11 14:13:40.608505",
|
||||
captureEnd: "2026-10-11 14:13:40.981978",
|
||||
captureStart: "2026-10-11T14:13:40.608505Z",
|
||||
captureEnd: "2026-10-11T14:13:40.981978Z",
|
||||
captureDurationSeconds: 0.373473,
|
||||
snapshotLength: 262144,
|
||||
sha256:
|
||||
@@ -74,8 +74,67 @@ describe("parseCapinfosOutput", () => {
|
||||
"First packet time",
|
||||
).replace("Latest packet time", "Last packet time");
|
||||
const metadata = parseCapinfosOutput(output);
|
||||
expect(metadata.captureStart).toBe("2026-10-11 14:13:40.608505");
|
||||
expect(metadata.captureEnd).toBe("2026-10-11 14:13:40.981978");
|
||||
expect(metadata.captureStart).toBe("2026-10-11T14:13:40.608505Z");
|
||||
expect(metadata.captureEnd).toBe("2026-10-11T14:13:40.981978Z");
|
||||
});
|
||||
|
||||
it("projects epoch timestamps to UTC instants and keeps other spellings unknown", () => {
|
||||
const withTimes = (start: string, end: string): string =>
|
||||
REAL_CAPINFOS.replace("1791728020.608505", start).replace(
|
||||
"1791728020.981978",
|
||||
end,
|
||||
);
|
||||
expect(
|
||||
parseCapinfosOutput(withTimes("1791728020", "1791728020.500000")),
|
||||
).toMatchObject({
|
||||
captureStart: "2026-10-11T14:13:40Z",
|
||||
captureEnd: "2026-10-11T14:13:40.500000Z",
|
||||
});
|
||||
// capinfos prints n/a for an empty capture, and its default wall-clock
|
||||
// spelling carries no zone, so neither names an instant.
|
||||
expect(
|
||||
parseCapinfosOutput(withTimes("n/a", "2026-10-11 14:13:40.981978")),
|
||||
).toMatchObject({ captureStart: null, captureEnd: null });
|
||||
// a packet before the epoch prints with a leading minus; the whole seconds
|
||||
// and the fraction are borrowed so the instant stays exact
|
||||
expect(parseCapinfosOutput(withTimes("-1", "-1.250000"))).toMatchObject({
|
||||
captureStart: "1969-12-31T23:59:59Z",
|
||||
captureEnd: "1969-12-31T23:59:58.750000Z",
|
||||
});
|
||||
expect(parseCapinfosOutput(withTimes("-0.5", "0"))).toMatchObject({
|
||||
captureStart: "1969-12-31T23:59:59.5Z",
|
||||
captureEnd: "1970-01-01T00:00:00Z",
|
||||
});
|
||||
// beyond the RFC 3339 year range in either direction stays unknown
|
||||
expect(
|
||||
parseCapinfosOutput(withTimes("-62167219201", "253402300800")),
|
||||
).toMatchObject({ captureStart: null, captureEnd: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["0.000000001", "1970-01-01T00:00:00.000000001Z"],
|
||||
["-0.000000001", "1969-12-31T23:59:59.999999999Z"],
|
||||
["-1.000000", "1969-12-31T23:59:59.000000Z"],
|
||||
["-0.000000", "1970-01-01T00:00:00.000000Z"],
|
||||
["-62167219200.000000001", null],
|
||||
["-62167219200", "0000-01-01T00:00:00Z"],
|
||||
["253402300799.999999999", "9999-12-31T23:59:59.999999999Z"],
|
||||
["253402300800", null],
|
||||
["NaN", null],
|
||||
["Infinity", null],
|
||||
["1791728020.5junk", null],
|
||||
["1e3", null],
|
||||
["1.1234567890", null],
|
||||
["9".repeat(1000), null],
|
||||
])("preserves the exact instant or unknown for %s", (text, expected) => {
|
||||
const metadata = parseCapinfosOutput(
|
||||
REAL_CAPINFOS.replace("1791728020.608505", text).replace(
|
||||
"1791728020.981978",
|
||||
text,
|
||||
),
|
||||
);
|
||||
expect(metadata.captureStart).toBe(expected);
|
||||
expect(metadata.captureEnd).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps missing capture timestamps unknown", () => {
|
||||
|
||||
+40
-6
@@ -27,6 +27,40 @@ export interface CapinfosMetadata {
|
||||
readonly sha256: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* capinfos -S prints each packet timestamp as seconds since the Unix epoch
|
||||
* with the capture's own fractional precision, a leading minus for an instant
|
||||
* before the epoch. Its default spelling is a zone-less local wall-clock time,
|
||||
* so one capture reads differently on hosts in different time zones. Project
|
||||
* the exact value to an RFC 3339 UTC instant, keeping the fractional digit
|
||||
* count. "n/a" (an empty capture), a wall-clock spelling and a value outside
|
||||
* years 0000 to 9999 stay unknown.
|
||||
*/
|
||||
const captureInstant = (value: string | undefined): string | null => {
|
||||
// Wireshark formats at most 64-bit seconds and nine fractional digits.
|
||||
const match = /^(-?)(\d{1,20})(?:\.(\d{1,9}))?$/u.exec(value ?? "");
|
||||
if (match?.[2] === undefined) return null;
|
||||
const negative = match[1] === "-";
|
||||
let seconds = BigInt(match[2]);
|
||||
let fraction = match[3] ?? "";
|
||||
if (negative) {
|
||||
// -1.25 is one and a quarter seconds before the epoch: whole second
|
||||
// -2 plus 0.75, so the fraction is borrowed from the next whole second.
|
||||
const digits = fraction.length;
|
||||
const borrowed = digits > 0 && BigInt(fraction) > 0n;
|
||||
seconds = -seconds - (borrowed ? 1n : 0n);
|
||||
if (borrowed)
|
||||
fraction = (10n ** BigInt(digits) - BigInt(fraction))
|
||||
.toString()
|
||||
.padStart(digits, "0");
|
||||
}
|
||||
if (seconds < -62_167_219_200n || seconds > 253_402_300_799n) return null;
|
||||
const date = new Date(Number(seconds) * 1000);
|
||||
const year = date.getUTCFullYear();
|
||||
if (Number.isNaN(year) || year < 0 || year > 9999) return null;
|
||||
return `${date.toISOString().slice(0, 19)}${fraction === "" ? "" : `.${fraction}`}Z`;
|
||||
};
|
||||
|
||||
/** capinfos -M with a C locale provides exact numbers instead of "20 k". */
|
||||
export const parseCapinfosOutput = (stdout: string): CapinfosMetadata => {
|
||||
const lines = new Map<string, string>();
|
||||
@@ -66,12 +100,12 @@ export const parseCapinfosOutput = (stdout: string): CapinfosMetadata => {
|
||||
fileType,
|
||||
encapsulation,
|
||||
packetCount,
|
||||
captureStart:
|
||||
lines.get("Earliest packet time") ??
|
||||
lines.get("First packet time") ??
|
||||
null,
|
||||
captureEnd:
|
||||
lines.get("Latest packet time") ?? lines.get("Last packet time") ?? null,
|
||||
captureStart: captureInstant(
|
||||
lines.get("Earliest packet time") ?? lines.get("First packet time"),
|
||||
),
|
||||
captureEnd: captureInstant(
|
||||
lines.get("Latest packet time") ?? lines.get("Last packet time"),
|
||||
),
|
||||
captureDurationSeconds: duration === null ? null : Number(duration[1]),
|
||||
snapshotLength:
|
||||
snapshotLength !== null &&
|
||||
|
||||
@@ -578,7 +578,7 @@ export class PcapProvider {
|
||||
const info = await this.run(
|
||||
"inspect_pcap_capture",
|
||||
selection.capinfos.command,
|
||||
["-M", "--", snapshot.path],
|
||||
["-M", "-S", "--", snapshot.path],
|
||||
{ timeoutMs: 60_000, maxOutputBytes: 64 * 1024 },
|
||||
signal,
|
||||
snapshot,
|
||||
|
||||
Reference in new issue
Block a user