diff --git a/Cargo.lock b/Cargo.lock
index a1a36d3..c56ad1c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -180,6 +180,12 @@ dependencies = [
"half",
]
+[[package]]
+name = "circular"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fc239e0f6cb375d2402d48afb92f76f5404fd1df208a41930ec81eda078bea"
+
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -769,6 +775,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pcap-parser"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f8d57cc6bdf76d7abd6d3cc1113278047dab29c2ff6d97190e8d1c29d4efdac"
+dependencies = [
+ "circular",
+ "nom",
+ "rusticata-macros",
+]
+
[[package]]
name = "petgraph"
version = "0.6.5"
@@ -1033,6 +1050,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
[[package]]
name = "rustix"
version = "1.1.4"
@@ -1410,6 +1436,7 @@ dependencies = [
name = "spar-trace-topology"
version = "0.9.3"
dependencies = [
+ "pcap-parser",
"serde",
"serde_json",
"spar-base-db",
diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml
index 675166a..e18e69d 100644
--- a/artifacts/requirements.yaml
+++ b/artifacts/requirements.yaml
@@ -1863,4 +1863,20 @@ artifacts:
status: implemented
tags: [trace-topology, ingest, lldp, v0100]
+ - id: REQ-TRACE-TOPOLOGY-003
+ type: requirement
+ title: PCAPNG FrameSource for L2 frame ingest
+ description: >
+ System shall provide a PCAPNG-backed FrameSource implementation
+ in spar-trace-topology that, given a `.pcapng` file recorded
+ with tcpdump / tshark / Wireshark from a TAP/SPAN port, yields
+ a stream of CapturedFrame records carrying mac_src, mac_dst,
+ optional 802.1Q VLAN-ID and PCP, and a Unix-epoch ns timestamp
+ derived from the per-IDB ts_resol option. Built atop the
+ `pcap-parser` crate. Errors carry concrete kinds (Truncated,
+ UnsupportedLinkType, MalformedPcapng, Io). Per the v0.10.0
+ trace-topology design's §"Implementation phasing" PCAPNG entry.
+ status: implemented
+ tags: [trace-topology, ingest, pcapng, v0100]
+
# Research findings tracked separately in research/findings.yaml
diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml
index 96ba763..136b304 100644
--- a/artifacts/verification.yaml
+++ b/artifacts/verification.yaml
@@ -2429,3 +2429,23 @@ artifacts:
links:
- type: satisfies
target: REQ-TRACE-TOPOLOGY-004
+
+ - id: TEST-TRACE-TOPOLOGY-PCAPNG
+ type: feature
+ title: PcapngFrameSource yields correct typed CapturedFrames
+ description: >
+ Tests in crates/spar-trace-topology/src/ingest.rs cover an
+ untagged Ethernet frame roundtrip, an 802.1Q-tagged frame with
+ VLAN-ID and PCP extraction, a truncated frame producing
+ IngestError::Truncated, an unsupported link type producing
+ IngestError::UnsupportedLinkType, and per-IDB ts_resol
+ handling for both µs and ns resolutions.
+ fields:
+ method: automated-test
+ steps:
+ - run: cargo test -p spar-trace-topology --lib -- ingest::tests
+ status: passing
+ tags: [trace-topology, ingest, pcapng, v0100]
+ links:
+ - type: satisfies
+ target: REQ-TRACE-TOPOLOGY-003
diff --git a/crates/spar-trace-topology/Cargo.toml b/crates/spar-trace-topology/Cargo.toml
index b478f23..166b1cd 100644
--- a/crates/spar-trace-topology/Cargo.toml
+++ b/crates/spar-trace-topology/Cargo.toml
@@ -12,6 +12,7 @@ spar-base-db.workspace = true
spar-syntax.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+pcap-parser = "0.16"
[dev-dependencies]
tempfile = "3"
diff --git a/crates/spar-trace-topology/src/ingest.rs b/crates/spar-trace-topology/src/ingest.rs
index bd0a9be..418c0b8 100644
--- a/crates/spar-trace-topology/src/ingest.rs
+++ b/crates/spar-trace-topology/src/ingest.rs
@@ -1,32 +1,51 @@
-//! Runtime-artefact parser surfaces.
+//! Runtime-artefact parsers feeding the v0.11.0 reconciliation engine.
//!
-//! v0.10.0 shipped placeholder traits only. v0.10.x sibling commits
-//! land the real parsers — PCAPNG (FrameSource), LLDP
-//! (TopologySource), Qcc YANG (SwitchConfigSource), gPTP
-//! (PtpTimeSource).
+//! v0.10.x lands these incrementally:
//!
-//! v0.10.x B-3 (this commit): real LLDP JSON `TopologySource`
-//! implementation backed by `lldpctl -f json` output (see
-//! ). The other three traits remain
-//! placeholders and are filled in by sibling commits.
+//! - **PCAPNG** ([`PcapngFrameSource`]) — implemented in v0.10.x B-2.
+//! Uses Pierre Chifflier's `pcap-parser` crate; yields typed
+//! [`CapturedFrame`] records carrying L2 identity (mac_src, mac_dst,
+//! optional 802.1Q VLAN-ID and PCP) plus a Unix-epoch nanosecond
+//! timestamp resolved via the per-IDB `ts_resol` option.
+//! - **LLDP** ([`LldpJsonTopologySource`]) — implemented in v0.10.x B-3.
+//! Backed by `lldpctl -f json` output (see );
+//! yields [`LldpNeighbor`] records carrying local_port + typed
+//! remote chassis-id / port-id / system-name.
+//! - **Qcc YANG** ([`SwitchConfigSource`]) — placeholder, sibling commit.
+//! - **gPTP** ([`PtpTimeSource`]) — placeholder, sibling commit.
//!
//! See `docs/designs/v0.10.0-trace-topology.md` §"Implementation
//! phasing" for the per-source roadmap.
use std::path::Path;
+use pcap_parser::traits::PcapReaderIterator;
+use pcap_parser::{Block, Linktype, PcapBlockOwned, PcapError, PcapNGReader};
+
+/// One captured L2 frame, distilled to the fields the v0.11.0
+/// reconciler consumes. Higher-layer headers are ignored — this is
+/// strictly L2 identity + timestamp.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CapturedFrame {
+ /// Source MAC address (Ethernet bytes 6..12).
+ pub mac_src: [u8; 6],
+ /// Destination MAC address (Ethernet bytes 0..6).
+ pub mac_dst: [u8; 6],
+ /// 802.1Q VLAN-ID (0..=4094) if the frame carried a 0x8100 tag.
+ pub vlan_id: Option,
+ /// 802.1Q PCP (0..=7) if the frame carried a 0x8100 tag.
+ pub pcp: Option,
+ /// Capture timestamp, normalised to nanoseconds since the
+ /// Unix epoch using the IDB `ts_resol` option (defaults to 1µs
+ /// per pcapng spec).
+ pub timestamp_ns: u64,
+}
+
/// Source of L2 frames captured at runtime — typically a PCAPNG file
/// recorded with `tcpdump`, `tshark`, or a TAP/SPAN port.
-///
-/// TODO(v0.10.0+): real parser — PCAPNG (RFC pcapng-draft / IETF
-/// opsawg-pcapng). See design doc §"Input artefact set" for the full
-/// list of supported link types and capture-options.
pub trait FrameSource {
- /// Open the frame source at `path`. v0.10.0 placeholder — the
- /// real parser returns an iterator of typed frames.
- fn open(path: &Path) -> Result
- where
- Self: Sized;
+ /// Iterate captured frames in capture order.
+ fn frames(&mut self) -> Box> + '_>;
}
/// Source of LLDP topology snapshots — neighbor adjacency observed at
@@ -279,32 +298,45 @@ fn type_name(v: &serde_json::Value) -> &'static str {
/// v0.10.0 shipped only `Unimplemented`; v0.10.x parsers extend this
/// enum additively with concrete I/O / format-decode kinds. The
/// `Unimplemented` variant is preserved for the placeholder trait
-/// `open()` calls that haven't been replaced yet.
+/// `open()` calls that haven't been replaced yet (Qcc YANG, gPTP).
#[derive(Debug)]
pub enum IngestError {
+ /// Underlying I/O error opening or reading the artefact file.
+ Io(std::io::Error),
+ /// Captured frame is shorter than a full L2 header (or shorter
+ /// than the 16 bytes required when an 802.1Q tag is present).
+ Truncated,
+ /// pcap-parser reported a malformed pcapng block / record.
+ MalformedPcapng(String),
+ /// pcapng link type other than Ethernet (LINKTYPE_ETHERNET = 1).
+ UnsupportedLinkType(i32),
+ /// LLDP JSON dump did not match the `lldpctl -f json` schema.
+ MalformedLldpJson(String),
/// The requested parser surface is not implemented in this
/// build of spar-trace-topology. v0.10.0 returned this from
/// every `open` call; v0.10.x parsers replace it with concrete
/// kinds as they land.
Unimplemented,
- /// Underlying I/O error opening the artefact file.
- Io(std::io::Error),
- /// LLDP JSON dump did not match the `lldpctl -f json` schema.
- MalformedLldpJson(String),
}
impl core::fmt::Display for IngestError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
- Self::Unimplemented => write!(
+ Self::Io(e) => write!(f, "I/O error: {e}"),
+ Self::Truncated => write!(f, "captured frame is shorter than a full L2 header"),
+ Self::MalformedPcapng(msg) => write!(f, "malformed pcapng: {msg}"),
+ Self::UnsupportedLinkType(lt) => write!(
f,
- "parser not implemented in v0.10.0 foundation; see \
- docs/designs/v0.10.0-trace-topology.md"
+ "unsupported pcapng link type {lt}; only Ethernet (1) is supported"
),
- Self::Io(e) => write!(f, "I/O error: {e}"),
Self::MalformedLldpJson(msg) => {
write!(f, "malformed lldpctl JSON: {msg}")
}
+ Self::Unimplemented => write!(
+ f,
+ "parser not implemented in v0.10.0 foundation; see \
+ docs/designs/v0.10.0-trace-topology.md"
+ ),
}
}
}
@@ -318,6 +350,245 @@ impl std::error::Error for IngestError {
}
}
+impl From for IngestError {
+ fn from(e: std::io::Error) -> Self {
+ Self::Io(e)
+ }
+}
+
+/// PCAPNG-backed [`FrameSource`] using the `pcap-parser` crate.
+///
+/// Reads the entire `.pcapng` into memory at `open()` time — pcapng
+/// captures from real deployments are bounded artefacts (typically
+/// tens to hundreds of MB), not pipes, so the simpler in-memory parse
+/// avoids the streaming-`consume()` lifetime gymnastics that
+/// `PcapNGReader` would otherwise require us to fight.
+#[derive(Debug)]
+pub struct PcapngFrameSource {
+ /// Raw pcapng bytes — owned so iteration can hold borrows
+ /// without juggling lifetimes against the source struct.
+ bytes: Vec,
+ /// Pre-validated link type from the first IDB. The current
+ /// implementation only accepts captures whose first IDB declares
+ /// LINKTYPE_ETHERNET; multi-IDB or per-frame link-type variation
+ /// is out-of-scope for v0.10.x.
+ linktype: Linktype,
+}
+
+impl PcapngFrameSource {
+ /// Open a pcapng file and validate the first Interface Description
+ /// Block declares `LINKTYPE_ETHERNET`.
+ pub fn open(path: &Path) -> Result {
+ let bytes = std::fs::read(path)?;
+ let linktype = first_idb_linktype(&bytes)?;
+ if linktype != Linktype::ETHERNET {
+ return Err(IngestError::UnsupportedLinkType(linktype.0));
+ }
+ Ok(Self { bytes, linktype })
+ }
+}
+
+impl FrameSource for PcapngFrameSource {
+ fn frames(&mut self) -> Box> + '_> {
+ Box::new(PcapngFrameIter::new(&self.bytes, self.linktype))
+ }
+}
+
+/// Iterator over captured frames in a pcapng buffer.
+struct PcapngFrameIter<'a> {
+ reader: Option>,
+ /// IDB ts_resolution (fractions-of-a-second; 1_000_000 = µs).
+ ts_resolution: u64,
+ /// Set true once we surface a fatal stream-level error so we
+ /// don't keep retrying the underlying parser.
+ done: bool,
+ /// Total input length — `PcapNGReader::reader_exhausted()` only
+ /// flips when the backing reader yields zero bytes on a refill,
+ /// which never happens for a `&[u8]` source that was fully
+ /// preloaded. Comparing against `position()` lets us distinguish
+ /// a truly-truncated tail block from clean EOF.
+ total_len: usize,
+ /// Pre-validated link type from `open()`. We ignore mid-stream
+ /// IDB changes for v0.10.x.
+ _linktype: Linktype,
+}
+
+impl<'a> PcapngFrameIter<'a> {
+ fn new(bytes: &'a [u8], linktype: Linktype) -> Self {
+ let reader = PcapNGReader::new(bytes.len().max(65536), bytes).ok();
+ let done = reader.is_none();
+ Self {
+ reader,
+ ts_resolution: DEFAULT_TS_RESOLUTION,
+ done,
+ total_len: bytes.len(),
+ _linktype: linktype,
+ }
+ }
+}
+
+/// Default IDB ts_resolution per pcapng spec §4.2 (`if_tsresol = 6`,
+/// i.e. 10^-6 seconds = µs). Means 1_000_000 ticks per second.
+const DEFAULT_TS_RESOLUTION: u64 = 1_000_000;
+
+impl Iterator for PcapngFrameIter<'_> {
+ type Item = Result;
+
+ fn next(&mut self) -> Option {
+ if self.done {
+ return None;
+ }
+ let reader = self.reader.as_mut()?;
+ loop {
+ match reader.next() {
+ Ok((offset, block)) => {
+ let outcome = match block {
+ PcapBlockOwned::NG(Block::SectionHeader(_)) => Step::Continue,
+ PcapBlockOwned::NG(Block::InterfaceDescription(idb)) => {
+ if let Some(res) = idb.ts_resolution() {
+ self.ts_resolution = res;
+ } else {
+ self.ts_resolution = DEFAULT_TS_RESOLUTION;
+ }
+ Step::Continue
+ }
+ PcapBlockOwned::NG(Block::EnhancedPacket(epb)) => {
+ match decode_ethernet(epb.data) {
+ Ok(eth) => {
+ let timestamp_ns = epb_timestamp_ns(
+ epb.ts_high,
+ epb.ts_low,
+ self.ts_resolution,
+ );
+ Step::Yield(Ok(CapturedFrame {
+ mac_src: eth.mac_src,
+ mac_dst: eth.mac_dst,
+ vlan_id: eth.vlan_id,
+ pcp: eth.pcp,
+ timestamp_ns,
+ }))
+ }
+ Err(e) => Step::Yield(Err(e)),
+ }
+ }
+ _ => Step::Continue,
+ };
+ reader.consume(offset);
+ match outcome {
+ Step::Continue => continue,
+ Step::Yield(item) => return Some(item),
+ }
+ }
+ Err(PcapError::Eof) => {
+ self.done = true;
+ return None;
+ }
+ Err(PcapError::Incomplete(_)) => {
+ self.done = true;
+ if reader.position() >= self.total_len {
+ return None;
+ }
+ return Some(Err(IngestError::MalformedPcapng(
+ "incomplete trailing block".to_string(),
+ )));
+ }
+ Err(e) => {
+ self.done = true;
+ return Some(Err(IngestError::MalformedPcapng(format!("{e:?}"))));
+ }
+ }
+ }
+ }
+}
+
+enum Step {
+ Continue,
+ Yield(Result),
+}
+
+/// L2 fields extracted from a single Ethernet frame's header.
+struct EthHeader {
+ mac_dst: [u8; 6],
+ mac_src: [u8; 6],
+ vlan_id: Option,
+ pcp: Option,
+}
+
+/// Decode the L2 header of an Ethernet frame.
+fn decode_ethernet(data: &[u8]) -> Result {
+ if data.len() < 14 {
+ return Err(IngestError::Truncated);
+ }
+ let mut mac_dst = [0u8; 6];
+ let mut mac_src = [0u8; 6];
+ mac_dst.copy_from_slice(&data[0..6]);
+ mac_src.copy_from_slice(&data[6..12]);
+ let ethertype = u16::from_be_bytes([data[12], data[13]]);
+ if ethertype == 0x8100 {
+ if data.len() < 16 {
+ return Err(IngestError::Truncated);
+ }
+ let tci = u16::from_be_bytes([data[14], data[15]]);
+ let pcp = ((tci >> 13) & 0x7) as u8;
+ let vlan_id = tci & 0x0FFF;
+ Ok(EthHeader {
+ mac_dst,
+ mac_src,
+ vlan_id: Some(vlan_id),
+ pcp: Some(pcp),
+ })
+ } else {
+ Ok(EthHeader {
+ mac_dst,
+ mac_src,
+ vlan_id: None,
+ pcp: None,
+ })
+ }
+}
+
+/// Convert an EPB ts_high/ts_low pair to ns-since-Unix-epoch using
+/// the IDB's ts_resolution (fractions-of-a-second).
+fn epb_timestamp_ns(ts_high: u32, ts_low: u32, resolution: u64) -> u64 {
+ let ticks = (u64::from(ts_high) << 32) | u64::from(ts_low);
+ if resolution == 0 {
+ return 0;
+ }
+ ticks
+ .saturating_mul(1_000_000_000)
+ .saturating_div(resolution)
+}
+
+/// Pull the link type from the first Interface Description Block.
+fn first_idb_linktype(bytes: &[u8]) -> Result {
+ let mut reader = PcapNGReader::new(bytes.len().max(65536), bytes)
+ .map_err(|e| IngestError::MalformedPcapng(format!("{e:?}")))?;
+ loop {
+ match reader.next() {
+ Ok((offset, block)) => {
+ if let PcapBlockOwned::NG(Block::InterfaceDescription(idb)) = block {
+ let lt = idb.linktype;
+ return Ok(lt);
+ }
+ reader.consume(offset);
+ }
+ Err(PcapError::Eof) => {
+ return Err(IngestError::MalformedPcapng(
+ "no InterfaceDescriptionBlock found".to_string(),
+ ));
+ }
+ Err(PcapError::Incomplete(_)) => {
+ return Err(IngestError::MalformedPcapng(
+ "incomplete pcapng prefix".to_string(),
+ ));
+ }
+ Err(e) => {
+ return Err(IngestError::MalformedPcapng(format!("{e:?}")));
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -473,4 +744,182 @@ mod tests {
assert_eq!(src.neighbors().len(), 1);
assert_eq!(src.neighbors()[0].local_port, "eth0");
}
+
+ // ── PCAPNG tests ────────────────────────────────────────────────
+ use std::io::Write as _;
+
+ /// Hand-build a minimal valid pcapng buffer:
+ /// SHB + IDB(linktype, if_tsresol) + EPB(ts_high, ts_low, frame_data).
+ fn build_pcapng(
+ linktype_id: u16,
+ if_tsresol: u8,
+ ts_high: u32,
+ ts_low: u32,
+ frame: &[u8],
+ ) -> Vec {
+ let mut out = Vec::new();
+
+ // Section Header Block (block_type 0x0A0D0D0A).
+ let shb_total = 4 + 4 + 4 + 4 + 8 + 4;
+ out.extend_from_slice(&0x0A0D_0D0A_u32.to_le_bytes());
+ out.extend_from_slice(&(shb_total as u32).to_le_bytes());
+ out.extend_from_slice(&0x1A2B_3C4D_u32.to_le_bytes());
+ out.extend_from_slice(&1u16.to_le_bytes());
+ out.extend_from_slice(&0u16.to_le_bytes());
+ out.extend_from_slice(&(-1_i64).to_le_bytes());
+ out.extend_from_slice(&(shb_total as u32).to_le_bytes());
+
+ // Interface Description Block (block_type 0x00000001).
+ let mut idb_options: Vec = Vec::new();
+ idb_options.extend_from_slice(&9u16.to_le_bytes());
+ idb_options.extend_from_slice(&1u16.to_le_bytes());
+ idb_options.push(if_tsresol);
+ idb_options.extend_from_slice(&[0u8, 0, 0]);
+ idb_options.extend_from_slice(&0u16.to_le_bytes());
+ idb_options.extend_from_slice(&0u16.to_le_bytes());
+
+ let idb_total = 4 + 4 + 2 + 2 + 4 + idb_options.len() + 4;
+ out.extend_from_slice(&0x0000_0001_u32.to_le_bytes());
+ out.extend_from_slice(&(idb_total as u32).to_le_bytes());
+ out.extend_from_slice(&linktype_id.to_le_bytes());
+ out.extend_from_slice(&0u16.to_le_bytes());
+ out.extend_from_slice(&65535u32.to_le_bytes());
+ out.extend_from_slice(&idb_options);
+ out.extend_from_slice(&(idb_total as u32).to_le_bytes());
+
+ // Enhanced Packet Block (block_type 0x00000006).
+ let pad_len = (4 - (frame.len() % 4)) % 4;
+ let epb_data_padded_len = frame.len() + pad_len;
+ let epb_total = 4 + 4 + 4 + 4 + 4 + 4 + 4 + epb_data_padded_len + 4;
+ out.extend_from_slice(&0x0000_0006_u32.to_le_bytes());
+ out.extend_from_slice(&(epb_total as u32).to_le_bytes());
+ out.extend_from_slice(&0u32.to_le_bytes());
+ out.extend_from_slice(&ts_high.to_le_bytes());
+ out.extend_from_slice(&ts_low.to_le_bytes());
+ out.extend_from_slice(&(frame.len() as u32).to_le_bytes());
+ out.extend_from_slice(&(frame.len() as u32).to_le_bytes());
+ out.extend_from_slice(frame);
+ out.extend_from_slice(&vec![0u8; pad_len]);
+ out.extend_from_slice(&(epb_total as u32).to_le_bytes());
+
+ out
+ }
+
+ fn write_to_tempfile(bytes: &[u8]) -> tempfile::NamedTempFile {
+ let mut f = tempfile::NamedTempFile::new().unwrap();
+ f.write_all(bytes).unwrap();
+ f.flush().unwrap();
+ f
+ }
+
+ fn untagged_eth(dst: [u8; 6], src: [u8; 6], ethertype: u16) -> Vec {
+ let mut v = Vec::with_capacity(14);
+ v.extend_from_slice(&dst);
+ v.extend_from_slice(&src);
+ v.extend_from_slice(ðertype.to_be_bytes());
+ v
+ }
+
+ fn tagged_eth(
+ dst: [u8; 6],
+ src: [u8; 6],
+ vlan_id: u16,
+ pcp: u8,
+ inner_ethertype: u16,
+ ) -> Vec {
+ let mut v = Vec::with_capacity(16);
+ v.extend_from_slice(&dst);
+ v.extend_from_slice(&src);
+ v.extend_from_slice(&0x8100_u16.to_be_bytes());
+ let tci = ((u16::from(pcp) & 0x7) << 13) | (vlan_id & 0x0FFF);
+ v.extend_from_slice(&tci.to_be_bytes());
+ v.extend_from_slice(&inner_ethertype.to_be_bytes());
+ v
+ }
+
+ #[test]
+ fn pcapng_roundtrip_untagged_frame() {
+ let dst = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01];
+ let src = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x02];
+ let frame = untagged_eth(dst, src, 0x0800);
+ let ts_high = 1u32;
+ let ts_low = 100u32;
+ let bytes = build_pcapng(1, 6, ts_high, ts_low, &frame);
+ let f = write_to_tempfile(&bytes);
+
+ let mut src_iter = PcapngFrameSource::open(f.path()).unwrap();
+ let frames: Vec<_> = src_iter.frames().collect();
+ assert_eq!(frames.len(), 1);
+ let cf = frames.into_iter().next().unwrap().unwrap();
+ assert_eq!(cf.mac_dst, dst);
+ assert_eq!(cf.mac_src, src);
+ assert_eq!(cf.vlan_id, None);
+ assert_eq!(cf.pcp, None);
+ let expected_ticks = (u64::from(ts_high) << 32) | u64::from(ts_low);
+ assert_eq!(cf.timestamp_ns, expected_ticks * 1000);
+ }
+
+ #[test]
+ fn pcapng_roundtrip_8021q_tagged() {
+ let dst = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06];
+ let src = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
+ let frame = tagged_eth(dst, src, 100, 5, 0x0800);
+ let bytes = build_pcapng(1, 6, 0, 12345, &frame);
+ let f = write_to_tempfile(&bytes);
+
+ let mut s = PcapngFrameSource::open(f.path()).unwrap();
+ let frames: Vec<_> = s.frames().collect();
+ assert_eq!(frames.len(), 1);
+ let cf = frames.into_iter().next().unwrap().unwrap();
+ assert_eq!(cf.mac_dst, dst);
+ assert_eq!(cf.mac_src, src);
+ assert_eq!(cf.vlan_id, Some(100));
+ assert_eq!(cf.pcp, Some(5));
+ assert_eq!(cf.timestamp_ns, 12345 * 1000);
+ }
+
+ #[test]
+ fn pcapng_truncated_frame_yields_error() {
+ let frame = vec![0u8; 8];
+ let bytes = build_pcapng(1, 6, 0, 0, &frame);
+ let f = write_to_tempfile(&bytes);
+
+ let mut s = PcapngFrameSource::open(f.path()).unwrap();
+ let mut it = s.frames();
+ let first = it.next().expect("expected one item");
+ match first {
+ Err(IngestError::Truncated) => {}
+ other => panic!("expected Truncated, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn pcapng_unsupported_linktype_errors_at_open() {
+ let frame = vec![0u8; 20];
+ let bytes = build_pcapng(101, 6, 0, 0, &frame);
+ let f = write_to_tempfile(&bytes);
+
+ match PcapngFrameSource::open(f.path()) {
+ Err(IngestError::UnsupportedLinkType(101)) => {}
+ other => panic!("expected UnsupportedLinkType(101), got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn pcapng_ts_resol_nanoseconds() {
+ let dst = [0x11; 6];
+ let src = [0x22; 6];
+ let frame = untagged_eth(dst, src, 0x88B5);
+ let total_ticks: u64 = 9_876_543_210;
+ let ts_high = (total_ticks >> 32) as u32;
+ let ts_low = (total_ticks & 0xFFFF_FFFF) as u32;
+ let bytes = build_pcapng(1, 9, ts_high, ts_low, &frame);
+ let f = write_to_tempfile(&bytes);
+
+ let mut s = PcapngFrameSource::open(f.path()).unwrap();
+ let frames: Vec<_> = s.frames().collect();
+ let cf = frames.into_iter().next().unwrap().unwrap();
+ let expected = (u64::from(ts_high) << 32) | u64::from(ts_low);
+ assert_eq!(cf.timestamp_ns, expected);
+ }
}
diff --git a/crates/spar-trace-topology/src/lib.rs b/crates/spar-trace-topology/src/lib.rs
index 96cbf88..33ea39e 100644
--- a/crates/spar-trace-topology/src/lib.rs
+++ b/crates/spar-trace-topology/src/lib.rs
@@ -42,4 +42,7 @@ pub mod ingest;
pub mod reconcile;
pub mod report;
-pub use ingest::{LldpJsonTopologySource, LldpNeighbor, TopologySource};
+pub use ingest::{
+ CapturedFrame, FrameSource, IngestError, LldpJsonTopologySource, LldpNeighbor,
+ PcapngFrameSource, TopologySource,
+};
diff --git a/supply-chain/config.toml b/supply-chain/config.toml
index a4191b9..5ed2f85 100644
--- a/supply-chain/config.toml
+++ b/supply-chain/config.toml
@@ -96,6 +96,10 @@ criteria = "safe-to-run"
version = "0.2.2"
criteria = "safe-to-run"
+[[exemptions.circular]]
+version = "0.3.0"
+criteria = "safe-to-deploy"
+
[[exemptions.clang-sys]]
version = "1.8.1"
criteria = "safe-to-deploy"
@@ -364,6 +368,10 @@ criteria = "safe-to-deploy"
version = "0.9.12"
criteria = "safe-to-deploy"
+[[exemptions.pcap-parser]]
+version = "0.16.0"
+criteria = "safe-to-deploy"
+
[[exemptions.petgraph]]
version = "0.6.5"
criteria = "safe-to-deploy"
@@ -476,6 +484,10 @@ criteria = "safe-to-deploy"
version = "1.1.0"
criteria = "safe-to-deploy"
+[[exemptions.rusticata-macros]]
+version = "4.1.0"
+criteria = "safe-to-deploy"
+
[[exemptions.rustix]]
version = "1.1.4"
criteria = "safe-to-run"