frameassembly 1.5.1

A DSL for creating custom .pcap/.pcapng captures and decompiling captures to .fasm for testing firewalls, IDS rules, SNMP traps, and pentesting.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! Native PCAP and PCAPNG to `.fasm` reverse decompiler for FrameAssembly.
//!
//! This module implements the five-stage decompilation pipeline that translates binary `.pcap` and
//! `.pcapng` packet captures back into canonical, lowercase `.fasm` network traffic specifications:
//!
//! 1. **Container Normalization (`container`)**: Streams legacy PCAP (microsecond and nanosecond
//!    magic numbers) and PCAPNG (`SectionHeaderBlock`, `InterfaceDescriptionBlock`,
//!    `EnhancedPacketBlock`, `SimplePacketBlock`, `PacketBlock`), scales `IfTsResol` timestamps to
//!    nanoseconds, normalizes link-layer types (`ETHERNET`, `RAW`, `IPV4`, `IPV6`, `LINUX_SLL`,
//!    `LINUX_SLL2`, `NULL`, `LOOP`) into Ethernet II frames, and strips 60/64-byte Ethernet zero
//!    padding and 4-byte FCS trailers.
//! 2. **Layer 2–7 Modular Protocol Dissection (`l2`, `l3`, `l4`, `l7`)**: Decodes all 81 native
//!    protocols across Layer 2 through Layer 7 into strongly-typed modifier structures and
//!    canonical `.fasm` token strings.
//! 3. **Deterministic Topology Discovery (`topology`)**: Infers sequential `host_1`, `host_2`, ...
//!    declarations, tracks dominant unicast MAC addresses, pairs broadcast/multicast traffic, and
//!    locks directional arrows (`->` vs `<-`) per endpoint pair.
//! 4. **Per-Packet Self-Check Oracle (`oracle`)**: Compiles each candidate `.fasm` statement in
//!    isolation via FrameAssembly's own forward compiler and preserves clean, readable high-level
//!    `.fasm` statements (or steps down to raw L4/L2 if a candidate statement fails to compile).
//! 5. **Shadow-State Elision & Emission (`shadow_emit`)**: Mirrors the forward compiler's 4-tuple
//!    TCP sequence/acknowledgment state machine and bidirectional port inheritance to elide
//!    redundant defaults and emit canonical `host` and `compile` blocks.

pub(crate) mod container;
pub(crate) mod l2;
pub(crate) mod l3;
pub(crate) mod l4;
pub(crate) mod l7;
pub(crate) mod oracle;
pub(crate) mod shadow_emit;
pub(crate) mod topology;

use alloc::string::String;
use alloc::vec::Vec;
use core::fmt::Write as _;
use core::net::IpAddr;

use crate::ast::{L2Modifiers, L3Modifiers, L4Modifiers, Protocol, TransportProto};
use crate::error::CompilerError;

/// Summary statistics and `.fasm` source code returned by the decompiler.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReverseOutput {
    /// Generated canonical lowercase `.fasm` code.
    pub fasm: String,
    /// Total number of packets reversed.
    pub packet_count: usize,
    /// Total number of `host_N` definitions emitted.
    pub host_count: usize,
}

/// Result returned by a protocol dissector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtocolDissection {
    /// Identified FrameAssembly [`Protocol`].
    pub protocol: Protocol,
    /// Canonical lowercase `.fasm` protocol keyword and protocol-specific modifiers
    /// (e.g., `"dns query txid 0x1a2b qname \"example.com\" qtype \"A\""`).
    /// Does NOT include caller/callee arrows, L2/L3/L4 universal modifiers, `payload`/`hexpayload`, or `wait`.
    pub tokens: String,
    /// Residual application payload bytes to attach as `payload "..."` or `hexpayload "..."`.
    pub residual_payload: Vec<u8>,
    /// Optional explicit `transport tcp` or `transport udp` modifier if non-default for this protocol.
    pub explicit_transport: Option<TransportProto>,
}

