From a3f5aba583858de13ab6ca2a8d83edb2c32d6eb3 Mon Sep 17 00:00:00 2001 From: Jayme Klein Date: Thu, 6 Aug 2026 14:14:05 -0300 Subject: [PATCH 1/2] feat(tui): add hotkeys to edit the filter and toggle recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `f` / Ctrl-F opens a one-line BPF editor over the packet list, pre-filled with the filter in force and completing the BPF vocabulary against the word under the caret. Ctrl-F applies and restarts the capture from a clean slate: stats, packet list, throughput history and the stop-condition budgets all reset. Expressions are compiled before they are accepted, so a typo is reported inline and the editor keeps your text instead of tearing the capture down to find out. `r` toggles whether packets reach the pcap. The capture keeps running and the table keeps filling — only the writing stops, so `r` gates the file the way `p` freezes the display. Stopping flushes, leaving a complete pcap on disk; starting again appends rather than reopening, which would truncate. A run that never records leaves the output file untouched, and the summary reports `recorded` alongside `packets` whenever they differ. Supporting changes: the terminal is set up once for the session so a filter restart doesn't flash the screen, and a single Ctrl-C handler now holds a session-long stop flag shared by each capture thread. --- README.md | 55 +++++ src/capture.rs | 236 +++++++++++++++++- src/main.rs | 140 +++++++---- src/ui/filter.rs | 604 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 2 + src/ui/printer.rs | 18 +- src/ui/theme.rs | 14 ++ src/ui/tui.rs | 286 +++++++++++++++++----- 8 files changed, 1224 insertions(+), 131 deletions(-) create mode 100644 src/ui/filter.rs create mode 100644 src/ui/theme.rs diff --git a/README.md b/README.md index b1e51df..f938f7d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ The TUI is selected automatically when stdout is a terminal (unless `--no-tui` o | Key | Action | |---|---| | `q` / Esc | quit and flush the pcap | +| `r` | start / stop recording to the pcap (see below) | +| `f` / `Ctrl-F` | edit the capture filter (see below) | | `↑` `↓` / `k` `j` | move the cursor through the packet list | | `PgUp` / `PgDn` | move a page at a time | | `g` / `G` | jump to the newest / oldest buffered packet | @@ -157,6 +159,59 @@ pins the cursor to that packet as new ones arrive; `g` resumes following the liv The last 2000 packets stay scrollable. Panes drop out on narrow terminals: the sidebar below 104 columns, the detail pane below 24 rows. +### Recording + +`r` toggles whether packets reach the `.pcap`. Recording starts **on**, and a badge in +the header always says which way it is: `● REC` or `○ NOT RECORDING`. + +Stopping recording does not stop the capture — packets keep arriving, the table keeps +filling and the stats keep counting; they simply aren't written. That's the difference +between `r` and `p`: `p` freezes the *display*, `r` gates the *file*. + +- Stopping flushes the pcap, so what's on disk is complete and openable in Wireshark + while you're still watching the live traffic. +- Starting again appends to the same file, so one run can hold several recorded + stretches with the quiet parts left out. +- `--max-file-size` only counts recorded bytes, so a run that's mostly not recording + won't trip it. +- If you never record — say you turned it off and then changed the filter — the output + file is not created or truncated at all, and the closing summary says so. + +The final summary reports `recorded` alongside `packets` whenever the two differ. + +### Changing the filter mid-run + +`f` (or `Ctrl-F`) opens a one-line BPF editor over the packet list, pre-filled with the +filter currently in force. `Ctrl-F` again applies it — so the whole edit is one key, +type, same key. + +| Key | Action | +|---|---| +| `Ctrl-F` / Enter | apply the filter and restart the capture | +| Esc | close the editor, leaving the running capture alone | +| Tab | insert the highlighted completion | +| `↑` `↓` / `Ctrl-P` `Ctrl-N` | move through the completion list | +| `←` `→`, Home / End, `Ctrl-A` / `Ctrl-E` | move the caret | +| `Ctrl-W` / `Ctrl-U` | delete the previous word / the whole line | + +Completion matches the word under the caret against the BPF vocabulary — `tcp`, `udp`, +`host`, `src port`, `ip proto`, `portrange`, `less`, `greater`, and the rest — each with +a one-line reminder of its syntax. Parentheses start a fresh word, so completion keeps +working inside `(...)` groups. + +The filter is compiled before it is accepted: a typo is reported inline (`✗ syntax +error`) and the editor stays open on your text. An empty expression means *no filter* — +capture everything. + +Applying restarts the capture from scratch: **stats, the packet list, the throughput +history, and the `--count` / `--duration` / `--max-file-size` budgets are all reset**, +and the output pcap is overwritten as soon as the new run records a packet. Use `-o` +with a fresh path per run if you need to keep an earlier capture — or press `r` before +applying, which leaves the file alone until you record again. + +Recording state is the one thing that carries across a restart: if you stopped recording +before applying, it stays stopped. + ### Stop conditions Capture ends on whichever fires first: `--count`, `--duration`, `--max-file-size`, `Ctrl-C`, diff --git a/src/capture.rs b/src/capture.rs index 238af4a..4af29f5 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{bail, Context, Result}; use crossbeam_channel::{bounded, Sender, TrySendError}; -use pcap::{Capture, Device}; +use pcap::{Capture, Device, Linktype}; use crate::parse::parse; use crate::types::{PacketMeta, StopCondition}; @@ -24,6 +24,7 @@ pub struct CaptureHandle { pub thread: JoinHandle>, pub rx: crossbeam_channel::Receiver, pub stop_flag: Arc, + pub recording: Arc, pub ui_dropped: Arc, } @@ -31,10 +32,82 @@ pub struct CaptureHandle { pub struct CaptureSummary { pub packets: u64, pub bytes: u64, + /// Packets actually written to the pcap — below `packets` if recording was off + /// for part of the run. + pub recorded: u64, + /// False when recording never ran, so the output file was never touched. + pub wrote_file: bool, pub kernel_dropped: u32, pub if_dropped: u32, } +/// What a `Recorder` writes into. Only flushing is behind the trait — writing goes +/// straight to the concrete sink handed back by [`Recorder::sink`]. +trait RecordSink { + fn flush(&mut self) -> Result<()>; +} + +impl RecordSink for pcap::Savefile { + fn flush(&mut self) -> Result<()> { + pcap::Savefile::flush(self).context("flushing pcap") + } +} + +/// Tracks the record toggle for one capture run: opens the output on the first packet +/// actually recorded, and flushes whenever recording stops so the file on disk is +/// always a complete pcap. +struct Recorder { + sink: Option, + on: bool, +} + +impl Recorder { + fn new() -> Self { + Self { + sink: None, + on: false, + } + } + + /// Follow the shared flag. `open` runs at most once, and only if recording is on — + /// so a run that never records leaves the output file untouched. + fn sync(&mut self, on: bool, open: impl FnOnce() -> Result) -> Result<()> { + if on && self.sink.is_none() { + self.sink = Some(open()?); + } + if self.on && !on { + if let Some(s) = self.sink.as_mut() { + if let Err(e) = s.flush() { + eprintln!("rust-wire: {e:#}"); + } + } + } + self.on = on; + Ok(()) + } + + /// The sink to write this packet to, or `None` while recording is stopped. + fn sink(&mut self) -> Option<&mut S> { + if self.on { + self.sink.as_mut() + } else { + None + } + } + + /// Final flush. Reports whether the output file was ever opened. + fn finish(&mut self) -> bool { + if let Some(s) = self.sink.as_mut() { + if let Err(e) = s.flush() { + eprintln!("rust-wire: {e:#}"); + } + true + } else { + false + } + } +} + pub fn list_interfaces() -> Result> { Device::list().context("listing capture devices") } @@ -54,23 +127,43 @@ pub fn default_interface() -> Result { Ok(pick) } -pub fn spawn(cfg: CaptureConfig) -> Result { +/// Check a BPF expression without touching a live interface, so the TUI can reject +/// a bad filter inline instead of tearing the capture down to find out. +/// +/// Compiled against Ethernet: it catches syntax and unknown-primitive errors, which +/// is what a typo produces. A filter that is only invalid for an exotic link type +/// still fails later, on the real handle. +pub fn validate_filter(bpf: &str) -> Result<()> { + let dead = Capture::dead(Linktype::ETHERNET).context("opening a dead capture handle")?; + dead.compile(bpf, true)?; + Ok(()) +} + +/// `stop_flag` and `recording` are owned by the caller: the Ctrl-C handler can only be +/// installed once, and both flags outlive the individual capture threads that a filter +/// restart spawns. +pub fn spawn( + cfg: CaptureConfig, + stop_flag: Arc, + recording: Arc, +) -> Result { let (tx, rx) = bounded::(4096); - let stop_flag = Arc::new(AtomicBool::new(false)); let ui_dropped = Arc::new(AtomicU64::new(0)); let stop_flag_t = Arc::clone(&stop_flag); + let recording_t = Arc::clone(&recording); let ui_dropped_t = Arc::clone(&ui_dropped); let thread = thread::Builder::new() .name("rust-wire-capture".into()) - .spawn(move || run(cfg, tx, stop_flag_t, ui_dropped_t)) + .spawn(move || run(cfg, tx, stop_flag_t, recording_t, ui_dropped_t)) .context("spawning capture thread")?; Ok(CaptureHandle { thread, rx, stop_flag, + recording, ui_dropped, }) } @@ -79,6 +172,7 @@ fn run( cfg: CaptureConfig, tx: Sender, stop_flag: Arc, + recording: Arc, ui_dropped: Arc, ) -> Result { let device = Device::from(cfg.interface.as_str()); @@ -98,13 +192,17 @@ fn run( .with_context(|| format!("compiling BPF filter `{bpf}`"))?; } - let mut save = cap - .savefile(&cfg.output) - .with_context(|| format!("opening output `{}`", cfg.output.display()))?; + // Synced up front so the usual case — recording on from the start — surfaces a + // permission problem here rather than on the first packet. + let mut rec = Recorder::new(); + rec.sync(recording.load(Ordering::Relaxed), || { + open_savefile(&cap, &cfg.output) + })?; let start = Instant::now(); let mut packets: u64 = 0; let mut bytes: u64 = 0; + let mut recorded: u64 = 0; // Approximate bytes written to disk: 24-byte pcap global header + per-packet 16-byte record // header + caplen bytes of packet data. Close enough for a size-based stop. const PCAP_GLOBAL_HDR: u64 = 24; @@ -115,6 +213,14 @@ fn run( if stop_flag.load(Ordering::Relaxed) { break; } + + // Recording is toggled from the UI thread. Sync here, at the top of the loop, + // rather than on packet arrival: stopping should land on disk within a tick even + // if the link then goes quiet. + rec.sync(recording.load(Ordering::Relaxed), || { + open_savefile(&cap, &cfg.output) + })?; + if let Some(max) = cfg.stop.max_packets { if packets >= max { break; @@ -133,11 +239,15 @@ fn run( match cap.next_packet() { Ok(pkt) => { - // Write to pcap first — capture path must not be blocked by UI. - save.write(&pkt); + // Write to pcap first — capture path must not be blocked by UI. While + // recording is off the packet is still parsed and shown, just not stored. + if let Some(sf) = rec.sink() { + sf.write(&pkt); + recorded += 1; + file_bytes += PCAP_RECORD_HDR + pkt.header.caplen as u64; + } packets += 1; bytes += pkt.header.len as u64; - file_bytes += PCAP_RECORD_HDR + pkt.header.caplen as u64; let ts = timeval_to_systemtime(pkt.header.ts.tv_sec, pkt.header.ts.tv_usec); let meta = parse(pkt.data, ts, pkt.header.len, pkt.header.caplen); @@ -159,9 +269,7 @@ fn run( } } - if let Err(e) = save.flush() { - eprintln!("rust-wire: pcap flush error: {e}"); - } + let wrote_file = rec.finish(); let (kernel_dropped, if_dropped) = match cap.stats() { Ok(s) => (s.dropped, s.if_dropped), @@ -171,11 +279,18 @@ fn run( Ok(CaptureSummary { packets, bytes, + recorded, + wrote_file, kernel_dropped, if_dropped, }) } +fn open_savefile(cap: &Capture, output: &std::path::Path) -> Result { + cap.savefile(output) + .with_context(|| format!("opening output `{}`", output.display())) +} + #[cfg(target_os = "linux")] fn timeval_to_systemtime(sec: i64, usec: i64) -> SystemTime { let dur = Duration::new(sec.max(0) as u64, (usec.max(0) as u32) * 1_000); @@ -189,3 +304,98 @@ fn timeval_to_systemtime(sec: impl Into, usec: impl Into) -> SystemTim let dur = Duration::new(sec.max(0) as u64, (usec.max(0) as u32) * 1_000); UNIX_EPOCH + dur } + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::rc::Rc; + + #[derive(Default)] + struct Fake { + flushes: Rc>, + } + + impl RecordSink for Fake { + fn flush(&mut self) -> Result<()> { + self.flushes.set(self.flushes.get() + 1); + Ok(()) + } + } + + /// Opens counted separately from flushes: reopening a savefile truncates it, so the + /// count going above 1 would mean silently discarding an earlier recording. + fn recorder() -> (Recorder, Rc>, Rc>) { + let (opens, flushes) = (Rc::new(Cell::new(0)), Rc::new(Cell::new(0))); + (Recorder::new(), opens, flushes) + } + + #[test] + fn nothing_is_opened_while_recording_is_off() { + let (mut r, opens, _f) = recorder(); + for _ in 0..3 { + r.sync(false, || { + opens.set(opens.get() + 1); + Ok(Fake::default()) + }) + .unwrap(); + assert!(r.sink().is_none(), "must not hand out a sink when off"); + } + assert_eq!(opens.get(), 0, "output file must stay untouched"); + assert!(!r.finish(), "no file was written"); + } + + #[test] + fn the_output_opens_once_and_survives_a_stop_start() { + let (mut r, opens, flushes) = recorder(); + let open = |o: &Rc>, f: &Rc>| { + o.set(o.get() + 1); + Ok(Fake { flushes: f.clone() }) + }; + + r.sync(true, || open(&opens, &flushes)).unwrap(); + assert!(r.sink().is_some()); + r.sync(false, || open(&opens, &flushes)).unwrap(); + assert!(r.sink().is_none(), "writes must stop with recording"); + assert_eq!(flushes.get(), 1, "stopping should flush what's on disk"); + + r.sync(true, || open(&opens, &flushes)).unwrap(); + assert!(r.sink().is_some(), "recording again resumes writing"); + assert_eq!(opens.get(), 1, "resuming must append, not reopen/truncate"); + + assert!(r.finish()); + assert_eq!(flushes.get(), 2, "one flush per stop, plus the final one"); + } + + #[test] + fn a_steady_state_does_not_flush_every_tick() { + let (mut r, opens, flushes) = recorder(); + r.sync(true, || { + opens.set(opens.get() + 1); + Ok(Fake { + flushes: flushes.clone(), + }) + }) + .unwrap(); + for _ in 0..5 { + r.sync(false, || panic!("must not reopen")).unwrap(); + } + assert_eq!(flushes.get(), 1, "only the transition flushes"); + } + + #[test] + fn a_late_first_recording_still_opens_the_file() { + let (mut r, opens, flushes) = recorder(); + r.sync(false, || panic!("must not open while off")).unwrap(); + r.sync(true, || { + opens.set(opens.get() + 1); + Ok(Fake { + flushes: flushes.clone(), + }) + }) + .unwrap(); + assert_eq!(opens.get(), 1); + assert!(r.sink().is_some()); + assert!(r.finish()); + } +} diff --git a/src/main.rs b/src/main.rs index 7774956..61afb18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,8 @@ mod types; mod ui; use std::process::ExitCode; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use anyhow::{Context, Result}; use clap::Parser; @@ -17,7 +18,7 @@ use crate::types::{StopCondition, UiMode}; use crate::ui::{ pick_mode, printer::Printer, - tui::{Tui, TuiConfig}, + tui::{TerminalGuard, Tui, TuiConfig, TuiOutcome}, }; fn main() -> ExitCode { @@ -55,62 +56,95 @@ fn run() -> Result<()> { let mode = pick_mode(args.no_tui, args.quiet); - let cfg = CaptureConfig { - interface: iface_name.clone(), - snaplen: args.snaplen, - promisc: args.promisc, - filter: args.filter.clone(), - output: args.output.clone(), - stop, + // One stop flag for the whole session: the Ctrl-C handler can only be installed + // once, but a filter change spawns a fresh capture thread on the far side of it. + let stop_flag = Arc::new(AtomicBool::new(false)); + let interrupted = Arc::new(AtomicBool::new(false)); + // Recording is on by default and survives a filter restart, so `r` off then a new + // filter doesn't quietly start writing again. + let recording = Arc::new(AtomicBool::new(true)); + { + let (stop, seen) = (stop_flag.clone(), interrupted.clone()); + ctrlc::set_handler(move || { + seen.store(true, Ordering::Relaxed); + stop.store(true, Ordering::Relaxed); + }) + .context("installing ctrl-c handler")?; + } + + // The alternate screen is set up once and survives filter restarts. + let mut term = match mode { + UiMode::Tui => Some(TerminalGuard::new().context("setting up terminal")?), + UiMode::Cli | UiMode::Quiet => None, }; - let handle = spawn(cfg).context("starting capture")?; - - // Ctrl-C -> flip the stop flag; capture thread notices and flushes. - let stop_for_ctrlc = handle.stop_flag.clone(); - ctrlc::set_handler(move || { - stop_for_ctrlc.store(true, Ordering::Relaxed); - }) - .context("installing ctrl-c handler")?; - - // Run the chosen UI. It returns when the capture thread stops or the user quits. - match mode { - UiMode::Tui => { - let tui = Tui { - cfg: TuiConfig { - interface: iface_name.clone(), - filter: args.filter.clone(), - output: args.output.clone(), + let mut filter = args.filter.clone(); + loop { + stop_flag.store(false, Ordering::Relaxed); + let cfg = CaptureConfig { + interface: iface_name.clone(), + snaplen: args.snaplen, + promisc: args.promisc, + filter: filter.clone(), + output: args.output.clone(), + stop, + }; + let handle = + spawn(cfg, stop_flag.clone(), recording.clone()).context("starting capture")?; + + // Run the chosen UI. It returns when the capture thread stops, the user quits, + // or the user applies a new filter. + let outcome = match term.as_mut() { + Some(guard) => { + let tui = Tui { + cfg: TuiConfig { + interface: iface_name.clone(), + filter: filter.clone(), + output: args.output.clone(), + max_talkers: args.max_talkers, + }, + rx: handle.rx.clone(), + stop_flag: handle.stop_flag.clone(), + recording: handle.recording.clone(), + ui_dropped: handle.ui_dropped.clone(), + }; + tui.run(guard.terminal()).context("running TUI")? + } + None => { + let printer = Printer { + rx: handle.rx.clone(), + stop_flag: handle.stop_flag.clone(), + quiet: matches!(mode, UiMode::Quiet), max_talkers: args.max_talkers, - }, - rx: handle.rx.clone(), - stop_flag: handle.stop_flag.clone(), - ui_dropped: handle.ui_dropped.clone(), - }; - let _stats = tui.run().context("running TUI")?; - } - UiMode::Cli | UiMode::Quiet => { - let printer = Printer { - rx: handle.rx.clone(), - stop_flag: handle.stop_flag.clone(), - quiet: matches!(mode, UiMode::Quiet), - max_talkers: args.max_talkers, - }; - let _stats = printer.run(); + }; + let _stats = printer.run(); + TuiOutcome::Quit + } + }; + + // Signal capture thread to stop (in case UI exited first) and join it. The join + // matters on restart too: it flushes and closes the pcap before the next thread + // reopens the same path. + handle.stop_flag.store(true, Ordering::Relaxed); + let summary: CaptureSummary = handle + .thread + .join() + .map_err(|_| anyhow::anyhow!("capture thread panicked"))??; + let ui_dropped = handle.ui_dropped.load(Ordering::Relaxed); + + match outcome { + // A Ctrl-C that landed during the restart still means quit. + TuiOutcome::Restart { filter: new } if !interrupted.load(Ordering::Relaxed) => { + filter = new; + } + _ => { + // Leave the alternate screen before writing the summary to the real one. + drop(term.take()); + crate::ui::printer::print_final_summary(&args.output, &summary, ui_dropped); + return Ok(()); + } } } - - // Signal capture thread to stop (in case UI exited first) and join it. - handle.stop_flag.store(true, Ordering::Relaxed); - let summary: CaptureSummary = handle - .thread - .join() - .map_err(|_| anyhow::anyhow!("capture thread panicked"))??; - - let ui_dropped = handle.ui_dropped.load(Ordering::Relaxed); - crate::ui::printer::print_final_summary(&args.output, &summary, ui_dropped); - - Ok(()) } fn list_interfaces() -> Result<()> { diff --git a/src/ui/filter.rs b/src/ui/filter.rs new file mode 100644 index 0000000..05f2fce --- /dev/null +++ b/src/ui/filter.rs @@ -0,0 +1,604 @@ +//! The live BPF filter editor: a one-line input with completion over the BPF +//! primitives, opened from the TUI with `f` / Ctrl-F and applied with Ctrl-F. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Clear, Padding, Paragraph}, +}; + +use super::theme; + +/// One completion entry: the text inserted, plus a one-line reminder of what it means. +pub struct Candidate { + pub word: &'static str, + pub help: &'static str, +} + +/// The BPF vocabulary worth completing. Multi-word entries are the pairings that +/// are awkward to remember (`ether proto`, `ip proto`) or simply common enough +/// (`tcp port`) that spelling them out saves a keystroke run. +const CANDIDATES: &[Candidate] = &[ + Candidate { + word: "and", + help: "both sides must match", + }, + Candidate { + word: "arp", + help: "ARP frames", + }, + Candidate { + word: "broadcast", + help: "broadcast frames only", + }, + Candidate { + word: "dst", + help: "destination qualifier", + }, + Candidate { + word: "dst host", + help: "destination address — dst host 1.1.1.1", + }, + Candidate { + word: "dst net", + help: "destination network — dst net 10.0.0.0/8", + }, + Candidate { + word: "dst port", + help: "destination port — dst port 443", + }, + Candidate { + word: "ether dst", + help: "destination MAC", + }, + Candidate { + word: "ether host", + help: "either MAC — ether host 00:15:5d:3a:1f:02", + }, + Candidate { + word: "ether proto", + help: "EtherType — ether proto 0x0806", + }, + Candidate { + word: "ether src", + help: "source MAC", + }, + Candidate { + word: "gateway", + help: "forwarded by a host, not addressed to it", + }, + Candidate { + word: "greater", + help: "frames at least N bytes — greater 1000", + }, + Candidate { + word: "host", + help: "either address — host 10.0.0.1", + }, + Candidate { + word: "icmp", + help: "ICMPv4", + }, + Candidate { + word: "icmp6", + help: "ICMPv6", + }, + Candidate { + word: "ip", + help: "IPv4 traffic", + }, + Candidate { + word: "ip proto", + help: "IP protocol — ip proto 47", + }, + Candidate { + word: "ip6", + help: "IPv6 traffic", + }, + Candidate { + word: "len", + help: "frame length — len > 1400", + }, + Candidate { + word: "less", + help: "frames at most N bytes — less 128", + }, + Candidate { + word: "mpls", + help: "MPLS-labelled packets", + }, + Candidate { + word: "multicast", + help: "multicast frames only", + }, + Candidate { + word: "net", + help: "network — net 192.168.0.0/16", + }, + Candidate { + word: "not", + help: "negate what follows", + }, + Candidate { + word: "or", + help: "either side may match", + }, + Candidate { + word: "port", + help: "either port — port 443", + }, + Candidate { + word: "portrange", + help: "port range — portrange 8000-8080", + }, + Candidate { + word: "rarp", + help: "RARP frames", + }, + Candidate { + word: "src", + help: "source qualifier", + }, + Candidate { + word: "src host", + help: "source address — src host 10.0.0.1", + }, + Candidate { + word: "src net", + help: "source network — src net 10.0.0.0/8", + }, + Candidate { + word: "src port", + help: "source port — src port 53", + }, + Candidate { + word: "tcp", + help: "TCP traffic", + }, + Candidate { + word: "tcp port", + help: "TCP port — tcp port 80", + }, + Candidate { + word: "tcp portrange", + help: "TCP port range — tcp portrange 8000-8080", + }, + Candidate { + word: "udp", + help: "UDP traffic", + }, + Candidate { + word: "udp port", + help: "UDP port — udp port 53", + }, + Candidate { + word: "vlan", + help: "802.1Q-tagged frames", + }, +]; + +/// Rows of the completion list shown at once. +const VISIBLE: usize = 6; + +/// What the TUI should do after feeding a key to the editor. +pub enum EditAction { + /// Stay in the editor. + Continue, + /// Close the editor, leaving the running capture alone. + Cancel, + /// Restart the capture under this filter (`None` = capture everything). + Apply(Option), +} + +pub struct FilterEditor { + input: String, + /// Byte offset into `input`; always on a char boundary. + cursor: usize, + /// Indices into `CANDIDATES` matching the word under the cursor. + matches: Vec, + sel: usize, + /// Rejection message from the last apply attempt, cleared on the next edit. + error: Option, +} + +impl FilterEditor { + /// Open the editor pre-filled with the filter currently in force. + pub fn new(current: Option<&str>) -> Self { + let input = current.unwrap_or("").to_string(); + let mut ed = Self { + cursor: input.len(), + input, + matches: Vec::new(), + sel: 0, + error: None, + }; + ed.refresh_matches(); + ed + } + + /// The filter as it would be applied: trimmed, with empty meaning "no filter". + pub fn value(&self) -> Option { + let t = self.input.trim(); + (!t.is_empty()).then(|| t.to_string()) + } + + /// Show why the filter was refused and keep the editor open on it. + pub fn set_error(&mut self, msg: impl Into) { + self.error = Some(msg.into()); + } + + pub fn on_key(&mut self, k: KeyEvent) -> EditAction { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + match k.code { + KeyCode::Esc => return EditAction::Cancel, + // Ctrl-F both opens and applies, so the whole edit is one key round-trip. + KeyCode::Enter => return EditAction::Apply(self.value()), + KeyCode::Char('f') if ctrl => return EditAction::Apply(self.value()), + + KeyCode::Tab => self.accept_suggestion(), + KeyCode::BackTab => self.move_sel(-1), + KeyCode::Down => self.move_sel(1), + KeyCode::Up => self.move_sel(-1), + KeyCode::Char('n') if ctrl => self.move_sel(1), + KeyCode::Char('p') if ctrl => self.move_sel(-1), + + KeyCode::Left => self.move_cursor(-1), + KeyCode::Right => self.move_cursor(1), + KeyCode::Home => self.set_cursor(0), + KeyCode::End => self.set_cursor(self.input.len()), + KeyCode::Char('a') if ctrl => self.set_cursor(0), + KeyCode::Char('e') if ctrl => self.set_cursor(self.input.len()), + + KeyCode::Backspace => { + if let Some((i, _)) = prev_char(&self.input, self.cursor) { + self.input.replace_range(i..self.cursor, ""); + self.cursor = i; + self.after_edit(); + } + } + KeyCode::Delete => { + if let Some(c) = self.input[self.cursor..].chars().next() { + let end = self.cursor + c.len_utf8(); + self.input.replace_range(self.cursor..end, ""); + self.after_edit(); + } + } + KeyCode::Char('u') if ctrl => { + self.input.clear(); + self.cursor = 0; + self.after_edit(); + } + KeyCode::Char('w') if ctrl => { + let start = word_start(&self.input, self.cursor); + self.input.replace_range(start..self.cursor, ""); + self.cursor = start; + self.after_edit(); + } + KeyCode::Char(c) if !ctrl && !k.modifiers.contains(KeyModifiers::ALT) => { + self.input.insert(self.cursor, c); + self.cursor += c.len_utf8(); + self.after_edit(); + } + _ => {} + } + EditAction::Continue + } + + fn after_edit(&mut self) { + self.error = None; + self.refresh_matches(); + } + + /// Completion follows the caret, so any move re-matches on the word it lands in. + fn set_cursor(&mut self, pos: usize) { + self.cursor = pos; + self.refresh_matches(); + } + + fn move_cursor(&mut self, delta: isize) { + let pos = if delta < 0 { + prev_char(&self.input, self.cursor).map(|(i, _)| i) + } else { + self.input[self.cursor..] + .chars() + .next() + .map(|c| self.cursor + c.len_utf8()) + }; + if let Some(p) = pos { + self.set_cursor(p); + } + } + + fn move_sel(&mut self, delta: isize) { + if self.matches.is_empty() { + return; + } + let n = self.matches.len() as isize; + self.sel = (((self.sel as isize + delta) % n + n) % n) as usize; + } + + /// The word the cursor sits in — what completion matches against. + fn current_word(&self) -> &str { + &self.input[word_start(&self.input, self.cursor)..self.cursor] + } + + fn refresh_matches(&mut self) { + let word = self.current_word().to_ascii_lowercase(); + self.matches = CANDIDATES + .iter() + .enumerate() + .filter(|(_, c)| c.word.starts_with(&word)) + .map(|(i, _)| i) + .collect(); + self.sel = 0; + } + + fn accept_suggestion(&mut self) { + let Some(&idx) = self.matches.get(self.sel) else { + return; + }; + let start = word_start(&self.input, self.cursor); + let insert = format!("{} ", CANDIDATES[idx].word); + self.input.replace_range(start..self.cursor, &insert); + self.cursor = start + insert.len(); + self.after_edit(); + } + + /// Rows total (input, notice, completions, error) plus the border. + pub fn height(&self) -> u16 { + let list = self.matches.len().min(VISIBLE) as u16; + let err = u16::from(self.error.is_some()); + // 2 border + 1 input + 1 notice + (1 rule + list) + err + 4 + if list > 0 { list + 1 } else { 0 } + err + } +} + +// ── rendering ─────────────────────────────────────────────────────────────── + +/// Draw the editor over `area`, returning the on-screen cursor position. +pub fn draw(f: &mut ratatui::Frame, area: Rect, ed: &FilterEditor) -> (u16, u16) { + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme::RUST_BRIGHT)) + .padding(Padding::horizontal(1)) + .title(Span::styled( + " capture filter ", + Style::default() + .fg(theme::EMBER) + .add_modifier(Modifier::BOLD), + )) + .title_bottom(Line::from(Span::styled( + " Tab complete · Ctrl-F apply & restart · Esc cancel ", + Style::default().fg(theme::DIM), + ))); + let inner = block.inner(area); + f.render_widget(Clear, area); + f.render_widget(block, area); + + let width = inner.width.max(1) as usize; + // Keep the caret on screen once the expression outruns the box. + let caret = ed.input[..ed.cursor].chars().count(); + let prompt = 2usize; // "> " + let avail = width.saturating_sub(prompt + 1); + let scroll = caret.saturating_sub(avail); + let shown: String = ed.input.chars().skip(scroll).take(avail + 1).collect(); + + let mut lines = vec![Line::from(vec![ + Span::styled("> ", Style::default().fg(theme::RUST_BRIGHT)), + Span::styled( + shown, + Style::default() + .fg(theme::COPPER) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + if ed.input.is_empty() { + "capture everything" + } else { + "" + }, + Style::default().fg(theme::DIM), + ), + ])]; + + // Restarting throws away the current capture, so say so where it is being decided. + lines.push(Line::from(Span::styled( + "↻ apply restarts capture — stats and pcap reset", + Style::default().fg(theme::DIM), + ))); + + let list = ed.matches.len().min(VISIBLE); + if list > 0 { + lines.push(Line::from(Span::styled( + "─".repeat(width), + Style::default().fg(theme::RUST_DEEP), + ))); + // Scroll the window so the highlighted row stays visible. + let top = ed.sel.saturating_sub(list - 1).min(ed.matches.len() - list); + for (row, &idx) in ed.matches[top..top + list].iter().enumerate() { + let c = &CANDIDATES[idx]; + let picked = top + row == ed.sel; + let hit = ed.current_word().chars().count().min(c.word.len()); + let (head, tail) = c.word.split_at(hit); + let base = if picked { + Style::default().bg(theme::SELECT_BG) + } else { + Style::default() + }; + lines.push(Line::from(vec![ + Span::styled( + if picked { "▍" } else { " " }, + Style::default().fg(theme::EMBER), + ), + Span::styled( + head.to_string(), + base.fg(theme::EMBER).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{: usize { + s[..at] + .char_indices() + .rev() + .find(|(_, c)| c.is_whitespace() || *c == '(' || *c == ')') + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0) +} + +fn prev_char(s: &str, at: usize) -> Option<(usize, char)> { + s[..at].char_indices().next_back() +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + + fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + fn plain(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn type_str(ed: &mut FilterEditor, s: &str) { + for c in s.chars() { + ed.on_key(key(c)); + } + } + + fn words(ed: &FilterEditor) -> Vec<&'static str> { + ed.matches.iter().map(|&i| CANDIDATES[i].word).collect() + } + + #[test] + fn opens_on_the_filter_in_force() { + let ed = FilterEditor::new(Some("tcp port 443")); + assert_eq!(ed.value().as_deref(), Some("tcp port 443")); + assert_eq!(ed.cursor, "tcp port 443".len()); + } + + #[test] + fn empty_input_means_no_filter() { + let mut ed = FilterEditor::new(Some("tcp")); + ed.on_key(ctrl('u')); + assert!(ed.value().is_none()); + } + + #[test] + fn completes_the_word_under_the_cursor() { + let mut ed = FilterEditor::new(None); + type_str(&mut ed, "ud"); + assert_eq!(words(&ed), vec!["udp", "udp port"]); + ed.on_key(plain(KeyCode::Tab)); + assert_eq!(ed.input, "udp "); + assert_eq!(ed.cursor, 4); + } + + #[test] + fn completion_matches_only_the_last_word() { + let mut ed = FilterEditor::new(None); + type_str(&mut ed, "tcp and po"); + assert_eq!(words(&ed), vec!["port", "portrange"]); + ed.on_key(plain(KeyCode::Down)); + ed.on_key(plain(KeyCode::Tab)); + assert_eq!(ed.input, "tcp and portrange "); + } + + #[test] + fn a_parenthesis_starts_a_fresh_word() { + let mut ed = FilterEditor::new(None); + type_str(&mut ed, "ip and (tc"); + assert_eq!(words(&ed), vec!["tcp", "tcp port", "tcp portrange"]); + ed.on_key(plain(KeyCode::Tab)); + assert_eq!(ed.input, "ip and (tcp "); + } + + #[test] + fn selection_wraps_both_ways() { + let mut ed = FilterEditor::new(None); + type_str(&mut ed, "ud"); // udp, udp port + ed.on_key(plain(KeyCode::Up)); + assert_eq!(ed.sel, 1); + ed.on_key(plain(KeyCode::Down)); + assert_eq!(ed.sel, 0); + } + + #[test] + fn ctrl_w_deletes_the_previous_word() { + let mut ed = FilterEditor::new(Some("tcp port 443")); + ed.on_key(ctrl('w')); + assert_eq!(ed.input, "tcp port "); + } + + #[test] + fn ctrl_f_and_enter_both_apply() { + let mut ed = FilterEditor::new(Some("arp")); + assert!(matches!( + ed.on_key(ctrl('f')), + EditAction::Apply(Some(f)) if f == "arp" + )); + assert!(matches!( + ed.on_key(plain(KeyCode::Enter)), + EditAction::Apply(Some(f)) if f == "arp" + )); + assert!(matches!(ed.on_key(plain(KeyCode::Esc)), EditAction::Cancel)); + } + + #[test] + fn ctrl_f_does_not_type_an_f() { + let mut ed = FilterEditor::new(None); + ed.on_key(ctrl('f')); + assert_eq!(ed.input, ""); + } + + #[test] + fn editing_mid_line_completes_in_place() { + let mut ed = FilterEditor::new(Some("tcp and udp")); + for _ in 0.." and udp".len() { + ed.on_key(plain(KeyCode::Left)); + } + // Cursor sits right after "tcp". + assert_eq!(ed.current_word(), "tcp"); + assert_eq!(words(&ed), vec!["tcp", "tcp port", "tcp portrange"]); + } + + #[test] + fn an_edit_clears_a_stale_error() { + let mut ed = FilterEditor::new(Some("tcp porrt 80")); + ed.set_error("syntax error"); + assert!(ed.error.is_some()); + ed.on_key(plain(KeyCode::Backspace)); + assert!(ed.error.is_none()); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0405860..a79d709 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,6 @@ +pub mod filter; pub mod printer; +pub mod theme; pub mod tui; use std::io::IsTerminal; diff --git a/src/ui/printer.rs b/src/ui/printer.rs index 2d5ab10..bebe236 100644 --- a/src/ui/printer.rs +++ b/src/ui/printer.rs @@ -87,8 +87,24 @@ pub fn print_final_summary(cfg_output: &std::path::Path, cap: &CaptureSummary, u cap.bytes, format_bytes(cap.bytes) ); + // Only worth a line when recording was toggled off at some point — otherwise it + // just restates the packet count. + if cap.recorded != cap.packets { + eprintln!( + " recorded : {} ({} not written)", + cap.recorded, + cap.packets - cap.recorded + ); + } eprintln!(" dropped kernel: {}", cap.kernel_dropped); eprintln!(" dropped iface : {}", cap.if_dropped); eprintln!(" dropped ui : {ui_dropped}"); - eprintln!(" output : {}", cfg_output.display()); + if cap.wrote_file { + eprintln!(" output : {}", cfg_output.display()); + } else { + eprintln!( + " output : nothing recorded, {} left untouched", + cfg_output.display() + ); + } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..c4fddb5 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,14 @@ +//! A rust-and-copper palette, so the whole UI reads as one piece with the logo. + +use ratatui::style::Color; + +pub const RUST_DEEP: Color = Color::Rgb(112, 51, 24); +pub const RUST: Color = Color::Rgb(183, 87, 39); +pub const RUST_BRIGHT: Color = Color::Rgb(214, 118, 48); +pub const EMBER: Color = Color::Rgb(240, 168, 82); +pub const COPPER: Color = Color::Rgb(196, 145, 96); +pub const STEEL: Color = Color::Rgb(154, 164, 176); +pub const DIM: Color = Color::Rgb(104, 110, 118); +pub const PATINA: Color = Color::Rgb(94, 176, 148); +pub const ALERT: Color = Color::Rgb(224, 92, 78); +pub const SELECT_BG: Color = Color::Rgb(58, 34, 20); diff --git a/src/ui/tui.rs b/src/ui/tui.rs index bce2aaf..227f58d 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -8,7 +8,9 @@ use std::time::{Duration, Instant, UNIX_EPOCH}; use anyhow::{Context, Result}; use crossbeam_channel::{Receiver, TryRecvError}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -21,6 +23,8 @@ use ratatui::{ Terminal, }; +use super::filter::{self, EditAction, FilterEditor}; +use super::theme; use crate::stats::{format_bits, format_bytes, Stats}; use crate::types::{format_mac, ip_proto_name, tcp_flag, PacketMeta, L3, L4}; @@ -30,22 +34,6 @@ const RECENT_CAP: usize = 2000; const HIST_CAP: usize = 240; const PAGE: usize = 10; -/// A rust-and-copper palette, so the whole UI reads as one piece with the logo. -mod theme { - use ratatui::style::Color; - - pub const RUST_DEEP: Color = Color::Rgb(112, 51, 24); - pub const RUST: Color = Color::Rgb(183, 87, 39); - pub const RUST_BRIGHT: Color = Color::Rgb(214, 118, 48); - pub const EMBER: Color = Color::Rgb(240, 168, 82); - pub const COPPER: Color = Color::Rgb(196, 145, 96); - pub const STEEL: Color = Color::Rgb(154, 164, 176); - pub const DIM: Color = Color::Rgb(104, 110, 118); - pub const PATINA: Color = Color::Rgb(94, 176, 148); - pub const ALERT: Color = Color::Rgb(224, 92, 78); - pub const SELECT_BG: Color = Color::Rgb(58, 34, 20); -} - pub struct TuiConfig { pub interface: String, pub filter: Option, @@ -57,9 +45,20 @@ pub struct Tui { pub cfg: TuiConfig, pub rx: Receiver, pub stop_flag: Arc, + /// Shared with the capture thread: while false, packets are shown but not written. + pub recording: Arc, pub ui_dropped: Arc, } +/// Why the TUI gave control back to `main`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TuiOutcome { + /// The user is done, or the capture ended on its own. + Quit, + /// Restart under a new filter, from a clean slate. + Restart { filter: Option }, +} + /// A packet in the scrollback, tagged with its capture-order number. struct RecentPkt { no: u64, @@ -78,6 +77,10 @@ struct AppState { table: TableState, show_detail: bool, pps_hist: VecDeque, + /// `Some` while the filter editor is open; it swallows every key until it closes. + editor: Option, + /// Mirror of the shared recording flag, refreshed each tick for drawing. + recording: bool, } impl AppState { @@ -91,6 +94,8 @@ impl AppState { table: TableState::new().with_selected(Some(0)), show_detail: true, pps_hist: VecDeque::with_capacity(HIST_CAP), + editor: None, + recording: true, } } @@ -144,14 +149,9 @@ impl AppState { } impl Tui { - pub fn run(self) -> Result { - let mut terminal = setup_terminal().context("setting up terminal")?; - let result = self.run_loop(&mut terminal); - restore_terminal(&mut terminal).ok(); - result - } - - fn run_loop(self, terminal: &mut Terminal>) -> Result { + /// Run until the user quits or asks for a new filter. The terminal is owned by + /// the caller so a filter restart doesn't flash the screen back and forth. + pub fn run(self, terminal: &mut Terminal>) -> Result { let mut app = AppState::new(self.cfg.max_talkers); let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); @@ -163,7 +163,7 @@ impl Tui { Ok(m) => app.ingest(m), Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { - return Ok(app.stats); + return Ok(TuiOutcome::Quit); } } } @@ -175,28 +175,53 @@ impl Tui { if event::poll(timeout).unwrap_or(false) { if let Ok(Event::Key(k)) = event::read() { if k.kind == KeyEventKind::Press { - match k.code { - KeyCode::Char('q') | KeyCode::Esc => { - self.stop_flag.store(true, Ordering::Relaxed); - return Ok(app.stats); - } - KeyCode::Char('p') => app.paused = !app.paused, - KeyCode::Char('d') | KeyCode::Enter => { - app.show_detail = !app.show_detail - } - KeyCode::Down | KeyCode::Char('j') => app.move_cursor(1), - KeyCode::Up | KeyCode::Char('k') => app.move_cursor(-1), - KeyCode::PageDown => app.move_cursor(PAGE as isize), - KeyCode::PageUp => app.move_cursor(-(PAGE as isize)), - KeyCode::Home | KeyCode::Char('g') => { - app.follow = true; - app.table.select(Some(0)); + // The editor, when open, owns the keyboard. + if let Some(ed) = app.editor.as_mut() { + match ed.on_key(k) { + EditAction::Continue => {} + EditAction::Cancel => app.editor = None, + EditAction::Apply(filter) => { + match filter.as_deref().map(crate::capture::validate_filter) { + Some(Err(e)) => ed.set_error(bpf_error(&e)), + _ => { + self.stop_flag.store(true, Ordering::Relaxed); + return Ok(TuiOutcome::Restart { filter }); + } + } + } } - KeyCode::End | KeyCode::Char('G') => { - let last = app.recent.len().saturating_sub(1); - app.move_cursor(last as isize); + } else { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + match k.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.stop_flag.store(true, Ordering::Relaxed); + return Ok(TuiOutcome::Quit); + } + KeyCode::Char('f') => { + app.editor = + Some(FilterEditor::new(self.cfg.filter.as_deref())); + } + KeyCode::Char('r') => { + self.recording.fetch_xor(true, Ordering::Relaxed); + } + KeyCode::Char('p') if !ctrl => app.paused = !app.paused, + KeyCode::Char('d') | KeyCode::Enter => { + app.show_detail = !app.show_detail + } + KeyCode::Down | KeyCode::Char('j') => app.move_cursor(1), + KeyCode::Up | KeyCode::Char('k') => app.move_cursor(-1), + KeyCode::PageDown => app.move_cursor(PAGE as isize), + KeyCode::PageUp => app.move_cursor(-(PAGE as isize)), + KeyCode::Home | KeyCode::Char('g') => { + app.follow = true; + app.table.select(Some(0)); + } + KeyCode::End | KeyCode::Char('G') => { + let last = app.recent.len().saturating_sub(1); + app.move_cursor(last as isize); + } + _ => {} } - _ => {} } } } @@ -204,6 +229,7 @@ impl Tui { // 3. On tick boundary, recompute rates and redraw. if last_tick.elapsed() >= tick_rate { + app.recording = self.recording.load(Ordering::Relaxed); app.tick(); let dropped = self.ui_dropped.load(Ordering::Relaxed); terminal.draw(|f| draw(f, &self.cfg, &mut app, dropped))?; @@ -212,30 +238,50 @@ impl Tui { // 4. If capture thread has stopped and channel is empty, exit. if self.stop_flag.load(Ordering::Relaxed) && self.rx.is_empty() { - return Ok(app.stats); + return Ok(TuiOutcome::Quit); } } } } -fn setup_terminal() -> Result>> { - enable_raw_mode()?; - let mut out = io::stdout(); - execute!(out, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(out); - let terminal = Terminal::new(backend)?; - Ok(terminal) +/// libpcap's compile errors arrive multi-line and double-prefixed; flatten them +/// onto the editor's single error row. +fn bpf_error(e: &anyhow::Error) -> String { + let s = e.to_string().trim().replace('\n', " "); + s.strip_prefix("libpcap error: ").unwrap_or(&s).to_string() } -fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - Ok(()) +/// Owns the alternate screen for the whole session: restores it on drop, so an +/// error path or a filter restart can't leave the terminal in raw mode. +pub struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + pub fn new() -> Result { + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + let terminal = Terminal::new(CrosstermBackend::new(out)).context("creating terminal")?; + Ok(Self { terminal }) + } + + pub fn terminal(&mut self) -> &mut Terminal> { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + disable_raw_mode().ok(); + execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .ok(); + self.terminal.show_cursor().ok(); + } } // ── layout ────────────────────────────────────────────────────────────────── @@ -277,6 +323,24 @@ fn draw(f: &mut ratatui::Frame, cfg: &TuiConfig, app: &mut AppState, ui_dropped: draw_detail(f, rows[2], app); } draw_footer(f, rows[3], app); + + if let Some(ed) = &app.editor { + let pos = filter::draw(f, editor_area(area, ed.height()), ed); + f.set_cursor_position(pos); + } +} + +/// Float the editor over the top of the packet list, wide enough for a real +/// expression but never wider than the terminal. +fn editor_area(area: Rect, height: u16) -> Rect { + let width = area.width.saturating_sub(4).clamp(1, 76); + let height = height.min(area.height); + Rect { + x: area.x + (area.width - width) / 2, + y: area.y + ((area.height.saturating_sub(height)) / 3).min(6), + width, + height, + } } fn panel(title: &str) -> Block<'_> { @@ -407,6 +471,25 @@ fn draw_header( ) }; + // The recording badge sits next to LIVE/PAUSED: those describe the display, this one + // describes what is reaching the disk. + let rec = if app.recording { + Span::styled( + " ● REC ", + Style::default() + .fg(Color::Black) + .bg(theme::ALERT) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::styled( + " ○ NOT RECORDING ", + Style::default() + .fg(theme::ALERT) + .add_modifier(Modifier::BOLD), + ) + }; + let drop_style = Style::default().fg(if ui_dropped > 0 { theme::ALERT } else { @@ -434,6 +517,8 @@ fn draw_header( Style::default().fg(theme::PATINA), ), live, + Span::raw(" "), + rec, ]), Line::from(vec![ kv_label("Interface"), @@ -460,6 +545,7 @@ fn draw_header( cfg.filter.clone().unwrap_or_else(|| "".into()), Style::default().fg(theme::COPPER), ), + Span::styled(" (f to edit)", Style::default().fg(theme::DIM)), ]), ]; @@ -928,8 +1014,10 @@ fn transport_spans(m: &PacketMeta) -> Vec> { // ── footer ────────────────────────────────────────────────────────────────── fn draw_footer(f: &mut ratatui::Frame, area: Rect, app: &AppState) { - let keys: [(&str, &str); 6] = [ + let keys: [(&str, &str); 8] = [ ("q", "quit"), + ("r", if app.recording { "stop rec" } else { "record" }), + ("f", "filter"), ("↑↓/jk", "select"), ("g/G", "newest/oldest"), ( @@ -1236,6 +1324,76 @@ mod tests { assert_eq!(format_num(1_234_567), "1,234,567"); } + #[test] + fn recording_state_is_visible_in_the_header_and_footer() { + let mut app = populated(); + let on = render(&mut app, 140, 34); + assert!(on.contains("● REC"), "REC badge missing"); + assert!(on.contains(" r stop rec"), "footer should offer to stop"); + + app.recording = false; + let off = render(&mut app, 140, 34); + println!("{off}"); + assert!(off.contains("○ NOT RECORDING"), "off badge missing"); + assert!(off.contains(" r record"), "footer should offer to record"); + // The list keeps filling either way — recording only gates the pcap. + assert!(off.contains("Destination"), "packet table still expected"); + } + + #[test] + fn filter_editor_overlays_the_live_view() { + let mut app = populated(); + app.editor = Some(FilterEditor::new(Some("tcp po"))); + let out = render(&mut app, 140, 34); + println!("{out}"); + assert!(out.contains("capture filter"), "editor frame missing"); + assert!(out.contains("tcp po"), "current filter not pre-filled"); + assert!(out.contains("portrange"), "completions missing"); + assert!(out.contains("Ctrl-F apply & restart"), "apply hint missing"); + assert!( + out.contains("stats and pcap reset"), + "reset warning missing" + ); + } + + #[test] + fn filter_editor_fits_a_narrow_terminal() { + let mut app = populated(); + app.editor = Some(FilterEditor::new(None)); + let out = render(&mut app, 60, 16); + println!("{out}"); + assert!(out.contains("capture filter")); + } + + #[test] + fn a_rejected_filter_keeps_the_editor_open_with_the_reason() { + let mut app = populated(); + let mut ed = FilterEditor::new(Some("tcp porrt 80")); + let err = crate::capture::validate_filter(&ed.value().unwrap()).unwrap_err(); + ed.set_error(bpf_error(&err)); + app.editor = Some(ed); + let out = render(&mut app, 140, 34); + println!("{out}"); + assert!(out.contains("✗ "), "error row missing"); + assert!( + out.contains("tcp porrt 80"), + "input should survive rejection" + ); + } + + #[test] + fn bpf_errors_are_one_line() { + let err = crate::capture::validate_filter("tcp porrt 80").unwrap_err(); + let msg = bpf_error(&err); + assert!(!msg.contains('\n'), "got: {msg}"); + assert!(!msg.is_empty()); + } + + #[test] + fn valid_filters_pass_validation() { + crate::capture::validate_filter("tcp port 443 or (udp and not arp)").unwrap(); + } + #[test] fn talkers_table_is_reachable() { let app = populated(); From 8722072087d8c09c0c92d3cfdb937437cb85c4e3 Mon Sep 17 00:00:00 2001 From: Jayme Klein Date: Thu, 6 Aug 2026 14:14:33 -0300 Subject: [PATCH 2/2] fix(capture): keep the loop responsive when no packets match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libpcap's read timeout is not a promise. On Linux the kernel only wakes the reader once a packet has landed, so a filter matching nothing — or simply a silent link — left `next_packet` parked indefinitely, and with it the stop flag, `--duration`, the record toggle and the filter restart. Quitting hung the app, and SIGTERM was swallowed by the Ctrl-C handler whose flag nobody was left to read, so the process had to be killed. Poll non-blocking with a 5 ms nap instead: the flags are checked ~200 times a second no matter what the link is doing. Also enable immediate mode, so packets are handed over as they arrive rather than sitting in a kernel block until the read timeout — on a quiet link they could show up a second late. --- src/capture.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/capture.rs b/src/capture.rs index 4af29f5..48be74f 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -11,6 +11,10 @@ use pcap::{Capture, Device, Linktype}; use crate::parse::parse; use crate::types::{PacketMeta, StopCondition}; +/// How long to nap when the link is quiet. Sets the worst-case delay before the capture +/// thread notices a stop, a record toggle or an elapsed `--duration`. +const IDLE_POLL: Duration = Duration::from_millis(5); + pub struct CaptureConfig { pub interface: String, pub snaplen: i32, @@ -181,7 +185,9 @@ fn run( .with_context(|| format!("selecting device `{}`", cfg.interface))? .snaplen(cfg.snaplen) .promisc(cfg.promisc) - // 1 s timeout lets next_packet return periodically so we can check stop flags. + // Hand packets over as they arrive instead of holding them in a kernel block + // until the read timeout — this is a live view, latency beats batching. + .immediate_mode(true) .timeout(1_000) .buffer_size(10_000_000) .open() @@ -192,6 +198,15 @@ fn run( .with_context(|| format!("compiling BPF filter `{bpf}`"))?; } + // Non-blocking, because the read timeout is not a promise: on Linux the kernel only + // wakes the reader once a packet has landed, so a filter that matches nothing (or a + // silent link) leaves a blocking `next_packet` parked indefinitely — and with it the + // stop flag, the record toggle, `--duration`, Ctrl-C and the filter restart. Polling + // ourselves keeps the loop responsive no matter what the link is doing. + let mut cap = cap + .setnonblock() + .context("switching the capture handle to non-blocking")?; + // Synced up front so the usual case — recording on from the start — surfaces a // permission problem here rather than on the first packet. let mut rec = Recorder::new(); @@ -260,7 +275,9 @@ fn run( Err(TrySendError::Disconnected(_)) => break, } } - Err(pcap::Error::TimeoutExpired) => continue, + // Non-blocking: nothing ready right now. A short nap keeps the loop from + // spinning while still checking the flags ~200 times a second. + Err(pcap::Error::TimeoutExpired) => thread::sleep(IDLE_POLL), Err(pcap::Error::NoMorePackets) => break, Err(e) => { eprintln!("rust-wire: capture error: {e}");