use clap::builder::PathBufValueParser;
use clap::{Arg, Command, ValueHint};
use frameassembly::backend::{compile_program, generate_pcap};
use frameassembly::parser::parse_program;
use frameassembly::reverse::reverse_pcap_file;
use miette::{IntoDiagnostic, NamedSource, Result};
use std::fmt::Write as _;
use std::fs;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
fn derive_default_output_path(input: &Path, default_ext: &str) -> PathBuf {
let has_same_ext = input
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(default_ext));
if has_same_ext {
let mut os = input.as_os_str().to_os_string();
os.push(".");
os.push(default_ext);
PathBuf::from(os)
} else {
let candidate = input.with_extension(default_ext);
if candidate == input {
let mut os = input.as_os_str().to_os_string();
os.push(".");
os.push(default_ext);
PathBuf::from(os)
} else {
candidate
}
}
}
fn build_cli() -> Command {
Command::new("frameassembly")
.version(env!("CARGO_PKG_VERSION"))
.about("Domain specific language for writing and compiling custom .pcap/.pcapng")
.args_conflicts_with_subcommands(true)
.subcommand(
Command::new("show")
.about("Validate a .fasm file and print a terminal packet ladder table without writing a .pcap file")
.arg(
Arg::new("input")
.help("Path to the .fasm source file")
.required(true)
.index(1)
.value_hint(ValueHint::FilePath)
.value_parser(PathBufValueParser::new()),
),
)
.subcommand(
Command::new("reverse")
.about("Decompile a .pcap or .pcapng capture file into canonical .fasm code")
.arg(
Arg::new("input")
.help("Path to the input .pcap or .pcapng capture file")
.required(true)
.index(1)
.value_hint(ValueHint::FilePath)
.value_parser(PathBufValueParser::new()),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.help("Path to the output .fasm file (defaults to <input_stem>.fasm)")
.value_hint(ValueHint::FilePath)
.value_parser(PathBufValueParser::new()),
),
)
.arg(
Arg::new("input")
.help("Path to the .fasm source file")
.required(true)
.index(1)
.value_hint(ValueHint::FilePath)
.value_parser(PathBufValueParser::new()),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.help("Path to the output PCAP file (defaults to <input_stem>.pcap)")
.value_hint(ValueHint::FilePath)
.value_parser(PathBufValueParser::new()),
)
}
fn port_to_l7_name(port: u16, is_tcp: bool) -> Option<&'static str> {
match (port, is_tcp) {
(21, true) => Some("FTP"),
(22, true) => Some("SSH"),
(23, true) => Some("TELNET"),
(25 | 587, true) => Some("SMTP"),
(43, true) => Some("WHOIS"),
(49, true) => Some("TACACS"),
(53, _) => Some("DNS"),
(67 | 68 | 546 | 547, false) => Some("DHCP"),
(69, false) => Some("TFTP"),
(80, true) => Some("HTTP"),
(88, _) => Some("KERBEROS"),
(102, true) => Some("S7COMM"),
(110, true) => Some("POP3"),
(123, false) => Some("NTP"),
(143, true) => Some("IMAP"),
(161 | 162, false) => Some("SNMP"),
(179, true) => Some("BGP"),
(319 | 320, false) => Some("PTP"),
(389, _) => Some("LDAP"),
(443, true) => Some("TLS"),
(443, false) => Some("QUIC"),
(445, true) => Some("SMB"),
(500 | 4500, false) => Some("IKEV2"),
(502, true) => Some("MODBUS"),
(514, false) => Some("SYSLOG"),
(520 | 521, false) => Some("RIP"),
(554, true) => Some("RTSP"),
(1812 | 1813, false) => Some("RADIUS"),
(1883, true) => Some("MQTT"),
(1900, false) => Some("SSDP"),
(1985, false) => Some("HSRP"),
(2049, _) => Some("NFS"),
(2055, false) => Some("NETFLOW"),
(3306, true) => Some("MYSQL"),
(3478, false) => Some("STUN"),
(3784, false) => Some("BFD"),
(4739, false) => Some("IPFIX"),
(4789, false) => Some("VXLAN"),
(4840, true) => Some("OPCUA"),
(5004, false) => Some("RTP"),
(5060, _) => Some("SIP"),
(5353, false) => Some("MDNS"),
(5432, true) => Some("POSTGRES"),
(5672, true) => Some("AMQP"),
(5683, false) => Some("COAP"),
(6081, false) => Some("GENEVE"),
(6343, false) => Some("SFLOW"),
(6379, true) => Some("REDIS"),
(6653, true) => Some("OPENFLOW"),
(11211, _) => Some("MEMCACHED"),
(20000, true) => Some("DNP3"),
(44818, true) => Some("ETHERNETIP"),
(47808, false) => Some("BACNET"),
(50051, true) => Some("GRPC"),
(51820, false) => Some("WIREGUARD"),
_ => None,
}
}
fn format_mac(b: &[u8]) -> String {
if b.len() < 6 {
return "00:00:00:00:00:00".to_string();
}
format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
b[0], b[1], b[2], b[3], b[4], b[5]
)
}
fn summarize_frame(data: &[u8]) -> (String, String, String) {
if data.len() < 14 {
return (
"unknown -> unknown".to_string(),
"RAW".to_string(),
format!("{} bytes", data.len()),
);
}
let dst_mac = format_mac(&data[0..6]);
let src_mac = format_mac(&data[6..12]);
let mut etype = u16::from_be_bytes([data[12], data[13]]);
let mut offset = 14usize;
let mut l2_tags = Vec::new();
while (etype == 0x8100 || etype == 0x88A8 || etype == 0x9100) && offset + 4 <= data.len() {
let tci = u16::from_be_bytes([data[offset], data[offset + 1]]);
let vid = tci & 0x0FFF;
let pcp = (tci >> 13) & 0x07;
if etype == 0x88A8 || etype == 0x9100 {
l2_tags.push(format!("SVLAN={vid}"));
} else if l2_tags.iter().any(|t: &String| t.starts_with("SVLAN=")) {
l2_tags.push(format!("CVLAN={vid}"));
} else if pcp > 0 {
l2_tags.push(format!("VLAN={vid}(PCP={pcp})"));
} else {
l2_tags.push(format!("VLAN={vid}"));
}
etype = u16::from_be_bytes([data[offset + 2], data[offset + 3]]);
offset += 4;
}
if (etype == 0x8847 || etype == 0x8848) && offset + 4 <= data.len() {
while offset + 4 <= data.len() {
let entry = u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]);
let label = entry >> 12;
let bos = (entry >> 8) & 1;
l2_tags.push(format!("MPLS={label}"));
offset += 4;
if bos == 1 {
break;
}
}
if offset < data.len() {
let first_nibble = data[offset] >> 4;
if first_nibble == 4 {
etype = 0x0800;
} else if first_nibble == 6 {
etype = 0x86DD;
}
}
}
let tag_prefix = if l2_tags.is_empty() {
String::new()
} else {
format!("[{}] ", l2_tags.join(" "))
};
match etype {
0x0806 if offset + 28 <= data.len() => {
let oper = u16::from_be_bytes([data[offset + 6], data[offset + 7]]);
let spa = Ipv4Addr::new(
data[offset + 14],
data[offset + 15],
data[offset + 16],
data[offset + 17],
);
let tpa = Ipv4Addr::new(
data[offset + 24],
data[offset + 25],
data[offset + 26],
data[offset + 27],
);
let detail = if oper == 1 {
format!("{tag_prefix}Who has {tpa}? Tell {spa}")
} else {
format!("{tag_prefix}{spa} is at {src_mac}")
};
(format!("{spa} -> {tpa}"), "ARP".to_string(), detail)
}
0x0800 if offset + 20 <= data.len() => {
let ihl = ((data[offset] & 0x0F) as usize) * 4;
let proto = data[offset + 9];
let src_ip = Ipv4Addr::new(
data[offset + 12],
data[offset + 13],
data[offset + 14],
data[offset + 15],
);
let dst_ip = Ipv4Addr::new(
data[offset + 16],
data[offset + 17],
data[offset + 18],
data[offset + 19],
);
let l4_slice = if offset + ihl <= data.len() {
&data[offset + ihl..]
} else {
&[]
};
let (p_label, summary) = summarize_l4(proto, l4_slice, &tag_prefix);
(format!("{src_ip} -> {dst_ip}"), p_label, summary)
}
0x86DD if offset + 40 <= data.len() => {
let mut next_hdr = data[offset + 6];
let mut src_bytes = [0u8; 16];
src_bytes.copy_from_slice(&data[offset + 8..offset + 24]);
let mut dst_bytes = [0u8; 16];
dst_bytes.copy_from_slice(&data[offset + 24..offset + 40]);
let src_ip = Ipv6Addr::from(src_bytes);
let dst_ip = Ipv6Addr::from(dst_bytes);
let mut l4_offset = offset + 40;
if next_hdr == 0 && l4_offset + 8 <= data.len() {
next_hdr = data[l4_offset];
let ext_len = (data[l4_offset + 1] as usize + 1) * 8;
if l4_offset + ext_len <= data.len() {
l4_offset += ext_len;
}
}
let l4_slice = if l4_offset <= data.len() {
&data[l4_offset..]
} else {
&[]
};
let (p_label, summary) = summarize_l4(next_hdr, l4_slice, &tag_prefix);
(format!("{src_ip} -> {dst_ip}"), p_label, summary)
}
0x88CC => (
format!("{src_mac} -> {dst_mac}"),
"LLDP".to_string(),
format!("{tag_prefix}Link Layer Discovery Protocol frame"),
),
0x8809 => (
format!("{src_mac} -> {dst_mac}"),
"LACP".to_string(),
format!("{tag_prefix}Link Aggregation Control Protocol PDU"),
),
0x888E => (
format!("{src_mac} -> {dst_mac}"),
"EAPOL".to_string(),
format!("{tag_prefix}IEEE 802.1X EAP over LAN frame"),
),
_ => {
let proto = if dst_mac == "01:80:c2:00:00:00" {
"STP"
} else if l2_tags.iter().any(|t| t.starts_with("SVLAN=")) {
"QINQ"
} else if l2_tags.iter().any(|t| t.starts_with("VLAN=")) {
"VLAN"
} else if l2_tags.iter().any(|t| t.starts_with("MPLS=")) {
"MPLS"
} else {
"L2"
};
(
format!("{src_mac} -> {dst_mac}"),
proto.to_string(),
format!("{tag_prefix}EtherType 0x{etype:04x}"),
)
}
}
}
fn summarize_l4(proto: u8, l4: &[u8], tag_prefix: &str) -> (String, String) {
match proto {
6 if l4.len() >= 20 => {
let sport = u16::from_be_bytes([l4[0], l4[1]]);
let dport = u16::from_be_bytes([l4[2], l4[3]]);
let seq = u32::from_be_bytes([l4[4], l4[5], l4[6], l4[7]]);
let ack = u32::from_be_bytes([l4[8], l4[9], l4[10], l4[11]]);
let hdr_len = ((l4[12] >> 4) as usize) * 4;
let flags = l4[13];
let payload_len = l4.len().saturating_sub(hdr_len);
let mut flag_names = Vec::new();
for (bit, name) in [
(0x02, "SYN"),
(0x10, "ACK"),
(0x08, "PSH"),
(0x04, "RST"),
(0x01, "FIN"),
(0x20, "URG"),
(0x40, "ECE"),
(0x80, "CWR"),
] {
if flags & bit != 0 {
flag_names.push(name);
}
}
let flags_str = if flag_names.is_empty() {
"NONE".to_string()
} else {
flag_names.join(" ")
};
let l7_proto = if payload_len > 0 {
port_to_l7_name(dport, true).or_else(|| port_to_l7_name(sport, true))
} else {
None
};
let label = l7_proto.unwrap_or("TCP").to_string();
let ack_part = if flags & 0x10 != 0 {
format!(" ack={ack}")
} else {
String::new()
};
(
label,
format!(
"{tag_prefix}{sport} -> {dport} [{flags_str}] seq={seq}{ack_part} len={payload_len}"
),
)
}
17 if l4.len() >= 8 => {
let sport = u16::from_be_bytes([l4[0], l4[1]]);
let dport = u16::from_be_bytes([l4[2], l4[3]]);
let payload_len = l4.len().saturating_sub(8);
let l7_proto = port_to_l7_name(dport, false).or_else(|| port_to_l7_name(sport, false));
let label = l7_proto.unwrap_or("UDP").to_string();
(
label,
format!("{tag_prefix}{sport} -> {dport} len={payload_len}"),
)
}
1 if l4.len() >= 4 => {
let icmp_type = l4[0];
let icmp_code = l4[1];
let desc = match icmp_type {
8 => "Echo Request",
0 => "Echo Reply",
3 => "Destination Unreachable",
5 => "Redirect",
11 => "Time Exceeded",
_ => "Control Message",
};
(
"ICMP".to_string(),
format!("{tag_prefix}{desc} (type={icmp_type} code={icmp_code})"),
)
}
58 if l4.len() >= 4 => {
let icmp6_type = l4[0];
let (label, desc) = match icmp6_type {
128 => ("ICMPv6", "Echo Request"),
129 => ("ICMPv6", "Echo Reply"),
130..=132 | 143 => ("MLD", "Multicast Listener Discovery"),
133..=137 => ("NDP", "Neighbor Discovery Protocol"),
_ => ("ICMPv6", "Control Message"),
};
(
label.to_string(),
format!("{tag_prefix}{desc} (type={icmp6_type})"),
)
}
2 => (
"IGMP".to_string(),
format!("{tag_prefix}Multicast Membership Message"),
),
47 => ("GRE".to_string(), format!("{tag_prefix}GRE Tunnel Packet")),
50 => (
"ESP".to_string(),
format!("{tag_prefix}IPsec Encapsulating Security Payload"),
),
51 => (
"AH".to_string(),
format!("{tag_prefix}IPsec Authentication Header"),
),
89 => (
"OSPF".to_string(),
format!("{tag_prefix}Open Shortest Path First"),
),
103 => (
"PIM".to_string(),
format!("{tag_prefix}Protocol Independent Multicast"),
),
112 => (
"VRRP".to_string(),
format!("{tag_prefix}Virtual Router Redundancy Protocol"),
),
132 if l4.len() >= 12 => {
let sport = u16::from_be_bytes([l4[0], l4[1]]);
let dport = u16::from_be_bytes([l4[2], l4[3]]);
(
"SCTP".to_string(),
format!("{tag_prefix}{sport} -> {dport}"),
)
}
other => (
format!("IP({other})"),
format!("{tag_prefix}Protocol {other} ({} bytes)", l4.len()),
),
}
}
fn format_packet_ladder(frames: &[(Vec<u8>, u64)]) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"{:<5} {:<10} {:<39} {:<10} {:<5} Summary",
"#", "Time (ms)", "Source -> Destination", "Proto", "Bytes"
);
let _ = writeln!(out, "{}", "-".repeat(110));
let base_ts = frames.first().map(|(_, ts)| *ts).unwrap_or(0);
for (idx, (data, timestamp_ns)) in frames.iter().enumerate() {
let time_ms = timestamp_ns.saturating_sub(base_ts) as f64 / 1_000_000.0;
let (endpoints, proto, summary) = summarize_frame(data);
let _ = writeln!(
out,
"{:<5} {:<10.3} {:<39} {:<10} {:<5} {}",
idx + 1,
time_ms,
endpoints,
proto,
data.len(),
summary
);
}
out
}
fn main() -> Result<()> {
let matches = build_cli().get_matches();
if let Some(rev_matches) = matches.subcommand_matches("reverse") {
let input_path = rev_matches
.get_one::<PathBuf>("input")
.ok_or_else(|| miette::miette!("Missing input .pcap / .pcapng file"))?;
let output_path = rev_matches
.get_one::<PathBuf>("output")
.cloned()
.unwrap_or_else(|| derive_default_output_path(input_path, "fasm"));
let res = reverse_pcap_file(input_path)
.map_err(|e| miette::miette!("Reverse decompilation error: {}", e))?;
fs::write(&output_path, &res.fasm).into_diagnostic()?;
println!(
"Successfully reversed {} into {} ({} hosts, {} packets)",
input_path.display(),
output_path.display(),
res.host_count,
res.packet_count
);
return Ok(());
}
let (file_path, show_only, output_pcap) =
if let Some(show_matches) = matches.subcommand_matches("show") {
let input_file = show_matches
.get_one::<PathBuf>("input")
.ok_or_else(|| miette::miette!("Missing input .fasm file"))?;
(input_file.clone(), true, PathBuf::new())
} else {
let input_file = matches
.get_one::<PathBuf>("input")
.ok_or_else(|| miette::miette!("Missing input .fasm file"))?;
let out_path = matches
.get_one::<PathBuf>("output")
.cloned()
.unwrap_or_else(|| derive_default_output_path(input_file, "pcap"));
(input_file.clone(), false, out_path)
};
let file_display = file_path.display().to_string();
let code = fs::read_to_string(&file_path).into_diagnostic()?;
let program = parse_program(&code).map_err(|e| {
miette::Report::new(e)
.with_source_code(NamedSource::new(file_display.clone(), code.clone()))
})?;
let frames = compile_program(&program).map_err(|e| miette::miette!("Backend error: {}", e))?;
if show_only {
print!("{}", format_packet_ladder(&frames));
return Ok(());
}
println!(
"Successfully parsed {} hosts, {} flows",
program.hosts.len(),
program.flows.len(),
);
let total_bytes: usize = frames.iter().map(|(data, _)| data.len()).sum();
let first_ts = frames.first().map(|(_, ts)| *ts).unwrap_or(0);
let duration_ms = frames
.last()
.map(|(_, ts_ns)| ts_ns.saturating_sub(first_ts) as f64 / 1_000_000.0)
.unwrap_or(0.0);
let output_pcap_str = output_pcap.to_string_lossy();
println!("Starting PCAP compilation...");
generate_pcap(&program, &output_pcap_str)
.map_err(|e| miette::miette!("Backend error: {}", e))?;
println!(
"Successfully generated {} ({} frames, {} bytes, duration {:.3} ms)",
output_pcap_str,
frames.len(),
total_bytes,
duration_ms
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_cli_invariants() {
build_cli().debug_assert();
}
#[test]
fn test_derive_default_output_path() {
assert_eq!(
derive_default_output_path(Path::new("example.fasm"), "pcap"),
PathBuf::from("example.pcap")
);
assert_eq!(
derive_default_output_path(Path::new("dir/test.fasm"), "pcap"),
PathBuf::from("dir/test.pcap")
);
assert_eq!(
derive_default_output_path(Path::new("already.pcap"), "pcap"),
PathBuf::from("already.pcap.pcap")
);
assert_eq!(
derive_default_output_path(Path::new("example.pcap"), "fasm"),
PathBuf::from("example.fasm")
);
assert_eq!(
derive_default_output_path(Path::new("traces/cap.pcapng"), "fasm"),
PathBuf::from("traces/cap.fasm")
);
assert_eq!(
derive_default_output_path(Path::new("already.fasm"), "fasm"),
PathBuf::from("already.fasm.fasm")
);
}
#[test]
fn test_cli_compile_show_and_reverse_subcommands() {
let compile_matches = build_cli()
.try_get_matches_from(["frameassembly", "demo.fasm", "-o", "custom.pcap"])
.expect("compile CLI arguments should parse");
assert!(compile_matches.subcommand_matches("show").is_none());
assert_eq!(
compile_matches.get_one::<PathBuf>("input"),
Some(&PathBuf::from("demo.fasm"))
);
assert_eq!(
compile_matches.get_one::<PathBuf>("output"),
Some(&PathBuf::from("custom.pcap"))
);
let show_matches = build_cli()
.try_get_matches_from(["frameassembly", "show", "demo.fasm"])
.expect("show subcommand should parse");
let show_sub = show_matches
.subcommand_matches("show")
.expect("show subcommand should be matched");
assert_eq!(
show_sub.get_one::<PathBuf>("input"),
Some(&PathBuf::from("demo.fasm"))
);
let rev_matches = build_cli()
.try_get_matches_from([
"frameassembly",
"reverse",
"traces/cap.pcapng",
"-o",
"traces/cap.fasm",
])
.expect("reverse subcommand should parse");
let rev_sub = rev_matches
.subcommand_matches("reverse")
.expect("reverse subcommand should be matched");
assert_eq!(
rev_sub.get_one::<PathBuf>("input"),
Some(&PathBuf::from("traces/cap.pcapng"))
);
assert_eq!(
rev_sub.get_one::<PathBuf>("output"),
Some(&PathBuf::from("traces/cap.fasm"))
);
let sample = "HOST a { IP 10.0.0.1 }\nHOST b { IP 10.0.0.2 }\nFLOW f(c, s) {\n c -> s TCP SRCPORT 50000 DSTPORT 443 SYN\n c <- s TCP SYN ACK WAIT 5ms\n}\nCOMPILE { f(a, b) }";
let program = parse_program(sample).expect("sample should parse");
let frames = compile_program(&program).expect("sample should compile");
let ladder = format_packet_ladder(&frames);
assert!(ladder.contains("Source -> Destination"));
assert!(ladder.contains("0.000"));
assert!(ladder.contains("5.000"));
}
}