diff --git a/README.md b/README.md index a77eac1..53a4610 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ dpp --report-format json input.pcap output.csv > dpp-summary.json | --- | --- | | `-s, --silent` | Suppress info-level log output | | `-f, --format ` | Select output format; stdout output is supported only for `csv` | -| `--report-format ` | Select the final process report format; defaults to `text`; `json` cannot be combined with `output_filename = -` | +| `--report-format ` | Select the final process report format; defaults to `text`; `json` cannot be combined with `output_filename = -` or `--silent` | | `--match-timeout-ms ` | Set the DNS query-response match timeout in milliseconds; allowed range is `1..=5000`, default is `1200` | | `--monotonic-capture` | Assume globally monotonic packet timestamps, enable batched timeout eviction, and abort if a regression is detected | | `-t, --threads ` | Cap the CPU execution budget at a positive `N`, never above available CPUs | @@ -171,7 +171,7 @@ provide supported per-core thread pinning through the affinity backend. | `DPP_FILENAME` | Input PCAP path. Use `-` to read a finite offline PCAP stream from `stdin` | | `DPP_OUTPUT_FILENAME` | Optional output file path; use `-` for CSV stdout output | | `DPP_FORMAT` | Output format: `csv`, `parquet`, or `pq` | -| `DPP_REPORT_FORMAT` | Final process report format: `text` or `json`; defaults to `text`; `json` cannot be combined with `DPP_OUTPUT_FILENAME=-` | +| `DPP_REPORT_FORMAT` | Final process report format: `text` or `json`; defaults to `text`; `json` cannot be combined with `DPP_OUTPUT_FILENAME=-` or `DPP_SILENT` | | `DPP_MATCH_TIMEOUT_MS` | DNS query-response match timeout in milliseconds; allowed range is `1..=5000`, default is `1200` | | `DPP_MONOTONIC_CAPTURE` | Assume globally monotonic packet timestamps, enable batched timeout eviction, and abort if a regression is detected | | `DPP_THREADS` | Maximum CPU execution budget when `--threads` is not set; must be a positive integer | @@ -353,6 +353,8 @@ type/code values rather than collapsing them to an `Unknown` label. `--report-format json` or `DPP_REPORT_FORMAT=json` suppresses routine `info`/`warn` reporting and writes one final summary object to stdout. CSV or Parquet records remain in their own output file. +JSON mode rejects `--silent` and `DPP_SILENT`: silent runs skip peak-RSS sampling, so +`metrics.max_memory_usage_kib` would not be a measured value. The summary includes these processing and matching-quality metrics: diff --git a/docs/architecture.md b/docs/architecture.md index 4908e53..05dadc8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -243,6 +243,8 @@ authoritative processing counters. Oversized-QNAME rejections appear as Final JSON reporting uses the CLI/config report-format contract. Runtime logging remains human-readable. Stdout export is CSV-only, rejects JSON reports, and suppresses text reports. +Explicit silent mode (`--silent` or `DPP_SILENT`) also rejects JSON reports, because silent runs do +not start the RSS monitor that measures `metrics.max_memory_usage_kib`. If stderr itself is a closed pipe, the logger suppresses broken-pipe noise. ### Completion and failure handling diff --git a/src/cli.rs b/src/cli.rs index 148e8ad..b8d78e1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,10 +87,12 @@ pub(crate) fn parse_args() -> Result { validate_distinct_input_output(&input_source, &output_filename)?; validate_distinct_key_output(anonymize.as_deref(), &output_filename)?; validate_output_mode(output_target, format, report_format)?; + let silent_requested = matches.get_flag("silent") || env_silent; + validate_silent_report_format(silent_requested, report_format)?; let zstd = matches.get_flag("zstd") || env_zstd; let v2 = matches.get_flag("v2") || env_v2; - let silent = resolve_silent_mode(&matches, env_silent, output_target); + let silent = resolve_silent_mode(silent_requested, output_target); let affinity = matches.get_flag("affinity") || env_affinity; let dns_wire_fast_path = matches.get_flag("dns_wire_fast_path") || env_dns_wire_fast_path; let (allow_fragments, full_fragments) = @@ -150,7 +152,7 @@ fn build_cli(version: &'static str) -> Command { DPP_MONOTONIC_CAPTURE Set to 'true' to assume globally monotonic packet timestamps, enable batched timeout eviction, and abort on timestamp regressions DPP_THREADS Maximum CPU execution budget (positive integer, capped to available CPUs; used if --threads is not specified) - DPP_REPORT_FORMAT Final process report format: text or json (used if --report-format is not specified; json cannot be combined with stdout output) + DPP_REPORT_FORMAT Final process report format: text or json (used if --report-format is not specified; json cannot be combined with stdout output or silent mode) DPP_MATCH_TIMEOUT_MS DNS match timeout in milliseconds; allowed range is 1..=5000, default is 1200 DPP_BONDED=N Set IO channel capacity in records; internally rounded up to batched messages of up to 1024 records; 0 uses the safe default bounded capacity DPP_FORMAT Output format: csv, parquet, or pq (used if --format option is not specified) @@ -194,7 +196,7 @@ LICENSE INFORMATION: .arg( Arg::new("report_format") .long("report-format") - .help("Final process report format: text or json; json cannot be combined with stdout output") + .help("Final process report format: text or json; json cannot be combined with stdout output or --silent") .value_name("text|json") .num_args(1) .value_parser(["text", "json"]), @@ -440,12 +442,8 @@ fn resolve_max_dns_compression_jumps( } } -fn resolve_silent_mode( - matches: &ArgMatches, - env_silent: bool, - output_target: OutputTarget, -) -> bool { - matches.get_flag("silent") || env_silent || matches!(output_target, OutputTarget::Stdout) +fn resolve_silent_mode(silent_requested: bool, output_target: OutputTarget) -> bool { + silent_requested || matches!(output_target, OutputTarget::Stdout) } fn validate_parquet_only_flags(format: OutputFormat, zstd: bool, v2: bool) -> Result<()> { @@ -559,6 +557,18 @@ fn validate_output_mode( Ok(()) } +/// Silent runs skip peak-RSS sampling, so a JSON report could not carry a measured value. +fn validate_silent_report_format( + silent_requested: bool, + report_format: ReportFormat, +) -> Result<()> { + if silent_requested && matches!(report_format, ReportFormat::Json) { + bail!("'--report-format json' cannot be combined with '--silent' (or DPP_SILENT)."); + } + + Ok(()) +} + fn validate_input_source(input_source: &InputSource) -> Result<()> { match input_source { InputSource::Stdin => validate_stdin_input(), @@ -1280,4 +1290,31 @@ mod tests { validate_output_mode(OutputTarget::Stdout, OutputFormat::Csv, ReportFormat::Text) .expect("text report format remains valid for stdout output"); } + + #[test] + fn json_report_format_is_rejected_in_silent_mode() { + let error = validate_silent_report_format(true, ReportFormat::Json) + .expect_err("json report format must be rejected in silent mode"); + + assert!( + error.to_string().contains( + "'--report-format json' cannot be combined with '--silent' (or DPP_SILENT)" + ) + ); + } + + #[test] + fn silent_mode_restricts_only_json_reports() { + validate_silent_report_format(true, ReportFormat::Text) + .expect("text report format remains valid in silent mode"); + validate_silent_report_format(false, ReportFormat::Json) + .expect("json report format remains valid without silent mode"); + } + + #[test] + fn stdout_output_implies_silent_mode() { + assert!(resolve_silent_mode(false, OutputTarget::Stdout)); + assert!(resolve_silent_mode(true, OutputTarget::File)); + assert!(!resolve_silent_mode(false, OutputTarget::File)); + } } diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index e58df70..eed75af 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -136,7 +136,7 @@ fn run_fragment_reuse_capture( fs::write(&input_path, capture).expect("fragment reuse capture written"); let mut command = Command::new(dpp_binary()); command - .args(["-s", "-f", "csv", "--full-fragments", "--threads"]) + .args(["-f", "csv", "--full-fragments", "--threads"]) .arg(threads.to_string()) .args(["--report-format", "json"]) .args(extra_args) @@ -577,7 +577,7 @@ fn full_ipv4_reassembly_ignores_first_fragment_duplicate_after_completion() { .expect("fragmented duplicate test pcap written"); let result = Command::new(dpp_binary()) - .args(["-s", "--full-fragments", "--report-format", "json"]) + .args(["--full-fragments", "--report-format", "json"]) .arg(&input_path) .arg(&output_path) .output() @@ -982,6 +982,51 @@ fn thread_cap_changes_reported_execution_budget() { fs::remove_file(&output_path).expect("remove output csv"); } +#[test] +fn json_report_is_rejected_in_silent_mode() { + let input_path = temp_test_path("silent-json-report", "pcap"); + let output_path = temp_test_path("silent-json-report", "csv"); + fs::write(&input_path, classic_pcap_bytes(&[])).expect("empty pcap written"); + + for (args, env_silent) in [ + (&["-s", "--report-format", "json"][..], None), + (&["--report-format", "json"][..], Some("true")), + ] { + let mut command = Command::new(dpp_binary()); + command + .args(args) + .env_remove("DPP_SILENT") + .env_remove("DPP_REPORT_FORMAT"); + if let Some(value) = env_silent { + command.env("DPP_SILENT", value); + } + let output = command + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed"); + + assert!( + !output.status.success(), + "silent JSON run must be rejected: {args:?}, DPP_SILENT={env_silent:?}" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains( + "'--report-format json' cannot be combined with '--silent' (or DPP_SILENT)" + ), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty(), "no JSON report may be emitted"); + assert!( + !output_path.exists(), + "output must not be created when CLI validation fails" + ); + } + + fs::remove_file(&input_path).expect("remove input pcap"); +} + #[test] fn oversized_wire_qname_is_rejected_and_reported() { let input_path = temp_test_path("oversized-wire-qname", "pcap");