use std::ops::Range;
use std::path::PathBuf;
use ratatui::layout::Rect;
use super::cursor::CursorBuffer;
use super::owned_packet::OwnedPacket;
#[derive(Clone)]
pub struct PacketIndex {
pub data_offset: u64,
pub captured_len: u32,
pub original_len: u32,
pub timestamp_secs: u64,
pub timestamp_usecs: u32,
pub link_type: u16,
pub _pad: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveMode {
Live,
Paused,
Complete,
}
pub struct CaptureMap {
mmap: memmap2::Mmap,
file: Option<std::fs::File>,
}
#[allow(unsafe_code)]
impl CaptureMap {
pub fn new(file: &std::fs::File) -> std::io::Result<Self> {
let mmap = unsafe { memmap2::MmapOptions::new().map(file)? };
Ok(Self { mmap, file: None })
}
pub fn new_live(file: std::fs::File) -> std::io::Result<Self> {
let file_len = file.metadata()?.len();
let mmap = if file_len == 0 {
memmap2::MmapMut::map_anon(0)?.make_read_only()?
} else {
unsafe { memmap2::MmapOptions::new().map(&file)? }
};
Ok(Self {
mmap,
file: Some(file),
})
}
pub fn refresh(&mut self) -> std::io::Result<bool> {
let file = match &self.file {
Some(f) => f,
None => return Ok(false),
};
let file_len = file.metadata()?.len() as usize;
if file_len < self.mmap.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"capture file was truncated; mmap append-only invariant violated",
));
}
if file_len == self.mmap.len() {
return Ok(false);
}
let new_mmap = unsafe { memmap2::MmapOptions::new().map(file)? };
self.mmap = new_mmap;
Ok(true)
}
pub fn packet_data(&self, index: &PacketIndex) -> Option<&[u8]> {
let start = index.data_offset as usize;
let end = start + index.captured_len as usize;
self.mmap.get(start..end)
}
pub fn as_bytes(&self) -> &[u8] {
&self.mmap
}
}
pub struct RowSummary {
pub source: String,
pub destination: String,
pub protocol: &'static str,
pub info: String,
}
pub struct SelectedPacket {
pub pkt_idx: usize,
pub packet: OwnedPacket,
pub tree_nodes: Vec<TreeNode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
PacketList,
DetailTree,
HexDump,
FilterInput,
TreeSearch,
YankPrompt,
CommandMode,
}
impl Pane {
pub fn next(self) -> Self {
match self {
Self::PacketList => Self::DetailTree,
Self::DetailTree => Self::HexDump,
Self::HexDump => Self::PacketList,
Self::FilterInput | Self::TreeSearch | Self::YankPrompt | Self::CommandMode => {
Self::PacketList
}
}
}
pub fn prev(self) -> Self {
match self {
Self::PacketList => Self::HexDump,
Self::DetailTree => Self::PacketList,
Self::HexDump => Self::DetailTree,
Self::FilterInput | Self::TreeSearch | Self::YankPrompt | Self::CommandMode => {
Self::PacketList
}
}
}
}
#[derive(Default, Clone, Copy)]
pub struct PaneLayout {
pub packet_list: Rect,
pub detail_tree: Rect,
pub hex_dump: Rect,
pub frame_area: Rect,
}
impl PaneLayout {
pub fn pane_at(&self, col: u16, row: u16) -> Option<Pane> {
if self.packet_list.contains((col, row).into()) {
Some(Pane::PacketList)
} else if self.detail_tree.contains((col, row).into()) {
Some(Pane::DetailTree)
} else if self.hex_dump.contains((col, row).into()) {
Some(Pane::HexDump)
} else {
None
}
}
}
#[derive(Default)]
pub struct PacketListState {
pub selected: usize,
pub scroll_offset: usize,
}
pub struct TreeNode {
pub label: String,
pub depth: usize,
pub expanded: bool,
pub byte_range: Range<usize>,
pub children_count: usize,
pub is_layer: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionMode {
Char,
Line,
}
pub struct SelectionState {
pub mode: SelectionMode,
pub anchor_node: usize,
pub anchor_char: usize,
pub cursor_char: usize,
}
#[derive(Default)]
pub struct DetailTreeState {
pub selected: usize,
pub scroll_offset: usize,
pub search_query: String,
pub search_completions: Vec<TreeSearchCandidate>,
pub search_completion_selected: usize,
pub selection: Option<SelectionState>,
pub yank_message: Option<String>,
}
pub struct TreeSearchCandidate {
pub label: String,
pub node_index: usize,
}
#[derive(Default)]
pub struct HexDumpState {
pub scroll_offset: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TimeFormat {
#[default]
Absolute,
Relative,
Delta,
}
impl TimeFormat {
pub fn next(self) -> Self {
match self {
Self::Absolute => Self::Relative,
Self::Relative => Self::Delta,
Self::Delta => Self::Absolute,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Absolute => "Abs",
Self::Relative => "Rel",
Self::Delta => "Delta",
}
}
}
pub struct CommandState {
pub buf: CursorBuffer,
}
pub const DEFAULT_PANE_WEIGHTS: [u16; 3] = [35, 35, 30];
#[derive(Default)]
pub struct FilterState {
pub buf: CursorBuffer,
pub applied: String,
pub error_message: Option<String>,
pub completions: Vec<CompletionCandidate>,
pub completion_selected: usize,
pub completion_visible: bool,
pub history: Vec<String>,
pub history_pos: Option<usize>,
pub history_saved_input: String,
}
pub struct IndexProgress {
pub state: packet_dissector_pcap::IndexState,
pub total_bytes: usize,
}
impl IndexProgress {
pub fn fraction(&self) -> f64 {
if self.total_bytes == 0 {
1.0
} else {
self.state.byte_offset as f64 / self.total_bytes as f64
}
}
}
pub struct FilterProgress {
pub expr: Option<crate::filter_expr::FilterExpr>,
pub cursor: usize,
pub results: Vec<usize>,
}
impl FilterProgress {
pub fn fraction(&self, total: usize) -> f64 {
if total == 0 {
1.0
} else {
self.cursor as f64 / total as f64
}
}
}
pub struct StatsProgress {
pub cursor: usize,
pub collector: crate::stats::StatsCollector,
}
impl StatsProgress {
pub fn fraction(&self, total: usize) -> f64 {
if total == 0 {
1.0
} else {
self.cursor as f64 / total as f64
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum StreamKey {
TcpStreamId(u32),
Tuple {
addr_lo: String,
addr_hi: String,
port_lo: u16,
port_hi: u16,
protocol: &'static str,
},
}
pub struct StreamLine {
pub text: String,
pub is_client: bool,
}
pub struct StreamViewState {
pub lines: Vec<StreamLine>,
pub scroll_offset: usize,
pub title: String,
}
pub struct StreamBuildProgress {
pub stream_key: StreamKey,
pub cursor: usize,
pub lines: Vec<StreamLine>,
pub client_addr: Option<String>,
pub title: String,
pub protocol: &'static str,
}
impl StreamBuildProgress {
pub fn fraction(&self, total: usize) -> f64 {
if total == 0 {
1.0
} else {
self.cursor as f64 / total as f64
}
}
}
pub struct CompletionCandidate {
pub label: String,
}
const HISTORY_MAX_ENTRIES: usize = 1000;
pub fn history_path() -> Option<PathBuf> {
if let Ok(state) = std::env::var("XDG_STATE_HOME") {
return Some(PathBuf::from(state).join("dsct").join("filter_history"));
}
if let Ok(cache) = std::env::var("XDG_CACHE_HOME") {
return Some(PathBuf::from(cache).join("dsct").join("filter_history"));
}
if let Ok(home) = std::env::var("HOME") {
return Some(
PathBuf::from(home)
.join(".local/state/dsct")
.join("filter_history"),
);
}
None
}
pub fn load_history() -> Vec<String> {
let path = match history_path() {
Some(p) => p,
None => return Vec::new(),
};
match std::fs::read_to_string(&path) {
Ok(content) => content
.lines()
.filter(|l| !l.is_empty())
.take(HISTORY_MAX_ENTRIES)
.map(String::from)
.collect(),
Err(_) => Vec::new(),
}
}
pub fn save_history(history: &[String]) {
let path = match history_path() {
Some(p) => p,
None => return,
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let entries: Vec<&String> = if history.len() > HISTORY_MAX_ENTRIES {
history[history.len() - HISTORY_MAX_ENTRIES..]
.iter()
.collect()
} else {
history.iter().collect()
};
let content = entries
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n");
let tmp_path = path.with_extension("tmp");
if std::fs::write(&tmp_path, &content).is_ok() {
let _ = std::fs::rename(&tmp_path, &path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packet_index_size() {
assert_eq!(std::mem::size_of::<PacketIndex>(), 32);
}
#[test]
fn index_progress_fraction() {
let progress = IndexProgress {
state: packet_dissector_pcap::IndexState {
byte_offset: 500,
format: packet_dissector_pcap::IndexFormat::Pcap {
is_le: true,
link_type: 1,
},
done: false,
},
total_bytes: 1000,
};
assert!((progress.fraction() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn index_progress_fraction_zero_total() {
let progress = IndexProgress {
state: packet_dissector_pcap::IndexState {
byte_offset: 0,
format: packet_dissector_pcap::IndexFormat::Pcap {
is_le: true,
link_type: 1,
},
done: true,
},
total_bytes: 0,
};
assert!((progress.fraction() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn pane_next_cycles() {
assert_eq!(Pane::PacketList.next(), Pane::DetailTree);
assert_eq!(Pane::DetailTree.next(), Pane::HexDump);
assert_eq!(Pane::HexDump.next(), Pane::PacketList);
assert_eq!(Pane::FilterInput.next(), Pane::PacketList);
}
#[test]
fn pane_prev_cycles() {
assert_eq!(Pane::PacketList.prev(), Pane::HexDump);
assert_eq!(Pane::DetailTree.prev(), Pane::PacketList);
assert_eq!(Pane::HexDump.prev(), Pane::DetailTree);
assert_eq!(Pane::FilterInput.prev(), Pane::PacketList);
}
#[test]
fn pane_layout_hit_test() {
let layout = PaneLayout {
frame_area: Rect::new(0, 0, 80, 30),
packet_list: Rect::new(0, 0, 80, 10),
detail_tree: Rect::new(0, 10, 80, 10),
hex_dump: Rect::new(0, 20, 80, 10),
};
assert_eq!(layout.pane_at(40, 5), Some(Pane::PacketList));
assert_eq!(layout.pane_at(40, 15), Some(Pane::DetailTree));
assert_eq!(layout.pane_at(40, 25), Some(Pane::HexDump));
assert_eq!(layout.pane_at(40, 35), None); }
#[test]
fn history_save_and_load_to_path() {
let dir = std::env::temp_dir().join(format!("dsct_hist_test_{}", std::process::id()));
let path = dir.join("filter_history");
std::fs::create_dir_all(&dir).unwrap();
let history = ["tcp".to_string(), "dns".to_string(), "not icmp".to_string()];
let content = history.join("\n");
std::fs::write(&path, &content).unwrap();
let loaded: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
assert_eq!(loaded, vec!["tcp", "dns", "not icmp"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn history_max_entries_truncation() {
let big: Vec<String> = (0..1500).map(|i| format!("filter_{i}")).collect();
let entries: Vec<&String> = big[big.len() - HISTORY_MAX_ENTRIES..].iter().collect();
assert_eq!(entries.len(), 1000);
assert_eq!(*entries[0], "filter_500");
assert_eq!(*entries[999], "filter_1499");
}
#[test]
fn history_path_returns_some() {
let _ = history_path();
}
#[test]
fn capture_map_new_live_and_refresh() {
use std::io::Write;
let pcap_2 = super::super::loader::tests::build_pcap_for_test(2);
let pcap_5 = super::super::loader::tests::build_pcap_for_test(5);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&pcap_2).unwrap();
tmp.flush().unwrap();
let file = tmp.as_file().try_clone().unwrap();
let mut capture = CaptureMap::new_live(file).unwrap();
assert_eq!(capture.as_bytes().len(), pcap_2.len());
assert!(!capture.refresh().unwrap());
tmp.as_file().set_len(0).unwrap();
use std::io::Seek;
tmp.seek(std::io::SeekFrom::Start(0)).unwrap();
tmp.write_all(&pcap_5).unwrap();
tmp.flush().unwrap();
assert!(capture.refresh().unwrap());
assert_eq!(capture.as_bytes().len(), pcap_5.len());
assert!(!capture.refresh().unwrap());
}
#[test]
fn capture_map_static_refresh_is_noop() {
let pcap = super::super::loader::tests::build_pcap_for_test(1);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut tmp.as_file(), &pcap).unwrap();
let file = std::fs::File::open(tmp.path()).unwrap();
let mut capture = CaptureMap::new(&file).unwrap();
assert!(!capture.refresh().unwrap());
}
#[test]
fn capture_map_new_live_empty_file() {
use std::io::Write;
let tmp = tempfile::NamedTempFile::new().unwrap();
let file = tmp.as_file().try_clone().unwrap();
let mut capture = CaptureMap::new_live(file).unwrap();
assert_eq!(capture.as_bytes().len(), 0);
assert!(!capture.refresh().unwrap());
let pcap = super::super::loader::tests::build_pcap_for_test(1);
std::io::Write::write_all(&mut tmp.as_file(), &pcap).unwrap();
tmp.as_file().flush().unwrap();
assert!(capture.refresh().unwrap());
assert_eq!(capture.as_bytes().len(), pcap.len());
}
#[test]
fn capture_map_new_live_iterative_append_offset_invariant() {
use std::io::Write;
let tmp = tempfile::NamedTempFile::new().unwrap();
let file = tmp.as_file().try_clone().unwrap();
let mut capture = CaptureMap::new_live(file).unwrap();
let pcap = super::super::loader::tests::build_pcap_for_test(10);
let chunk = 16usize;
let mut prev_len = 0usize;
let mut written = 0usize;
let mut i = 0;
while written < pcap.len() {
let lo = i * chunk;
let hi = (lo + chunk).min(pcap.len());
tmp.as_file().write_all(&pcap[lo..hi]).unwrap();
tmp.as_file().flush().unwrap();
written = hi;
i += 1;
let _ = capture.refresh().unwrap();
let mmap_len = capture.as_bytes().len();
assert!(
written <= mmap_len,
"observed offset {written} exceeds mmap len {mmap_len}"
);
assert!(
mmap_len >= prev_len,
"mmap shrunk from {prev_len} to {mmap_len}"
);
prev_len = mmap_len;
}
assert_eq!(capture.as_bytes().len(), pcap.len());
}
#[test]
fn capture_map_refresh_rejects_truncation() {
use std::io::Write;
let pcap = super::super::loader::tests::build_pcap_for_test(5);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&pcap).unwrap();
tmp.flush().unwrap();
let file = tmp.as_file().try_clone().unwrap();
let mut capture = CaptureMap::new_live(file).unwrap();
assert_eq!(capture.as_bytes().len(), pcap.len());
let new_len = (pcap.len() / 2) as u64;
tmp.as_file().set_len(new_len).unwrap();
tmp.as_file().flush().unwrap();
let result = capture.refresh();
assert!(
result.is_err(),
"refresh() should reject truncation; got {result:?}"
);
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
}