/// Normalized, dissected packet representation across L2–L7.
#[derive(Debug, Clone)]
pub(crate) struct DissectedPacket {
    /// Capture timestamp in nanoseconds.
    pub timestamp_ns: u64,
    /// Normalized Ethernet II frame bytes (with Ethernet zero-padding and 4B FCS stripped).
    pub raw_eth_frame: Vec<u8>,
    /// Whether the original capture linktype was L3-only (`RAW`/`IPV4`/`IPV6`/`NULL`/`LOOP`).
    pub is_l3_only: bool,
    /// Outer source MAC address.
    pub src_mac: [u8; 6],
    /// Outer destination MAC address.
    pub dst_mac: [u8; 6],
    /// Parsed L2 modifiers (`vlan`, `qinq`, `mpls`, `ethertype`, `src_mac`, `dst_mac`).
    pub l2: L2Modifiers,
    /// Inner EtherType (or IEEE 802.3 length <= 1500) following any VLAN/QinQ/MPLS tags.
    pub inner_ethertype: u16,
    /// Source IP address if an IPv4/IPv6 header (or ARP) is present.
    pub src_ip: Option<IpAddr>,
    /// Destination IP address if an IPv4/IPv6 header (or ARP) is present.
    pub dst_ip: Option<IpAddr>,
    /// Whether `src_ip`/`dst_ip` came from an actual IPv4/IPv6 L3 header (`true`) vs pure L2/ARP (`false`).
    pub has_ip_header: bool,
    /// IP protocol / Next Header number if `has_ip_header` is `true`.
    pub ip_proto: Option<u8>,
    /// Parsed L3 modifiers (`ttl`, `dscp`, `ecn`, `flowlabel`, `ipid`, `df`, `mf`, `frag_offset`).
    pub l3: L3Modifiers,
    /// Parsed L4 modifiers (`src_port`, `dst_port`, `transport`, `tcp_flags`, `urgent`, `tcp_options`, `sctp_params`).
    pub l4: L4Modifiers,
    /// TCP sequence number if TCP.
    pub seq: Option<u32>,
    /// TCP acknowledgment number if TCP.
    pub ack_num: Option<u32>,
    /// TCP window size if TCP.
    pub win: Option<u16>,
    /// Raw L4 payload bytes (after TCP/UDP/SCTP header), or L3 payload bytes, or L2 payload bytes.
    pub l4_payload: Vec<u8>,
    /// Raw L2 payload bytes (immediately after Ethernet/VLAN/QinQ/MPLS header).
    pub l2_payload: Vec<u8>,
    /// High-level protocol dissection result.
    pub dissection: ProtocolDissection,
}

/// Decompiles a binary `.pcap` or `.pcapng` capture stream into canonical `.fasm` code.
///
/// # Errors
/// Returns [`CompilerError::BackendError`] if the stream is not a valid `.pcap` or `.pcapng`
/// capture or if an I/O or container framing error occurs.
pub fn reverse_pcap_reader<R: std::io::Read>(reader: R) -> Result<ReverseOutput, CompilerError> {
    let frames = container::read_capture_frames(reader).map_err(CompilerError::BackendError)?;
    let packets: Vec<DissectedPacket> = frames.into_iter().map(l2::dissect_packet).collect();
    let topo = topology::build_topology(&packets);
    Ok(shadow_emit::emit_fasm_program(&packets, &topo))
}

/// Decompiles an in-memory `.pcap` or `.pcapng` byte slice into canonical `.fasm` code.
///
/// # Errors
/// Returns [`CompilerError::BackendError`] if `bytes` is not a valid `.pcap` or `.pcapng` capture.
pub fn reverse_pcap_bytes(bytes: &[u8]) -> Result<ReverseOutput, CompilerError> {
    reverse_pcap_reader(std::io::Cursor::new(bytes))
}

/// Decompiles a `.pcap` or `.pcapng` file on disk into canonical `.fasm` code.
///
/// # Errors
/// Returns [`CompilerError::BackendError`] if the file cannot be opened or is not a valid capture.
pub fn reverse_pcap_file(path: impl AsRef<std::path::Path>) -> Result<ReverseOutput, CompilerError> {
    let path_ref = path.as_ref();
    let file = std::fs::File::open(path_ref).map_err(|e| {
        CompilerError::BackendError(alloc::format!(
            "Failed to open capture file '{}': {}",
            path_ref.display(),
            e
        ))
    })?;
    let reader = std::io::BufReader::new(file);
    reverse_pcap_reader(reader)
}

/// Formats a 6-byte MAC address as lowercase colon-separated hexadecimal (`aa:bb:cc:dd:ee:ff`).
pub(crate) fn format_mac_lower(mac: [u8; 6]) -> String {
    alloc::format!(
        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
        mac[0],
        mac[1],
        mac[2],
        mac[3],
        mac[4],
        mac[5]
    )
}

/// Escapes a UTF-8 string for inclusion inside a `.fasm` double-quoted string literal.
pub(crate) fn escape_fasm_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\r' => out.push_str("\\r"),
            '\n' => out.push_str("\\n"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
                let _ = write!(out, "\\x{:02x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out
}

/// Encodes a byte slice as a lowercase contiguous hexadecimal string.
pub(crate) fn bytes_to_hex_lower(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        let _ = write!(out, "{:02x}", b);
    }
    out
}

