32 KiB
Phase 4: Power User Features - Research
Researched: 2026-03-26 Domain: BPF filter integration, pcap file reading, timestamp-based aggregation Confidence: HIGH
Summary
Phase 4 adds two additive CLI features to an already working pipeline: a --filter flag that scopes live capture (or offline replay) using BPF syntax, and a --read flag that replaces the live capture source with an existing pcap file. Both features wire into the existing classify → aggregate → synthesis pipeline without touching audio synthesis, classification rules, or MP3 encoding.
The key insight from reading the actual library source in the module cache is that go-pcap's Handle.SetBPFFilter(expr string) error is available as a first-class method; it compiles a tcpdump-syntax BPF expression into kernel BPF instructions and attaches them to the socket. For pcap file reading, gopacket/pcapgo.NewReader(r io.Reader) reads standard pcap (v2.4) files and returns a *Reader whose ReadPacketData() method satisfies gopacket.PacketDataSource — so it plugs directly into gopacket.NewPacketSource, exactly as the live go-pcap handle does. Timestamp-based windowing for offline mode replaces the real-time time.Ticker with arithmetic over gopacket.CaptureInfo.Timestamp.
Primary recommendation: Add SetBPFFilter to OpenCapture and add a new ReadPcapFile function in capture/capture.go that returns the same <-chan gopacket.Packet channel as StartCapture. Add a AggregatePcap function in aggregate/window.go that uses packet timestamps instead of a ticker. Wire both paths in main.go.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Use packet timestamps from the pcap file to assign packets to 500ms time windows. Processing is fast — a 10-minute pcap produces a 10-minute MP3 in seconds.
- D-02: Preserve time gaps as silence. Windows with zero packets during gaps produce silent audio sections. MP3 duration faithfully matches the pcap file's time span.
- D-03:
--readand-iare mutually exclusive. Error if both provided.--readreplaces-ias the packet source. - D-04:
--filterworks with both-i(live capture) and--read(pcap file). BPF filter applies to whichever packet source is active. - D-05:
--readwithout-oderives output filename from input:capture.pcap→capture.mp3. - D-06: Bookend messages:
"Reading <file>..."at start, then protocol summary +"Saved"line at end. No progress bar. - D-07:
--verboseworks with--read— per-window protocol activity lines scroll by quickly. Consistent behavior regardless of packet source.
Claude's Discretion
- BPF filter validation approach (pre-validate before opening capture vs let go-pcap/gopacket reject it)
- Pcap file format detection and error messages for corrupt/unreadable files
- Implementation of timestamp-based windowing (new aggregation path vs adapter that feeds existing Aggregate())
- How to handle pcap files with no packets (reuse existing zero-packet guard from OUT-03)
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CAPT-05 | User can filter captured traffic using BPF syntax via --filter flag |
Handle.SetBPFFilter(expr) on go-pcap handle; filter.NewExpression(expr).Compile() for pre-validation without a live socket; errors surface before capture begins |
| CAPT-06 | User can sonify a pcap file instead of live traffic via --read flag |
pcapgo.NewReader(f) + gopacket.NewPacketSource gives same packet channel interface; CaptureInfo.Timestamp provides per-packet timestamps for window assignment per D-01/D-02 |
| </phase_requirements> |
Standard Stack
Core (all already in go.mod — no new dependencies)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
github.com/packetcap/go-pcap |
v0.0.0-20251215 | BPF filter on live handle | Already used; Handle.SetBPFFilter(string) error is a first-class method |
github.com/gopacket/gopacket/pcapgo |
included in gopacket v1.5.0 | Pcap file reading | Part of gopacket; pcapgo.NewReader(io.Reader) is the canonical pure-Go pcap file reader |
github.com/gopacket/gopacket |
v1.5.0 | Packet decoding from pcapgo reader | Already used; gopacket.NewPacketSource(pcapgoReader, linkType) identical to live path |
github.com/spf13/cobra |
v1.10.2 | New --filter and --read flags |
Already used |
No new dependencies needed. pcapgo is a sub-package of gopacket already in go.mod.
Installation: None required. All libraries are already present in go.mod/go.sum.
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
golang.org/x/net/bpf |
via go.sum (transitive) | BPF instruction assembly inside go-pcap | Not called directly; go-pcap's SetBPFFilter uses it internally |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
pcapgo.NewReader (already in gopacket) |
External libpcap CGo path | Libpcap can read pcap files but adds CGo and system library dependency — no benefit |
In-place BPF filter on --read |
Software filter in classify stage | go-pcap's SetBPFFilter only works on live sockets; for pcap file reading the filter must be applied in software after decoding. See Pitfall 2 below. |
Architecture Patterns
Recommended Project Structure
capture/
├── capture.go # OpenCapture (add filter param), StartCapture (add filter param), ReadPcapFile (new)
aggregate/
├── window.go # Aggregate (unchanged), AggregatePcap (new timestamp-based variant)
cmd/netsynth/
└── main.go # New --filter and --read Cobra flags; branch run() into live vs pcap paths
Pattern 1: BPF Filter on Live Handle
What: Call handle.SetBPFFilter(expr) immediately after pcap.OpenLive succeeds, before starting the read loop. The filter is installed in the kernel; packets not matching the expression are silently dropped by the kernel before they reach user space.
When to use: When -i is active (live capture mode) and --filter is provided.
Validated API (verified by reading /home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/pcap.go):
// Source: go-pcap pcap.go SetBPFFilter method
func (h *Handle) SetBPFFilter(expr string) error {
expr2 := strings.TrimSpace(expr)
if expr2 == "" {
return nil
}
e := filter.NewExpression(expr2)
if e == nil {
return fmt.Errorf("no expression received for filter '%s'", expr)
}
f := e.Compile()
instructions, err := f.Compile()
if err != nil {
return fmt.Errorf("failed to compile filter into instructions: %v", err)
}
raw, err := bpf.Assemble(instructions)
if err != nil {
return fmt.Errorf("bpf assembly failed: %v", err)
}
return h.SetRawBPFFilter(raw)
}
Usage in OpenCapture (updated signature):
// capture/capture.go
func OpenCapture(ctx context.Context, iface string, filter string) (*pcap.Handle, error) {
handle, err := pcap.OpenLive(ctx, iface, 65535, false, 0, false)
if err != nil { ... }
if filter != "" {
if err := handle.SetBPFFilter(filter); err != nil {
handle.Close()
return nil, fmt.Errorf("invalid BPF filter %q: %w", filter, err)
}
}
return handle, nil
}
Error message pattern for invalid BPF: The error from SetBPFFilter surfaces as "failed to compile filter into instructions: parse error" for unrecognized tokens, or "failed to compile filter into instructions: unknown host: abc" for unresolvable hostnames. Wrapping with fmt.Errorf("invalid BPF filter %q: %w", expr, err) gives the user the expression and the underlying reason.
Pattern 2: BPF Filter for Pcap File (Software Filter)
What: go-pcap's SetBPFFilter attaches to a Linux raw socket — it cannot be called on a pcapgo file reader. For --read mode, BPF filtering must happen in the classify goroutine or as an adapter between the packet channel and the classify channel.
Recommended approach (Claude's Discretion): Apply a software BPF filter in a thin wrapper over the packet channel. Use go-pcap's filter sub-package directly to pre-compile the expression and match each packet's raw bytes:
// Option A (simpler): validate expression early, then filter packets in a goroutine
// using filter.NewExpression(expr).Compile() which returns a Filter that has a
// Match([]byte) bool method. Verify Match signature from filter package source.
Important caveat: After examining filter/compile.go, the Filter interface has a Compile() ([]bpf.Instruction, error) method for kernel assembly but the per-packet matching for user-space is via golang.org/x/net/bpf.VM. This adds a dependency on bpf.NewVM(instructions) and vm.Run(packet) for software filtering.
Simpler alternative: For --read mode, skip hardware BPF entirely and apply the filter as a software drop in the classify stage: decode the packet normally, then check if it matches. But this defeats the "filter before classification" separation.
Most practical approach for this phase: Use bpf.NewVM from golang.org/x/net/bpf (already in go.sum as a transitive dependency) to run BPF programs in user space against pcap file packets.
// Source: golang.org/x/net/bpf VM interface (standard, HIGH confidence)
import "golang.org/x/net/bpf"
instructions, err := filter.NewExpression(expr).Compile().Compile()
vm, err := bpf.NewVM(instructions)
// Per packet:
ok, err := vm.Run(packetData)
// ok > 0 means packet passes the filter
Pattern 3: Pcap File Reading
What: Use gopacket/pcapgo.NewReader(io.Reader) to open a pcap file, then gopacket.NewPacketSource(reader, reader.LinkType()) to get the same packet-producing interface as live capture.
Validated API (verified by reading /home/dev/go/pkg/mod/github.com/gopacket/gopacket@v1.5.0/pcapgo/read.go):
// Source: gopacket/pcapgo read.go
import (
"os"
"github.com/gopacket/gopacket"
"github.com/gopacket/gopacket/pcapgo"
)
func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot open pcap file %q: %w", path, err)
}
r, err := pcapgo.NewReader(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("invalid pcap file %q: %w", path, err)
}
// r.LinkType() returns layers.LinkType — same as handle.LinkType() in live mode
packetSource := gopacket.NewPacketSource(r, r.LinkType())
packetSource.NoCopy = false // pcapgo allocates per packet; NoCopy=false is correct here
packets := make(chan gopacket.Packet, 512)
go func() {
defer close(packets)
defer f.Close()
for pkt := range packetSource.Packets() {
packets <- pkt
}
}()
return packets, nil
}
Key observation: pcapgo.NewReader reads and validates the pcap file header immediately. If the file is corrupt, truncated, or not a pcap file, NewReader returns an error before any packets are read. This is the natural pre-flight validation.
CaptureInfo.Timestamp: Every packet from pcapgo has pkt.Metadata().CaptureInfo.Timestamp set to the recorded timestamp. This is the time value used for D-01 window assignment.
Pattern 4: Timestamp-Based Aggregation for Pcap Mode
What: Replace the time.Ticker in Aggregate() with arithmetic over packet timestamps. Compute the current window index as int(pkt.Timestamp.Sub(firstPacket).Milliseconds()) / windowMs and flush when the index advances.
When to use: When --read mode is active. Reuse existing Aggregate() for live mode unchanged.
New function in aggregate/window.go:
// AggregatePcap reads ClassifiedPackets that include a Timestamp field,
// assigns each to a windowMs window relative to the first packet timestamp,
// fills gap windows with empty snapshots per D-02, and emits all windows.
// Returns a []WindowSnapshot directly (not a channel) because pcap processing
// is synchronous and all snapshots are available before encode.RunSynthesis.
func AggregatePcap(events []classify.ClassifiedPacket, timestamps []time.Time, windowMs int) []classify.WindowSnapshot
Alternative: Pass timestamps alongside ClassifiedPackets by adding a Timestamp time.Time field to classify.ClassifiedPacket. This keeps the pipeline shape consistent.
Recommended approach (Claude's Discretion): Add Timestamp time.Time to ClassifiedPacket. The classify goroutine already reads pkt.Metadata().CaptureInfo.Timestamp — adding it to the struct costs one field. Then a new AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int) []classify.WindowSnapshot function collects all events, assigns them to windows, and fills gaps. This reuses the existing classify stage unchanged and requires only a new aggregation function.
Gap-filling algorithm (D-02):
// For each window index from 0 to maxWindowIndex (inclusive):
// if window has packets → emit WindowSnapshot with counts
// if window is empty → emit WindowSnapshot with TotalPackets=0, empty Counts
// This preserves silence for pcap files with quiet periods.
Pattern 5: Filename Derivation (D-05)
// capture.pcap -> capture.mp3
func deriveOutputPath(readPath string) string {
ext := filepath.Ext(readPath)
return strings.TrimSuffix(readPath, ext) + ".mp3"
}
Pattern 6: Mutual Exclusion Validation (D-03)
// In run(), before any capture:
if readPath != "" && ifaceName != "" {
return fmt.Errorf("--read and -i are mutually exclusive; use one or the other")
}
if readPath == "" && ifaceName == "" {
return fmt.Errorf("interface required: use -i <interface> or --read <file>")
}
Anti-Patterns to Avoid
- Calling SetBPFFilter on a pcapgo.Reader: This method only exists on
go-pcap's liveHandle. Will not compile. - Using NoCopy=true with pcapgo: Unlike go-pcap which copies internally (making NoCopy safe), pcapgo's
ZeroCopyReadPacketDatareuses a buffer that is invalidated on the next read. UseReadPacketData(copy-safe) orNoCopy=falseon PacketSource. - Deriving pcap timestamps from wall clock: When
--readis active, usepkt.Metadata().CaptureInfo.Timestamp, nottime.Now(), or windows will all collapse into a single instant. - Omitting filter validation before capture starts: If
--filteris invalid, the error should appear before the "Starting capture..." message. Callhandle.SetBPFFilterbefore logging capture start; or pre-validate usingfilter.NewExpression(expr).Compile().Compile()before opening the handle.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| BPF expression compilation | Custom parser for tcpdump syntax | handle.SetBPFFilter(expr) (go-pcap) |
BPF syntax has 20+ primitives, direction qualifiers, boolean operators; already implemented and tested |
| Pcap file header parsing | Read magic bytes manually | pcapgo.NewReader(r) |
Handles big/little endian, microsecond/nanosecond timestamps, gzip-compressed pcap; error on unknown magic |
| Software BPF matching | Byte comparisons per-packet | bpf.NewVM(instructions) (golang.org/x/net/bpf) |
Runs the same BPF bytecode the kernel uses; already in go.sum as transitive dep |
| BPF expression validation | String parsing | filter.NewExpression(expr).Compile().Compile() |
Validates the full pipeline (parse → AST → BPF instructions) without needing an open socket |
Key insight: The BPF filter path in go-pcap is already a pure-Go parser + compiler; the same compiled instructions can be run in user space via bpf.NewVM without duplicating any filter logic.
Runtime State Inventory
Step 2.5: SKIPPED — this is a new feature addition phase, not a rename/refactor/migration.
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Go toolchain | Build | Yes | go1.24.1 linux/arm64 | — |
| C compiler (CGo for go-lame) | MP3 encoding | Yes (verified in Phase 2) | — | — |
golang.org/x/net/bpf |
Software BPF VM for --read --filter |
Yes (in go.sum as transitive dep) | v0.39.0 | — |
gopacket/pcapgo |
Pcap file reading | Yes (sub-package of gopacket v1.5.0, already in go.mod) | v1.5.0 | — |
| Test pcap files | Integration tests | Not present — must create | — | Use tcpdump -w test.pcap or generate a minimal pcap in test setup |
Missing dependencies with no fallback: None.
Missing dependencies with fallback: None — all needed libraries are already in go.mod/go.sum.
Note on test pcap files: Unit tests for ReadPcapFile and AggregatePcap will need either (a) a small pcap fixture committed to the repo, or (b) a test helper that writes a minimal valid pcap file using pcapgo.NewWriter. Option (b) is preferred (no binary test fixtures in repo).
Common Pitfalls
Pitfall 1: SetBPFFilter Error After Capture Has Started
What goes wrong: If BPF filter validation is deferred until after the "Starting capture on eth0..." message is printed, users see a misleading output before the error appears.
Why it happens: OpenCapture is called, then filter is applied, but the start message was already printed in main.go before calling StartCapture.
How to avoid: In OpenCapture, apply SetBPFFilter before returning the handle, and print the "Starting capture..." message only after the handle is successfully opened and filtered. Or pre-validate the BPF expression string using filter.NewExpression(expr).Compile().Compile() before OpenCapture.
Warning signs: Error message appears after "Starting capture..." log line.
Pitfall 2: BPF VM Not Available at Filter Validation Time
What goes wrong: For --read mode, trying to call handle.SetBPFFilter fails because there is no live handle. Without a check, the filter flag is silently ignored.
Why it happens: The BPF filter API is on *pcap.Handle, which doesn't exist in offline mode.
How to avoid: Pre-validate the BPF expression string once at startup using filter.NewExpression(expr).Compile().Compile() (pure parse + compile, no socket needed). Then for live mode use handle.SetBPFFilter; for pcap mode use bpf.NewVM(instructions) per packet.
Warning signs: --filter flag is provided with --read but no filtering occurs.
Pitfall 3: NoCopy=true With pcapgo
What goes wrong: NoCopy=true on PacketSource combined with pcapgo.Reader.ZeroCopyReadPacketData means packet data is overwritten on the next read. If the classify goroutine lags behind, it reads stale/corrupted data.
Why it happens: go-pcap copies data internally (making NoCopy safe), but pcapgo's zero-copy mode reuses a buffer. The NoCopy = true comment in StartCapture notes "safe: go-pcap copies internally" — this does not apply to pcapgo.
How to avoid: Use NoCopy = false (the default) when creating PacketSource from a pcapgo reader. Use ReadPacketData (not ZeroCopyReadPacketData) internally.
Warning signs: Occasional garbage in decoded packet fields during pcap replay.
Pitfall 4: Empty Pcap File Produces Zero Snapshots
What goes wrong: ReadPcapFile on a pcap with no packets returns an empty packet channel. AggregatePcap returns []WindowSnapshot{}. encode.RunSynthesis receives an empty slice and the existing OUT-03 guard fires: "no packets captured."
Why it happens: The zero-packet guard in RunSynthesis already handles this — but the error message ("no packets captured") is slightly wrong for the pcap case.
How to avoid: Check len(collectedSnapshots) == 0 before calling RunSynthesis in the pcap path and return a more informative error: "pcap file %q contains no packets". Reuse the guard, improve the message.
Warning signs: User runs netsynth --read empty.pcap and gets "no packets captured" without any file path context.
Pitfall 5: Timestamp Monotonicity in Pcap Files
What goes wrong: Some pcap files have non-monotonic timestamps (e.g., pcap files merged from multiple captures). Window assignment via pkt.Timestamp.Sub(firstPacket) produces negative or very large window indices for out-of-order packets.
Why it happens: The pcap format does not guarantee timestamp order.
How to avoid: Track minTimestamp rather than assuming the first packet has the earliest timestamp. Or use max(currentWindowIndex, computedWindowIndex) to clamp backward jumps. For this phase, a simple guard is sufficient: skip packets with timestamps before the first observed timestamp.
Warning signs: Panic on negative slice index, or extremely long MP3 output from merged pcap.
Pitfall 6: go-pcap SetBPFFilter Returns nil for Empty String
What goes wrong: If filter != "" check is omitted, passing an empty --filter "" to SetBPFFilter does nothing (the method returns nil for empty string). This is actually correct behavior, but test cases must confirm empty filter == no filter applied.
Why it happens: SetBPFFilter explicitly returns nil for whitespace-only strings (strings.TrimSpace(expr) == "").
How to avoid: Pass filter through only when it is non-empty. Document the empty-filter-is-no-filter behavior.
Code Examples
Verified patterns from module cache source:
BPF Pre-validation (no socket required)
// Source: go-pcap filter package (filter/expression.go, filter/compile.go)
// Validates the BPF expression string before opening any capture handle.
import "github.com/packetcap/go-pcap/filter"
func validateBPFFilter(expr string) error {
if strings.TrimSpace(expr) == "" {
return nil // empty is valid (means no filter)
}
e := filter.NewExpression(expr)
if e == nil {
return fmt.Errorf("invalid BPF filter expression: %q", expr)
}
compiled := e.Compile()
if _, err := compiled.Compile(); err != nil {
return fmt.Errorf("invalid BPF filter %q: %v", expr, err)
}
return nil
}
Opening pcapgo Reader
// Source: gopacket/pcapgo read.go (NewReader, LinkType)
import (
"os"
"github.com/gopacket/gopacket"
"github.com/gopacket/gopacket/pcapgo"
)
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot open %q: %w", path, err)
}
r, err := pcapgo.NewReader(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("not a valid pcap file %q: %w", path, err)
}
lt := r.LinkType() // layers.LinkType, same type as handle.LinkType() returns
packetSource := gopacket.NewPacketSource(r, lt)
packetSource.NoCopy = false // do NOT set true with pcapgo
Timestamp-Based Window Assignment
// Source: gopacket CaptureInfo struct (gopacket/packet.go)
// pkt.Metadata().CaptureInfo.Timestamp is a time.Time set by pcapgo.
func assignWindow(pktTime, firstTime time.Time, windowMs int) int {
elapsed := pktTime.Sub(firstTime)
if elapsed < 0 {
return 0 // clamp backward jumps
}
return int(elapsed.Milliseconds()) / windowMs
}
Gap-Filling for Silence (D-02)
// Emit a WindowSnapshot for every window index from 0 to maxIdx (inclusive).
// Windows with no packets get TotalPackets=0 and empty Counts map (silent audio).
snapshots := make([]classify.WindowSnapshot, maxWindowIdx+1)
for i := range snapshots {
snapshots[i] = classify.WindowSnapshot{
Counts: make(map[classify.TrafficClass]int64),
WindowIndex: i,
}
}
for _, ev := range buffered {
idx := assignWindow(ev.Timestamp, firstTime, windowMs)
snapshots[idx].Counts[ev.Class]++
snapshots[idx].TotalPackets++
}
Output Filename Derivation (D-05)
// Source: stdlib path/filepath
import "path/filepath"
func deriveOutputPath(readPath string) string {
ext := filepath.Ext(readPath)
base := strings.TrimSuffix(readPath, ext)
return base + ".mp3"
}
// "capture.pcap" -> "capture.mp3"
// "/tmp/net.pcap.gz" -> "/tmp/net.pcap.mp3" (intentional: only strips last ext)
Software BPF Matching via bpf.VM
// Source: golang.org/x/net/bpf (in go.sum as transitive dep via go-pcap)
import (
"golang.org/x/net/bpf"
gpcapfilter "github.com/packetcap/go-pcap/filter"
)
func compileSoftwareBPF(expr string) (*bpf.VM, error) {
e := gpcapfilter.NewExpression(expr)
instructions, err := e.Compile().Compile()
if err != nil {
return nil, err
}
return bpf.NewVM(instructions)
}
// Per packet:
result, err := vm.Run(pkt.Data())
if result > 0 {
// packet passes the filter
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
google/gopacket pcapgo |
gopacket/gopacket pcapgo (community fork) |
2022 (fork), actively maintained | pcapgo API identical; import path differs |
| libpcap for pcap file reading | pcapgo.NewReader (pure Go) |
Available in gopacket since v1.0 | No system library needed; works without libpcap installed |
No deprecations affecting this phase.
Open Questions
-
Software BPF for
--read --filter: bpf.VM vs skip filtering- What we know:
go-pcap'sSetBPFFilteris hardware-only; for pcap files we need software matching. - What's unclear: Whether
bpf.VMfromgolang.org/x/net/bpfcorrectly evaluates the same instructions thatSetBPFFilterwould compile for all filter expressions (hostname resolution differs between kernel and userspace). - Recommendation: Test with
port 53,tcp, andhost 10.0.0.1filter expressions against a known pcap fixture. If hostname resolution infilter.NewExpressionproves unreliable for user-space matching, document the limitation clearly in the error message.
- What we know:
-
ClassifiedPacket Timestamp field
- What we know:
aggregate.Aggregateuses a<-chan classify.ClassifiedPacketwith no timestamp. D-01 requires timestamp-based windowing for pcap mode. - What's unclear: Whether to add
Timestamp time.TimetoClassifiedPacket(shared struct change) or handle timestamps externally. - Recommendation (Claude's Discretion): Add
Timestamp time.TimetoClassifiedPacket. It is zero-valued in live mode (no behaviour change there) and populated frompkt.Metadata().CaptureInfo.Timestampin the classify goroutine for pcap mode. This is the least invasive change.
- What we know:
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | go test (stdlib) |
| Config file | none |
| Quick run command | go test ./capture/... ./aggregate/... ./cmd/netsynth/... |
| Full suite command | go test ./... |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CAPT-05 | --filter "port 53" with live capture applies BPF to handle |
unit | go test ./capture/... -run TestOpenCaptureWithFilter |
No — Wave 0 |
| CAPT-05 | Invalid BPF filter returns error before capture starts | unit | go test ./capture/... -run TestBPFFilterValidation |
No — Wave 0 |
| CAPT-05 | BPF pre-validation function rejects bad expressions | unit | go test ./capture/... -run TestValidateBPFFilter |
No — Wave 0 |
| CAPT-06 | ReadPcapFile returns packet channel from valid pcap |
unit | go test ./capture/... -run TestReadPcapFile |
No — Wave 0 |
| CAPT-06 | ReadPcapFile returns error on missing/corrupt file |
unit | go test ./capture/... -run TestReadPcapFileErrors |
No — Wave 0 |
| CAPT-06 | AggregatePcap assigns packets to correct windows |
unit | go test ./aggregate/... -run TestAggregatePcap |
No — Wave 0 |
| CAPT-06 | AggregatePcap fills gap windows with empty snapshots (D-02) |
unit | go test ./aggregate/... -run TestAggregatePcapGaps |
No — Wave 0 |
| CAPT-06 | --read and -i mutually exclusive |
unit | go test ./cmd/netsynth/... -run TestFlagMutualExclusion |
No — Wave 0 |
| CAPT-05+06 | --filter with --read applies software BPF to pcap packets |
unit | go test ./capture/... -run TestSoftwareBPFFilter |
No — Wave 0 |
| D-05 | Output filename derived from input when -o absent |
unit | go test ./cmd/netsynth/... -run TestDeriveOutputPath |
No — Wave 0 |
Sampling Rate
- Per task commit:
go test ./capture/... ./aggregate/... - Per wave merge:
go test ./... - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
capture/capture_test.go— addTestOpenCaptureWithFilter,TestBPFFilterValidation,TestValidateBPFFilter,TestReadPcapFile,TestReadPcapFileErrors,TestSoftwareBPFFiltercapture/testdata/— small pcap fixture forTestReadPcapFile(or a test helper that generates one usingpcapgo.NewWriter)aggregate/window_test.go— addTestAggregatePcap,TestAggregatePcapGapscmd/netsynth/main_test.go— addTestFlagMutualExclusion,TestDeriveOutputPath
Existing test infrastructure: go test ./... is already green (verified). All Wave 0 gaps are test additions, not framework setup.
Project Constraints (from CLAUDE.md)
These directives apply to all phase work:
| Directive | Applies To |
|---|---|
| Language: Go only | All new files |
| Single binary output | No new system library dependencies |
| MP3 output format | No change — RunSynthesis unchanged |
| Non-interactive model: run → Ctrl+C → file saved | --read mode: run → completes automatically → file saved |
Use gopacket/gopacket (community fork, NOT google/gopacket) |
import path in pcapgo: github.com/gopacket/gopacket/pcapgo |
Use packetcap/go-pcap for live capture |
unchanged for live path; pcapgo for file path |
Use sjzar/go-lame for MP3 |
unchanged |
Use spf13/cobra for CLI |
--filter and --read flags added via cobra |
go-audio/generator is archived — do not use |
not relevant to this phase |
Do not use faiface/beep, dasa.cc/snd, or viert/go-lame |
not relevant to this phase |
| GSD workflow: use Write/Edit only within a GSD command | enforced by workflow |
Sources
Primary (HIGH confidence)
- Module cache:
/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/pcap.go—SetBPFFiltersignature and implementation verified - Module cache:
/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/filter/expression.go—NewExpression,Compileverified - Module cache:
/home/dev/go/pkg/mod/github.com/packetcap/go-pcap@v0.0.0-20251215121130-f2cf9f991e7c/filter/compile_cases_test.go— error strings for invalid BPF expressions verified ("parse error","unknown host: ...") - Module cache:
/home/dev/go/pkg/mod/github.com/gopacket/gopacket@v1.5.0/pcapgo/read.go—NewReader,ReadPacketData,LinkType,CaptureInfo.Timestampverified - Project source:
capture/capture.go,aggregate/window.go,cmd/netsynth/main.go,classify/types.go— integration points verified by reading existing code go test ./...— full test suite passes (verified live on machine)
Secondary (MEDIUM confidence)
golang.org/x/net/bpfVM interface for software BPF matching — package is in go.sum (transitive), API is stable stdlib-adjacent,bpf.NewVM(instructions)andvm.Run(data)are well-documented
Tertiary (LOW confidence)
- None — all claims verified from module cache source directly
Metadata
Confidence breakdown:
- Standard stack: HIGH — verified from go.mod and module cache source; no new dependencies required
- Architecture: HIGH — API signatures verified from module cache source, not from documentation or training data
- Pitfalls: HIGH — derived from direct source reading (NoCopy comment in pcap_linux.go, error string tests in compile_cases_test.go)
- BPF VM for software filtering: MEDIUM — API is in go.sum, standard package, but
vm.Runreturn semantics not verified by reading source; test against real pcap to confirm
Research date: 2026-03-26 Valid until: 2026-09-26 (stable stdlib-adjacent packages; go-pcap minor version could change API but no indication of breaking changes planned)