use std::io::{self, Write};
use std::path::Path;
use clap::ValueEnum;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
Jsonl,
Csv,
Grep,
}
impl OutputFormat {
pub fn is_machine(self) -> bool {
!matches!(self, OutputFormat::Text)
}
}
#[derive(Debug, Serialize)]
pub struct ScanRecord {
pub ip: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub proto: &'static str,
pub scan: &'static str,
pub latency_ms: u128,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub banner: Option<String>,
}
pub fn render(records: &[ScanRecord], format: OutputFormat) -> String {
match format {
OutputFormat::Text => String::new(),
OutputFormat::Json => {
format!("{}\n", serde_json::to_string(records).unwrap())
}
OutputFormat::Jsonl => {
let mut out = String::new();
for record in records {
out.push_str(&serde_json::to_string(record).unwrap());
out.push('\n');
}
out
}
OutputFormat::Csv => render_csv(records),
OutputFormat::Grep => render_grep(records),
}
}
fn render_csv(records: &[ScanRecord]) -> String {
let mut out = String::from("ip,port,proto,scan,status,latency_ms\n");
for r in records {
let port = r.port.map(|p| p.to_string()).unwrap_or_default();
out.push_str(&format!(
"{},{},{},{},{},{}\n",
r.ip, port, r.proto, r.scan, r.status, r.latency_ms
));
}
out
}
fn render_grep(records: &[ScanRecord]) -> String {
let mut out = String::new();
for r in records {
let port = r
.port
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string());
out.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\n",
r.ip, port, r.proto, r.scan, r.status, r.latency_ms
));
}
out
}
pub fn emit(records: &[ScanRecord], format: OutputFormat, path: Option<&Path>) -> io::Result<()> {
if !format.is_machine() {
return Ok(());
}
let rendered = render(records, format);
match path {
Some(path) => std::fs::write(path, rendered),
None => io::stdout().write_all(rendered.as_bytes()),
}
}
pub fn print_json(records: &[ScanRecord]) {
print!("{}", render(records, OutputFormat::Json));
}
pub fn print_jsonl(records: &[ScanRecord]) {
print!("{}", render(records, OutputFormat::Jsonl));
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<ScanRecord> {
vec![
ScanRecord {
ip: "127.0.0.1".to_string(),
port: Some(80),
proto: "tcp",
scan: "connect",
latency_ms: 3,
status: "open",
service: None,
banner: None,
},
ScanRecord {
ip: "10.0.0.5".to_string(),
port: None,
proto: "tcp",
scan: "connect",
latency_ms: 12,
status: "up",
service: None,
banner: None,
},
]
}
#[test]
fn csv_has_header_and_blank_port_for_host_records() {
let out = render(&sample(), OutputFormat::Csv);
let mut lines = out.lines();
assert_eq!(
lines.next().unwrap(),
"ip,port,proto,scan,status,latency_ms"
);
assert_eq!(lines.next().unwrap(), "127.0.0.1,80,tcp,connect,open,3");
assert_eq!(lines.next().unwrap(), "10.0.0.5,,tcp,connect,up,12");
}
#[test]
fn empty_csv_still_has_header() {
assert_eq!(
render(&[], OutputFormat::Csv),
"ip,port,proto,scan,status,latency_ms\n"
);
}
#[test]
fn grep_is_tab_separated_with_dash_for_missing_port() {
let out = render(&sample(), OutputFormat::Grep);
let mut lines = out.lines();
assert_eq!(
lines.next().unwrap(),
"127.0.0.1\t80\ttcp\tconnect\topen\t3"
);
assert_eq!(lines.next().unwrap(), "10.0.0.5\t-\ttcp\tconnect\tup\t12");
}
#[test]
fn json_and_jsonl_shapes_match_expectations() {
assert_eq!(render(&[], OutputFormat::Json), "[]\n");
let jsonl = render(&sample(), OutputFormat::Jsonl);
assert_eq!(jsonl.lines().count(), 2);
assert!(
jsonl
.lines()
.all(|l| l.starts_with('{') && l.ends_with('}'))
);
}
#[test]
fn emit_writes_machine_output_to_a_file() {
let dir = std::env::temp_dir();
let path = dir.join(format!("asphyxia-emit-test-{}.csv", std::process::id()));
emit(&sample(), OutputFormat::Csv, Some(&path)).expect("write should succeed");
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.starts_with("ip,port,proto,scan,status,latency_ms\n"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn ack_records_carry_their_mode_and_firewall_state() {
let records = vec![
ScanRecord {
ip: "192.0.2.10".to_string(),
port: Some(22),
proto: "tcp",
scan: "ack",
latency_ms: 4,
status: "unfiltered",
service: None,
banner: None,
},
ScanRecord {
ip: "192.0.2.10".to_string(),
port: Some(23),
proto: "tcp",
scan: "ack",
latency_ms: 2000,
status: "filtered",
service: None,
banner: None,
},
];
let csv = render(&records, OutputFormat::Csv);
assert!(csv.contains("192.0.2.10,22,tcp,ack,unfiltered,4\n"));
assert!(csv.contains("192.0.2.10,23,tcp,ack,filtered,2000\n"));
let json = render(&records, OutputFormat::Json);
assert!(json.contains("\"scan\":\"ack\""));
assert!(json.contains("\"status\":\"unfiltered\""));
assert!(!json.contains("\"status\":\"open\""));
let grep = render(&records, OutputFormat::Grep);
assert!(grep.contains("192.0.2.10\t22\ttcp\tack\tunfiltered\t4\n"));
}
#[test]
fn emit_text_is_a_noop() {
let dir = std::env::temp_dir();
let path = dir.join(format!("asphyxia-emit-text-{}.txt", std::process::id()));
emit(&sample(), OutputFormat::Text, Some(&path)).expect("no-op should succeed");
assert!(!path.exists());
}
}