Skip to main content

pcap_toolkit/
config.rs

1//! TOML configuration types for pcap-toolkit.
2//!
3//! CLI flags take precedence over config file values.
4//! Parsing uses [`toml_span`] — no serde dependency for config.
5
6use std::path::PathBuf;
7
8use toml_span::{DeserError, Deserialize, Value, de_helpers::TableHelper};
9
10use crate::error::ConfigError;
11
12/// Top-level configuration loaded from a TOML file.
13#[derive(Debug, Default)]
14pub struct Config {
15    /// Input PCAP files (supports glob patterns).
16    pub input: Vec<InputConfig>,
17
18    /// Sorting options.
19    pub sort: SortConfig,
20
21    /// Packet filter options.
22    pub filter: FilterConfig,
23
24    /// Output targets.
25    pub output: Vec<OutputConfig>,
26
27    /// Packet-level transformation options.
28    pub transform: TransformConfig,
29
30    /// Structured data export options.
31    pub export: ExportConfig,
32
33    /// Live replay options.
34    pub replay: ReplayConfig,
35}
36
37/// Input source configuration.
38#[derive(Debug)]
39pub struct InputConfig {
40    /// Path or glob pattern to PCAP file(s).
41    pub path: String,
42}
43
44/// Sorting configuration.
45#[derive(Debug, Default)]
46pub struct SortConfig {
47    /// Enable two-pass chronological sorting.
48    pub enabled: bool,
49
50    /// Time-slice interval for splitting output (e.g. `"1h"`, `"1d"`).
51    pub slice: Option<String>,
52}
53
54/// Filter configuration.
55#[derive(Debug, Default)]
56pub struct FilterConfig {
57    /// When `true`, the entire filter result is inverted.
58    pub negate: bool,
59
60    /// Additional filter rules chained after the base body.
61    pub rules: Vec<FilterRuleConfig>,
62
63    /// IP protocols to keep (e.g. `["tcp", "udp"]`).
64    pub proto: Vec<String>,
65
66    /// Source IP addresses / CIDRs to keep (OR-ed).
67    pub src_ip: Vec<String>,
68
69    /// Destination IP addresses / CIDRs to keep (OR-ed).
70    pub dst_ip: Vec<String>,
71
72    /// Either-endpoint IP addresses / CIDRs (OR-ed).
73    pub ip: Vec<String>,
74
75    /// Source port or range strings to keep (OR-ed).
76    pub src_port: Vec<String>,
77
78    /// Destination port or range strings to keep (OR-ed).
79    pub dst_port: Vec<String>,
80
81    /// Either-endpoint port or range strings (OR-ed).
82    pub port: Vec<String>,
83
84    /// Hex flow IDs to retain (comma-separated or multiple entries).
85    pub flow_id: Vec<String>,
86
87    /// Retain only packets at or after this datetime (RFC 3339 or ms epoch).
88    pub from: Option<String>,
89
90    /// Retain only packets at or before this datetime (RFC 3339 or ms epoch).
91    pub to: Option<String>,
92
93    /// TCP flags filter string (e.g. `"SYN+ACK"`, `"RST:exact"`).
94    pub tcp_flags: Option<String>,
95
96    /// Minimum captured packet length in bytes.
97    pub min_len: Option<u32>,
98
99    /// Maximum captured packet length in bytes.
100    pub max_len: Option<u32>,
101
102    /// Use unidirectional flow IDs (default: bidirectional).
103    pub unidirectional: bool,
104
105    /// Only include flows with at least this many packets.
106    /// Non-IP packets are excluded when this filter is active.
107    pub min_flow_packets: Option<u64>,
108}
109
110/// A single rule entry within `[[filter.rules]]` in the TOML config.
111///
112/// The `op` field controls how this rule combines with the accumulated result:
113/// `"and"` (default), `"or"`, or `"not"`.
114#[derive(Debug, Default)]
115pub struct FilterRuleConfig {
116    /// Logical operator: `"and"`, `"or"`, or `"not"`.
117    pub op: String,
118    pub proto: Vec<String>,
119    pub src_ip: Vec<String>,
120    pub dst_ip: Vec<String>,
121    pub ip: Vec<String>,
122    pub src_port: Vec<String>,
123    pub dst_port: Vec<String>,
124    pub port: Vec<String>,
125    pub flow_id: Vec<String>,
126    pub from: Option<String>,
127    pub to: Option<String>,
128    pub tcp_flags: Option<String>,
129    pub min_len: Option<u32>,
130    pub max_len: Option<u32>,
131    pub unidirectional: bool,
132}
133
134/// A single per-protocol payload truncation rule (`[[transform.truncate_by_proto]]`).
135#[derive(Debug)]
136pub struct ProtocolTruncationConfig {
137    /// IP protocol name (`"tcp"`, `"udp"`, `"icmp"`) or decimal number (`"6"`).
138    pub proto: String,
139    /// Maximum payload bytes to keep for this protocol.
140    pub max_payload_bytes: u32,
141}
142
143/// Packet-level transformation configuration (`[transform]` TOML table).
144#[derive(Debug, Default)]
145pub struct TransformConfig {
146    /// Global maximum payload bytes (per-protocol rules take precedence when set).
147    pub max_payload_bytes: Option<u32>,
148    /// Shift all timestamps so the capture starts at this datetime (RFC 3339 or ms epoch).
149    pub timestamp_start: Option<String>,
150    /// IP address replacement rules as `"OLD_IP=NEW_IP"` strings.
151    pub replace_ip: Vec<String>,
152    /// Per-protocol payload truncation rules.
153    pub truncate_by_proto: Vec<ProtocolTruncationConfig>,
154}
155
156/// A single output target within `[[export.outputs]]`.
157#[derive(Debug)]
158pub struct ExportOutputConfig {
159    /// Output file path (extension determines format unless `format` is set).
160    pub path: PathBuf,
161
162    /// Override output format: `"json"`, `"parquet"`, or `"avro"`.
163    pub format: Option<String>,
164
165    /// Apply Zstd compression to payload bytes.
166    pub compress_payload: bool,
167}
168
169/// Structured data export configuration (`[export]` table in TOML).
170#[derive(Debug, Default)]
171pub struct ExportConfig {
172    /// Multiple output targets (fan-out). Takes precedence over `path`.
173    pub outputs: Vec<ExportOutputConfig>,
174
175    /// Legacy single output path (superseded by `[[export.outputs]]`).
176    pub path: Option<PathBuf>,
177
178    /// Override output format: `"json"`, `"parquet"`, or `"avro"`.
179    pub format: Option<String>,
180
181    /// Apply Zstd compression to payload bytes.
182    pub compress_payload: bool,
183
184    /// Compute flow IDs unidirectionally (default: bidirectional).
185    pub unidirectional: bool,
186}
187
188/// Output target configuration.
189#[derive(Debug)]
190pub struct OutputConfig {
191    /// Output format: `"pcap"`, `"json"`, `"parquet"`, or `"avro"`.
192    pub format: String,
193
194    /// Output file path.
195    pub path: PathBuf,
196
197    /// Compress payload field (JSON / Parquet).
198    pub compress_payload: bool,
199}
200
201/// Live replay configuration.
202#[derive(Debug)]
203pub struct ReplayConfig {
204    /// Network interface names for replay (fan-out: each packet sent to all).
205    /// Accepts `interfaces = ["eth0", "eth1"]` or legacy `interface = "eth0"`.
206    pub interfaces: Vec<String>,
207
208    /// Replay speed multiplier (1.0 = real-time, 2.0 = 2× faster).
209    /// Ignored when `pps` is set.
210    pub speed: f64,
211
212    /// Fixed replay rate in packets per second.
213    /// When set, `speed` is ignored and original inter-packet timing is discarded.
214    pub pps: Option<u64>,
215}
216
217impl Default for ReplayConfig {
218    fn default() -> Self {
219        Self {
220            interfaces: Vec::new(),
221            speed: 1.0,
222            pps: None,
223        }
224    }
225}
226
227// ── toml_span::Deserialize implementations ──────────────────────────────────
228
229impl<'de> Deserialize<'de> for Config {
230    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
231        let mut th = TableHelper::new(value)?;
232        let input = th.optional::<Vec<InputConfig>>("input").unwrap_or_default();
233        let sort = th.optional::<SortConfig>("sort").unwrap_or_default();
234        let filter = th.optional::<FilterConfig>("filter").unwrap_or_default();
235        let output = th
236            .optional::<Vec<OutputConfig>>("output")
237            .unwrap_or_default();
238        let transform = th
239            .optional::<TransformConfig>("transform")
240            .unwrap_or_default();
241        let export = th.optional::<ExportConfig>("export").unwrap_or_default();
242        let replay = th.optional::<ReplayConfig>("replay").unwrap_or_default();
243        th.finalize(None)?;
244        Ok(Config {
245            input,
246            sort,
247            filter,
248            output,
249            transform,
250            export,
251            replay,
252        })
253    }
254}
255
256impl<'de> Deserialize<'de> for InputConfig {
257    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
258        let mut th = TableHelper::new(value)?;
259        let path = th.required::<String>("path")?;
260        th.finalize(None)?;
261        Ok(InputConfig { path })
262    }
263}
264
265impl<'de> Deserialize<'de> for SortConfig {
266    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
267        let mut th = TableHelper::new(value)?;
268        let enabled = th.optional::<bool>("enabled").unwrap_or(false);
269        let slice = th.optional::<String>("slice");
270        th.finalize(None)?;
271        Ok(SortConfig { enabled, slice })
272    }
273}
274
275impl<'de> Deserialize<'de> for FilterConfig {
276    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
277        let mut th = TableHelper::new(value)?;
278        let negate = th.optional::<bool>("negate").unwrap_or(false);
279        let rules = th
280            .optional::<Vec<FilterRuleConfig>>("rules")
281            .unwrap_or_default();
282        let proto = th.optional::<Vec<String>>("proto").unwrap_or_default();
283        let src_ip = th.optional::<Vec<String>>("src_ip").unwrap_or_default();
284        let dst_ip = th.optional::<Vec<String>>("dst_ip").unwrap_or_default();
285        let ip = th.optional::<Vec<String>>("ip").unwrap_or_default();
286        let src_port = th.optional::<Vec<String>>("src_port").unwrap_or_default();
287        let dst_port = th.optional::<Vec<String>>("dst_port").unwrap_or_default();
288        let port = th.optional::<Vec<String>>("port").unwrap_or_default();
289        let flow_id = th.optional::<Vec<String>>("flow_id").unwrap_or_default();
290        let from = th.optional::<String>("from");
291        let to = th.optional::<String>("to");
292        let tcp_flags = th.optional::<String>("tcp_flags");
293        let min_len = th.optional::<u32>("min_len");
294        let max_len = th.optional::<u32>("max_len");
295        let unidirectional = th.optional::<bool>("unidirectional").unwrap_or(false);
296        let min_flow_packets = th.optional::<u64>("min_flow_packets");
297        th.finalize(None)?;
298        Ok(FilterConfig {
299            negate,
300            rules,
301            proto,
302            src_ip,
303            dst_ip,
304            ip,
305            src_port,
306            dst_port,
307            port,
308            flow_id,
309            from,
310            to,
311            tcp_flags,
312            min_len,
313            max_len,
314            unidirectional,
315            min_flow_packets,
316        })
317    }
318}
319
320impl<'de> Deserialize<'de> for FilterRuleConfig {
321    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
322        let mut th = TableHelper::new(value)?;
323        let op = th.optional::<String>("op").unwrap_or_default();
324        let proto = th.optional::<Vec<String>>("proto").unwrap_or_default();
325        let src_ip = th.optional::<Vec<String>>("src_ip").unwrap_or_default();
326        let dst_ip = th.optional::<Vec<String>>("dst_ip").unwrap_or_default();
327        let ip = th.optional::<Vec<String>>("ip").unwrap_or_default();
328        let src_port = th.optional::<Vec<String>>("src_port").unwrap_or_default();
329        let dst_port = th.optional::<Vec<String>>("dst_port").unwrap_or_default();
330        let port = th.optional::<Vec<String>>("port").unwrap_or_default();
331        let flow_id = th.optional::<Vec<String>>("flow_id").unwrap_or_default();
332        let from = th.optional::<String>("from");
333        let to = th.optional::<String>("to");
334        let tcp_flags = th.optional::<String>("tcp_flags");
335        let min_len = th.optional::<u32>("min_len");
336        let max_len = th.optional::<u32>("max_len");
337        let unidirectional = th.optional::<bool>("unidirectional").unwrap_or(false);
338        th.finalize(None)?;
339        Ok(FilterRuleConfig {
340            op,
341            proto,
342            src_ip,
343            dst_ip,
344            ip,
345            src_port,
346            dst_port,
347            port,
348            flow_id,
349            from,
350            to,
351            tcp_flags,
352            min_len,
353            max_len,
354            unidirectional,
355        })
356    }
357}
358
359impl<'de> Deserialize<'de> for ProtocolTruncationConfig {
360    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
361        let mut th = TableHelper::new(value)?;
362        let proto = th.required::<String>("proto")?;
363        let max_payload_bytes = th.required::<u32>("max_payload_bytes")?;
364        th.finalize(None)?;
365        Ok(ProtocolTruncationConfig {
366            proto,
367            max_payload_bytes,
368        })
369    }
370}
371
372impl<'de> Deserialize<'de> for TransformConfig {
373    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
374        let mut th = TableHelper::new(value)?;
375        let max_payload_bytes = th.optional::<u32>("max_payload_bytes");
376        let timestamp_start = th.optional::<String>("timestamp_start");
377        let replace_ip = th.optional::<Vec<String>>("replace_ip").unwrap_or_default();
378        let truncate_by_proto = th
379            .optional::<Vec<ProtocolTruncationConfig>>("truncate_by_proto")
380            .unwrap_or_default();
381        th.finalize(None)?;
382        Ok(TransformConfig {
383            max_payload_bytes,
384            timestamp_start,
385            replace_ip,
386            truncate_by_proto,
387        })
388    }
389}
390
391impl<'de> Deserialize<'de> for ExportOutputConfig {
392    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
393        let mut th = TableHelper::new(value)?;
394        let path = th.required::<String>("path").map(PathBuf::from)?;
395        let format = th.optional::<String>("format");
396        let compress_payload = th.optional::<bool>("compress_payload").unwrap_or(false);
397        th.finalize(None)?;
398        Ok(ExportOutputConfig {
399            path,
400            format,
401            compress_payload,
402        })
403    }
404}
405
406impl<'de> Deserialize<'de> for ExportConfig {
407    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
408        let mut th = TableHelper::new(value)?;
409        let outputs = th
410            .optional::<Vec<ExportOutputConfig>>("outputs")
411            .unwrap_or_default();
412        let path = th.optional::<String>("path").map(PathBuf::from);
413        let format = th.optional::<String>("format");
414        let compress_payload = th.optional::<bool>("compress_payload").unwrap_or(false);
415        let unidirectional = th.optional::<bool>("unidirectional").unwrap_or(false);
416        th.finalize(None)?;
417        Ok(ExportConfig {
418            outputs,
419            path,
420            format,
421            compress_payload,
422            unidirectional,
423        })
424    }
425}
426
427impl<'de> Deserialize<'de> for OutputConfig {
428    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
429        let mut th = TableHelper::new(value)?;
430        let format = th.required::<String>("format")?;
431        let path = th.required::<String>("path").map(PathBuf::from)?;
432        let compress_payload = th.optional::<bool>("compress_payload").unwrap_or(false);
433        th.finalize(None)?;
434        Ok(OutputConfig {
435            format,
436            path,
437            compress_payload,
438        })
439    }
440}
441
442impl<'de> Deserialize<'de> for ReplayConfig {
443    fn deserialize(value: &mut Value<'de>) -> Result<Self, DeserError> {
444        let mut th = TableHelper::new(value)?;
445        // Accept `interfaces = ["eth0", "eth1"]` (preferred) or legacy `interface = "eth0"`.
446        let mut interfaces = th.optional::<Vec<String>>("interfaces").unwrap_or_default();
447        if interfaces.is_empty() {
448            if let Some(single) = th.optional::<String>("interface") {
449                interfaces.push(single);
450            }
451        } else {
452            // consume the legacy key so finalize doesn't error on unknown keys
453            let _ = th.optional::<String>("interface");
454        }
455        let speed = th.optional::<f64>("speed").unwrap_or(1.0);
456        let pps = th.optional::<u64>("pps");
457        th.finalize(None)?;
458        Ok(ReplayConfig {
459            interfaces,
460            speed,
461            pps,
462        })
463    }
464}
465
466// ── File loading ─────────────────────────────────────────────────────────────
467
468impl Config {
469    /// Load configuration from a TOML file at `path`.
470    ///
471    /// # Errors
472    /// Returns [`ConfigError`] on I/O failure or TOML parse / deserialise error.
473    pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
474        let text = std::fs::read_to_string(path)?;
475        let mut value = toml_span::parse(&text)?;
476        let config = Config::deserialize(&mut value)?;
477        Ok(config)
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_default_config_is_valid() {
487        let config = Config::default();
488        assert!(config.input.is_empty());
489        assert!(!config.sort.enabled);
490        assert!(config.filter.proto.is_empty());
491        assert!(!config.filter.unidirectional);
492        assert_eq!(config.replay.speed, 1.0);
493    }
494
495    #[test]
496    fn test_from_toml_str_parses_correctly() {
497        let toml = r#"
498[[input]]
499path = "captures/*.pcap"
500
501[sort]
502enabled = true
503slice = "1h"
504
505[filter]
506proto = ["tcp", "udp"]
507dst_port = ["443", "80"]
508src_ip = ["10.0.0.0/8"]
509
510[[output]]
511format = "parquet"
512path = "out/traffic.parquet"
513
514[replay]
515interface = "eth0"
516speed = 2.0
517"#;
518        let mut value = toml_span::parse(toml).unwrap();
519        let config = Config::deserialize(&mut value).unwrap();
520
521        assert_eq!(config.input.len(), 1);
522        assert_eq!(config.input[0].path, "captures/*.pcap");
523        assert!(config.sort.enabled);
524        assert_eq!(config.sort.slice.as_deref(), Some("1h"));
525        assert_eq!(config.filter.proto, ["tcp", "udp"]);
526        assert_eq!(config.filter.dst_port, ["443", "80"]);
527        assert_eq!(config.output.len(), 1);
528        assert_eq!(config.output[0].format, "parquet");
529        assert_eq!(config.replay.speed, 2.0);
530        assert_eq!(config.replay.interfaces, ["eth0"]);
531    }
532
533    #[test]
534    fn test_filter_rules_toml() {
535        let toml = r#"
536[filter]
537proto = ["tcp"]
538
539[[filter.rules]]
540op = "or"
541proto = ["udp"]
542
543[[filter.rules]]
544op = "not"
545dst_ip = ["10.0.0.0/8"]
546"#;
547        let mut value = toml_span::parse(toml).unwrap();
548        let config = Config::deserialize(&mut value).unwrap();
549        assert_eq!(config.filter.proto, ["tcp"]);
550        assert_eq!(config.filter.rules.len(), 2);
551        assert_eq!(config.filter.rules[0].op, "or");
552        assert_eq!(config.filter.rules[0].proto, ["udp"]);
553        assert_eq!(config.filter.rules[1].op, "not");
554        assert_eq!(config.filter.rules[1].dst_ip, ["10.0.0.0/8"]);
555    }
556
557    #[test]
558    fn test_filter_negate_toml() {
559        let toml = r#"
560[filter]
561negate = true
562proto = ["tcp"]
563"#;
564        let mut value = toml_span::parse(toml).unwrap();
565        let config = Config::deserialize(&mut value).unwrap();
566        assert!(config.filter.negate);
567        assert_eq!(config.filter.proto, ["tcp"]);
568    }
569
570    #[test]
571    fn test_empty_toml_produces_default_config() {
572        let mut value = toml_span::parse("").unwrap();
573        let config = Config::deserialize(&mut value).unwrap();
574        assert!(config.input.is_empty());
575        assert!(!config.sort.enabled);
576    }
577
578    #[test]
579    fn test_unknown_keys_error() {
580        let toml = r#"
581[sort]
582enabled = true
583bogus_key = "oops"
584"#;
585        let mut value = toml_span::parse(toml).unwrap();
586        let result = Config::deserialize(&mut value);
587        assert!(result.is_err());
588    }
589
590    #[test]
591    fn test_transform_config_global_truncation() {
592        let toml = r#"
593[transform]
594max_payload_bytes = 256
595timestamp_start = "2024-01-01T00:00:00Z"
596replace_ip = ["10.0.0.1=192.168.1.1"]
597"#;
598        let mut value = toml_span::parse(toml).unwrap();
599        let config = Config::deserialize(&mut value).unwrap();
600        assert_eq!(config.transform.max_payload_bytes, Some(256));
601        assert_eq!(
602            config.transform.timestamp_start.as_deref(),
603            Some("2024-01-01T00:00:00Z")
604        );
605        assert_eq!(config.transform.replace_ip, ["10.0.0.1=192.168.1.1"]);
606        assert!(config.transform.truncate_by_proto.is_empty());
607    }
608
609    #[test]
610    fn test_transform_config_per_proto_truncation() {
611        let toml = r#"
612[transform]
613max_payload_bytes = 512
614
615[[transform.truncate_by_proto]]
616proto = "tcp"
617max_payload_bytes = 128
618
619[[transform.truncate_by_proto]]
620proto = "udp"
621max_payload_bytes = 64
622"#;
623        let mut value = toml_span::parse(toml).unwrap();
624        let config = Config::deserialize(&mut value).unwrap();
625        assert_eq!(config.transform.max_payload_bytes, Some(512));
626        assert_eq!(config.transform.truncate_by_proto.len(), 2);
627        assert_eq!(config.transform.truncate_by_proto[0].proto, "tcp");
628        assert_eq!(config.transform.truncate_by_proto[0].max_payload_bytes, 128);
629        assert_eq!(config.transform.truncate_by_proto[1].proto, "udp");
630        assert_eq!(config.transform.truncate_by_proto[1].max_payload_bytes, 64);
631    }
632
633    #[test]
634    fn test_transform_config_proto_only_no_global() {
635        let toml = r#"
636[[transform.truncate_by_proto]]
637proto = "17"
638max_payload_bytes = 32
639"#;
640        let mut value = toml_span::parse(toml).unwrap();
641        let config = Config::deserialize(&mut value).unwrap();
642        assert!(config.transform.max_payload_bytes.is_none());
643        assert_eq!(config.transform.truncate_by_proto.len(), 1);
644        assert_eq!(config.transform.truncate_by_proto[0].proto, "17");
645        assert_eq!(config.transform.truncate_by_proto[0].max_payload_bytes, 32);
646    }
647
648    #[test]
649    fn test_empty_transform_section() {
650        let toml = r#"
651[transform]
652"#;
653        let mut value = toml_span::parse(toml).unwrap();
654        let config = Config::deserialize(&mut value).unwrap();
655        assert!(config.transform.max_payload_bytes.is_none());
656        assert!(config.transform.truncate_by_proto.is_empty());
657    }
658}