/// Formats residual payload bytes as `payload "..."` when printable ASCII/UTF-8 or `hexpayload "..."` otherwise.
///
/// Returns an empty string when `bytes` is empty.
pub(crate) fn format_payload_modifier(bytes: &[u8]) -> String {
    if bytes.is_empty() {
        return String::new();
    }
    if bytes
        .iter()
        .all(|&b| (0x20..=0x7e).contains(&b) || b == b'\r' || b == b'\n' || b == b'\t')
        && let Ok(s) = core::str::from_utf8(bytes)
    {
        return alloc::format!("payload \"{}\"", escape_fasm_string(s));
    }
    alloc::format!("hexpayload \"{}\"", bytes_to_hex_lower(bytes))
}

/// Reads a big-endian `u16` from `b` at `off`.
pub(crate) fn be_u16(b: &[u8], off: usize) -> u16 {
    u16::from_be_bytes([b[off], b[off + 1]])
}

/// Reads a big-endian `u32` from `b` at `off`.
pub(crate) fn be_u32(b: &[u8], off: usize) -> u32 {
    u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}

/// Reads a big-endian `u64` from `b` at `off`.
pub(crate) fn be_u64(b: &[u8], off: usize) -> u64 {
    let mut arr = [0u8; 8];
    arr.copy_from_slice(&b[off..off + 8]);
    u64::from_be_bytes(arr)
}

/// Reads a little-endian `u32` from `b` at `off`.
pub(crate) fn le_u32(b: &[u8], off: usize) -> u32 {
    u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}

/// Reads a 6-byte MAC address from `b` at `off`.
pub(crate) fn mac_at(b: &[u8], off: usize) -> [u8; 6] {
    [
        b[off],
        b[off + 1],
        b[off + 2],
        b[off + 3],
        b[off + 4],
        b[off + 5],
    ]
}

/// Reads an `Ipv4Addr` from `b` at `off`.
pub(crate) fn ipv4_at(b: &[u8], off: usize) -> core::net::Ipv4Addr {
    core::net::Ipv4Addr::new(b[off], b[off + 1], b[off + 2], b[off + 3])
}

/// Reads an `Ipv6Addr` from `b` at `off`.
pub(crate) fn ipv6_at(b: &[u8], off: usize) -> core::net::Ipv6Addr {
    let mut arr = [0u8; 16];
    arr.copy_from_slice(&b[off..off + 16]);
    core::net::Ipv6Addr::from(arr)
}

/// Appends ` <kw> <val>` onto `out` when `val != default`.
pub(crate) fn push_if<T: PartialEq + core::fmt::Display>(
    out: &mut String,
    kw: &str,
    val: T,
    default: T,
) {
    if val != default {
        let _ = write!(out, " {kw} {val}");
    }
}

/// Appends ` <kw> 0x<val>` with `width` hex digits onto `out` when `val != default`.
pub(crate) fn push_hex(out: &mut String, kw: &str, val: u64, default: u64, width: usize) {
    if val != default {
        let _ = write!(out, " {kw} 0x{val:0width$x}");
    }
}

/// Appends ` <kw> "<val>"` onto `out` when `val != default`.
#[allow(dead_code)]
pub(crate) fn push_str_if(out: &mut String, kw: &str, val: &str, default: &str) {
    if val != default {
        let needs_escape = {
            let mut it = val.chars();
            let mut esc = false;
            while let Some(c) = it.next() {
                if matches!(c, '\0' | '\r' | '\n' | '"')
                    || (c == '\\' && it.next().is_none_or(|n| matches!(n, '\0' | '\r' | '\n')))
                {
                    esc = true;
                    break;
                }
            }
            esc
        };
        if needs_escape {
            let _ = write!(out, " {kw} \"{}\"", escape_fasm_string(val));
        } else {
            let _ = write!(out, " {kw} \"{val}\"");
        }
    }
}

/// Appends ` <kw> <ip>` onto `out` when `ip != default`.
#[allow(dead_code)]
pub(crate) fn push_ip_if<T: PartialEq + core::fmt::Display>(
    out: &mut String,
    kw: &str,
    ip: T,
    default: T,
) {
    if ip != default {
        let _ = write!(out, " {kw} {ip}");
    }
}

/// Appends ` <kw> <mac>` onto `out` when `mac != default`.
pub(crate) fn push_mac_if(out: &mut String, kw: &str, mac: [u8; 6], default: [u8; 6]) {
    if mac != default {
        let _ = write!(out, " {kw} {}", format_mac_lower(mac));
    }
}

/// Extracts `Ipv4Addr` from `src_ip` or returns `fallback`.
pub(crate) fn src_v4_or(
    src_ip: Option<IpAddr>,
    fallback: core::net::Ipv4Addr,
) -> core::net::Ipv4Addr {
    match src_ip {
        Some(IpAddr::V4(v4)) => v4,
        _ => fallback,
    }
}

/// Constructs a [`ProtocolDissection`] with no explicit L4 transport override.
pub(crate) fn sd(
    protocol: Protocol,
    tokens: String,
    residual_payload: Vec<u8>,
) -> ProtocolDissection {
    ProtocolDissection {
        protocol,
        tokens,
        residual_payload,
        explicit_transport: None,
    }
}

/// Constructs a [`ProtocolDissection`] with an explicit L4 transport override.
#[allow(dead_code)]
pub(crate) fn sd_tp(
    protocol: Protocol,
    tokens: String,
    residual_payload: Vec<u8>,
    explicit_transport: impl Into<Option<TransportProto>>,
) -> ProtocolDissection {
    ProtocolDissection {
        protocol,
        tokens,
        residual_payload,
        explicit_transport: explicit_transport.into(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::backend::compile_program;
    use crate::parser::parse_program;
    use pcap_file::pcapng::PcapNgWriter;
    use pcap_file::pcapng::blocks::Block;
    use pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketBlock;
    use pcap_file::pcapng::blocks::interface_description::{
        InterfaceDescriptionBlock, InterfaceDescriptionOption,
    };
    use std::borrow::Cow;
    use std::time::Duration;

    /// Serializes `(frame_bytes, timestamp_ns)` pairs into an in-memory PCAPNG buffer.
    fn frames_to_pcapng(frames: &[(Vec<u8>, u64)]) -> Vec<u8> {
        let mut buf = Vec::new();
        let Ok(mut writer) = PcapNgWriter::new(&mut buf) else {
            panic!("create PcapNgWriter");
        };
        let idb = InterfaceDescriptionBlock {
            linktype: pcap_file::DataLink::ETHERNET,
            snaplen: 65535,
            options: alloc::vec![InterfaceDescriptionOption::IfTsResol(9)],
        };
        assert!(
            writer
                .write_block(&Block::InterfaceDescription(idb))
                .is_ok()
        );
        for (data, ts) in frames {
            let epb = EnhancedPacketBlock {
                interface_id: 0,
                timestamp: Duration::from_nanos(*ts),
                original_len: data.len() as u32,
                data: Cow::Borrowed(data),
                options: Vec::new(),
            };
            assert!(writer.write_block(&Block::EnhancedPacket(epb)).is_ok());
        }
        buf
    }

    /// Recursively collects `.fasm` files under `dir`.
    fn collect_fasm_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
        if let Ok(entries) = std::fs::read_dir(dir) {
            let mut paths: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
            paths.sort();
            for p in paths {
                if p.is_dir() {
                    collect_fasm_files(&p, out);
                } else if p.extension().and_then(|s| s.to_str()) == Some("fasm") {
                    out.push(p);
                }
            }
        }
    }

    /// Verifies round-trip PCAPNG decompilation and recompilation across all `examples/{l2,l3,l4}` corpus files.
    #[test]
    fn test_l2_l3_l4_corpus_roundtrip_oracle() {
        let mut files = Vec::new();
        for sub in ["examples/l2", "examples/l3", "examples/l4"] {
            collect_fasm_files(std::path::Path::new(sub), &mut files);
        }
        assert!(!files.is_empty(), "expected L2/L3/L4 corpus files");

        for path in &files {
            let Ok(src) = std::fs::read_to_string(path) else {
                panic!("read corpus file");
            };
            let Ok(prog) = parse_program(&src) else {
                panic!("parse original corpus file");
            };
            let Ok(orig_frames) = compile_program(&prog) else {
                panic!("compile original corpus file");
            };
            let pcap_bytes = frames_to_pcapng(&orig_frames);

            let out = reverse_pcap_bytes(&pcap_bytes)
                .unwrap_or_else(|e| panic!("reverse failed on {}: {e}", path.display()));
            assert!(
                !out.fasm.contains("// ["),
                "unexpected fallback comment in {}:\n{}",
                path.display(),
                out.fasm
            );
            let reprog = parse_program(&out.fasm).unwrap_or_else(|e| {
                panic!(
                    "re-parse failed on {}: {e}\n--- FASM ---\n{}",
                    path.display(),
                    out.fasm
                )
            });
            let reframes = compile_program(&reprog)
                .unwrap_or_else(|e| panic!("re-compile failed on {}: {e}", path.display()));
            assert_eq!(
                orig_frames.len(),
                reframes.len(),
                "frame count mismatch on {}",
                path.display()
            );
            let orig_base = orig_frames.first().map_or(0, |(_, ts)| *ts);
            let re_base = reframes.first().map_or(0, |(_, ts)| *ts);
            for (idx, ((_orig_b, orig_ts), (_re_b, re_ts))) in
                orig_frames.iter().zip(reframes.iter()).enumerate()
            {
                assert_eq!(
                    orig_ts.saturating_sub(orig_base),
                    re_ts.saturating_sub(re_base),
                    "relative timestamp mismatch on {} frame #{}",
                    path.display(),
                    idx + 1
                );
            }
        }
    }
}