use memf_core::object_reader::ObjectReader;
use memf_format::PhysicalMemoryProvider;
use crate::{Error, NetfilterRuleInfo, Result};
const TABLE_NAMES: &[(&str, &str)] = &[
("filter", "iptable_filter_net_ops"),
("nat", "iptable_nat_net_ops"),
("mangle", "iptable_mangle_net_ops"),
];
pub fn walk_netfilter_rules<P: PhysicalMemoryProvider>(
reader: &ObjectReader<P>,
) -> Result<Vec<NetfilterRuleInfo>> {
let init_net_addr =
reader
.symbols()
.symbol_address("init_net")
.ok_or_else(|| Error::MissingKernelSymbol {
name: "init_net".into(),
})?;
let mut rules = Vec::new();
for &(table_name, _symbol) in TABLE_NAMES {
if let Ok(table_rules) = read_xt_table(reader, init_net_addr, table_name) {
rules.extend(table_rules);
}
}
Ok(rules)
}
fn read_xt_table<P: PhysicalMemoryProvider>(
reader: &ObjectReader<P>,
init_net_addr: u64,
table_name: &str,
) -> Result<Vec<NetfilterRuleInfo>> {
let xt_offset =
reader
.symbols()
.field_offset("net", "xt")
.ok_or_else(|| Error::MissingField {
struct_name: "net".into(),
field_name: "xt".into(),
})?;
let xt_addr = init_net_addr + xt_offset;
let tables_offset = reader
.symbols()
.field_offset("netns_xt", "tables")
.ok_or_else(|| Error::MissingField {
struct_name: "netns_xt".into(),
field_name: "tables".into(),
})?;
let list_head_size = reader.symbols().struct_size("list_head").unwrap_or(16);
let af_inet_list = xt_addr + tables_offset + 2 * list_head_size;
let table_addrs = reader.walk_list(af_inet_list, "xt_table", "list")?;
for &table_addr in &table_addrs {
let name = reader.read_field_string(table_addr, "xt_table", "name", 32)?;
if name == table_name {
return parse_table_rules(reader, table_addr, table_name);
}
}
Ok(Vec::new())
}
fn parse_table_rules<P: PhysicalMemoryProvider>(
reader: &ObjectReader<P>,
table_addr: u64,
table_name: &str,
) -> Result<Vec<NetfilterRuleInfo>> {
let private_ptr = reader.read_pointer(table_addr, "xt_table", "private")?;
if private_ptr == 0 {
return Ok(Vec::new());
}
let entries_vaddr = reader.read_pointer(private_ptr, "xt_table_info", "entries")?;
let size: u64 = reader.read_field::<u64>(private_ptr, "xt_table_info", "size")?;
parse_ipt_entries(reader, entries_vaddr, size, table_name)
}
pub fn parse_ipt_entries<P: PhysicalMemoryProvider>(
reader: &ObjectReader<P>,
data_vaddr: u64,
data_len: u64,
table_name: &str,
) -> Result<Vec<NetfilterRuleInfo>> {
const MAX_RULES: usize = 10_000;
let data_len = data_len as usize;
let data = reader.read_bytes(data_vaddr, data_len).unwrap_or_default();
if data.is_empty() {
return Ok(Vec::new());
}
let mut rules = Vec::new();
let mut offset = 0usize;
for _ in 0..MAX_RULES {
if offset + 0x5C > data.len() {
break;
}
let src_ip = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap_or([0; 4]));
let dst_ip = u32::from_le_bytes(data[offset + 4..offset + 8].try_into().unwrap_or([0; 4]));
let proto = u16::from_le_bytes(
data[offset + 0x10..offset + 0x12]
.try_into()
.unwrap_or([0; 2]),
);
let target_off = u16::from_le_bytes(
data[offset + 0x58..offset + 0x5A]
.try_into()
.unwrap_or([0; 2]),
) as usize;
let next_off = u16::from_le_bytes(
data[offset + 0x5A..offset + 0x5C]
.try_into()
.unwrap_or([0; 2]),
) as usize;
let target_name = if target_off > 0 && offset + target_off + 29 <= data.len() {
let name_bytes = &data[offset + target_off..offset + target_off + 29];
let end = name_bytes.iter().position(|&b| b == 0).unwrap_or(29);
String::from_utf8_lossy(&name_bytes[..end]).into_owned()
} else {
String::new()
};
let source = if src_ip != 0 {
Some(format_ipv4(src_ip))
} else {
None
};
let destination = if dst_ip != 0 {
Some(format_ipv4(dst_ip))
} else {
None
};
rules.push(NetfilterRuleInfo {
table: table_name.to_string(),
chain: String::new(), target: target_name,
protocol: protocol_name(proto),
source,
destination,
});
if next_off == 0 {
break;
}
offset += next_off;
if offset >= data.len() {
break;
}
}
Ok(rules)
}
fn format_ipv4(ip: u32) -> String {
let b = ip.to_le_bytes();
format!("{}.{}.{}.{}", b[0], b[1], b[2], b[3])
}
pub fn protocol_name(proto: u16) -> String {
match proto {
0 => "all".to_string(),
6 => "tcp".to_string(),
17 => "udp".to_string(),
1 => "icmp".to_string(),
_ => format!("proto:{proto}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use memf_core::test_builders::{flags, PageTableBuilder, SyntheticPhysMem};
use memf_core::vas::{TranslationMode, VirtualAddressSpace};
use memf_symbols::isf::IsfResolver;
use memf_symbols::test_builders::IsfBuilder;
#[test]
fn protocol_name_known() {
assert_eq!(protocol_name(0), "all");
assert_eq!(protocol_name(6), "tcp");
assert_eq!(protocol_name(17), "udp");
assert_eq!(protocol_name(1), "icmp");
}
#[test]
fn protocol_name_unknown() {
assert_eq!(protocol_name(132), "proto:132");
assert_eq!(protocol_name(255), "proto:255");
}
fn make_ipt_entry_data(src_ip: u32, dst_ip: u32, proto: u16, target_name: &str) -> Vec<u8> {
let mut data = vec![0u8; 256];
data[0x00..0x04].copy_from_slice(&src_ip.to_le_bytes());
data[0x04..0x08].copy_from_slice(&dst_ip.to_le_bytes());
data[0x10..0x12].copy_from_slice(&proto.to_le_bytes());
let target_off: u16 = 0x60;
data[0x58..0x5A].copy_from_slice(&target_off.to_le_bytes());
data[0x5A..0x5C].copy_from_slice(&0u16.to_le_bytes());
let name_bytes = target_name.as_bytes();
let len = name_bytes.len().min(28);
data[0x60..0x60 + len].copy_from_slice(&name_bytes[..len]);
data
}
fn make_ipt_reader(
entry_data: &[u8],
entry_vaddr: u64,
entry_paddr: u64,
) -> ObjectReader<SyntheticPhysMem> {
let isf = IsfBuilder::new().build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mut mem) = PageTableBuilder::new()
.map_4k(entry_vaddr, entry_paddr, flags::PRESENT | flags::WRITABLE)
.build();
mem.write_bytes(entry_paddr, entry_data);
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
ObjectReader::new(vas, Box::new(resolver))
}
#[test]
fn parse_ipt_entries_src_ip_and_target() {
let src_ip: u32 = 0xC0A8_0101_u32.to_le();
let dst_ip: u32 = 0;
let data = make_ipt_entry_data(src_ip, dst_ip, 6, "ACCEPT");
let entry_vaddr: u64 = 0xFFFF_8000_0010_0000;
let entry_paddr: u64 = 0x0080_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "filter").unwrap();
assert_eq!(rules.len(), 1, "should parse exactly one ipt_entry");
let rule = &rules[0];
assert_eq!(rule.target, "ACCEPT");
assert_eq!(rule.protocol, "tcp");
assert!(rule.source.is_some());
}
#[test]
fn parse_ipt_entries_drop_rule() {
let data = make_ipt_entry_data(0, 0x0A00_0001_u32.to_le(), 0, "DROP");
let entry_vaddr: u64 = 0xFFFF_8000_0020_0000;
let entry_paddr: u64 = 0x0090_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "nat").unwrap();
assert_eq!(rules.len(), 1);
let rule = &rules[0];
assert_eq!(rule.target, "DROP");
assert_eq!(rule.protocol, "all");
}
#[test]
fn parse_ipt_entries_empty_data_returns_empty() {
let entry_vaddr: u64 = 0xFFFF_8000_0030_0000;
let isf = IsfBuilder::new().build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mem) = PageTableBuilder::new().build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
let reader = ObjectReader::new(vas, Box::new(resolver));
let rules = parse_ipt_entries(&reader, entry_vaddr, 0, "filter").unwrap();
assert!(rules.is_empty(), "zero data_len should produce no rules");
}
#[test]
fn parse_ipt_entries_icmp_protocol() {
let data = make_ipt_entry_data(0, 0, 1, "ACCEPT");
let entry_vaddr: u64 = 0xFFFF_8000_0040_0000;
let entry_paddr: u64 = 0x00B0_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "filter").unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].protocol, "icmp");
}
#[test]
fn parse_ipt_entries_udp_protocol() {
let data = make_ipt_entry_data(0, 0, 17, "ACCEPT");
let entry_vaddr: u64 = 0xFFFF_8000_0050_0000;
let entry_paddr: u64 = 0x00C0_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "mangle").unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].protocol, "udp");
}
#[test]
fn parse_ipt_entries_unknown_protocol() {
let data = make_ipt_entry_data(0, 0, 47, "ACCEPT");
let entry_vaddr: u64 = 0xFFFF_8000_0060_0000;
let entry_paddr: u64 = 0x00D0_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "filter").unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].protocol, "proto:47");
}
#[test]
fn walk_netfilter_rules_missing_init_net_returns_error() {
let isf = IsfBuilder::new().build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mem) = PageTableBuilder::new().build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
let reader = ObjectReader::new(vas, Box::new(resolver));
let result = walk_netfilter_rules(&reader);
assert!(
matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "init_net"),
"expected MissingKernelSymbol {{name: \"init_net\"}}, got {result:?}"
);
}
#[test]
fn walk_netfilter_rules_init_net_present_no_xt_field_returns_empty() {
let init_net_vaddr: u64 = 0xFFFF_8800_0080_0000;
let init_net_paddr: u64 = 0x00D0_0000;
let page = [0u8; 4096];
let isf = IsfBuilder::new()
.add_symbol("init_net", init_net_vaddr)
.add_struct("net", 256)
.build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mem) = PageTableBuilder::new()
.map_4k(init_net_vaddr, init_net_paddr, flags::WRITABLE)
.write_phys(init_net_paddr, &page)
.build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
let reader = ObjectReader::new(vas, Box::new(resolver));
let result = walk_netfilter_rules(&reader).unwrap_or_default();
assert!(
result.is_empty(),
"missing net.xt field → all tables fail → empty result"
);
}
#[test]
fn walk_netfilter_rules_init_net_present_no_netns_xt_tables_returns_empty() {
let init_net_vaddr: u64 = 0xFFFF_8800_0090_0000;
let init_net_paddr: u64 = 0x00E0_0000;
let page = [0u8; 4096];
let isf = IsfBuilder::new()
.add_symbol("init_net", init_net_vaddr)
.add_struct("net", 256)
.add_field("net", "xt", 0x00u64, "netns_xt")
.add_struct("netns_xt", 256)
.build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mem) = PageTableBuilder::new()
.map_4k(init_net_vaddr, init_net_paddr, flags::WRITABLE)
.write_phys(init_net_paddr, &page)
.build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
let reader = ObjectReader::new(vas, Box::new(resolver));
let result = walk_netfilter_rules(&reader).unwrap_or_default();
assert!(
result.is_empty(),
"missing netns_xt.tables → all tables fail → empty result"
);
}
#[test]
fn walk_netfilter_rules_init_net_with_empty_xt_list() {
let init_net_vaddr: u64 = 0xFFFF_8800_00A0_0000;
let init_net_paddr: u64 = 0x00B0_0000;
let af_inet_list_offset: usize = 32; let af_inet_list_vaddr = init_net_vaddr + af_inet_list_offset as u64;
let mut page = [0u8; 4096];
page[af_inet_list_offset..af_inet_list_offset + 8]
.copy_from_slice(&af_inet_list_vaddr.to_le_bytes());
page[af_inet_list_offset + 8..af_inet_list_offset + 16]
.copy_from_slice(&af_inet_list_vaddr.to_le_bytes());
let isf = IsfBuilder::new()
.add_symbol("init_net", init_net_vaddr)
.add_struct("net", 256)
.add_field("net", "xt", 0x00u64, "netns_xt")
.add_struct("netns_xt", 256)
.add_field("netns_xt", "tables", 0x00u64, "pointer")
.add_struct("list_head", 16)
.add_field("list_head", "next", 0x00u64, "pointer")
.add_field("list_head", "prev", 0x08u64, "pointer")
.add_struct("xt_table", 128)
.add_field("xt_table", "list", 0x00u64, "list_head")
.add_field("xt_table", "name", 0x10u64, "char")
.build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mem) = PageTableBuilder::new()
.map_4k(init_net_vaddr, init_net_paddr, flags::WRITABLE)
.write_phys(init_net_paddr, &page)
.build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
let reader = ObjectReader::new(vas, Box::new(resolver));
let result = walk_netfilter_rules(&reader).unwrap_or_default();
assert!(
result.is_empty(),
"empty xt_table list should produce no rules"
);
}
#[test]
fn format_ipv4_correct() {
let src_ip: u32 = u32::from_le_bytes([1, 2, 3, 4]); let data = make_ipt_entry_data(src_ip, 0, 6, "ACCEPT");
let entry_vaddr: u64 = 0xFFFF_8000_0070_0000;
let entry_paddr: u64 = 0x00E0_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "filter").unwrap();
assert_eq!(rules.len(), 1);
let src = rules[0].source.as_deref().unwrap_or("");
assert!(
src.contains('.'),
"source IP should be dotted notation: {src}"
);
}
#[test]
fn parse_ipt_entries_two_chained_entries() {
let entry_size = 128usize;
let mut data = vec![0u8; entry_size * 2];
let src1 = u32::from_le_bytes([1, 2, 3, 4]);
data[0x00..0x04].copy_from_slice(&src1.to_le_bytes());
data[0x10..0x12].copy_from_slice(&6u16.to_le_bytes()); let target_off1: u16 = 0x60;
data[0x58..0x5A].copy_from_slice(&target_off1.to_le_bytes());
data[0x5A..0x5C].copy_from_slice(&(entry_size as u16).to_le_bytes());
data[0x60..0x66].copy_from_slice(b"ACCEPT");
let dst2 = u32::from_le_bytes([5, 6, 7, 8]);
data[entry_size + 0x04..entry_size + 0x08].copy_from_slice(&dst2.to_le_bytes());
data[entry_size + 0x10..entry_size + 0x12].copy_from_slice(&17u16.to_le_bytes()); let target_off2: u16 = 0x60;
data[entry_size + 0x58..entry_size + 0x5A].copy_from_slice(&target_off2.to_le_bytes());
data[entry_size + 0x5A..entry_size + 0x5C].copy_from_slice(&0u16.to_le_bytes()); data[entry_size + 0x60..entry_size + 0x64].copy_from_slice(b"DROP");
let entry_vaddr: u64 = 0xFFFF_8000_0080_0000;
let entry_paddr: u64 = 0x00F0_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "filter").unwrap();
assert_eq!(
rules.len(),
2,
"two chained entries should produce two rules"
);
assert_eq!(rules[0].target, "ACCEPT");
assert_eq!(rules[0].protocol, "tcp");
assert!(rules[0].source.is_some(), "entry 1 has src_ip");
assert_eq!(rules[1].target, "DROP");
assert_eq!(rules[1].protocol, "udp");
assert!(rules[1].destination.is_some(), "entry 2 has dst_ip");
}
#[test]
fn parse_ipt_entries_zero_target_offset_empty_target_name() {
let data = vec![0u8; 256];
let entry_vaddr: u64 = 0xFFFF_8000_0090_0000;
let entry_paddr: u64 = 0x00F1_0000;
let reader = make_ipt_reader(&data, entry_vaddr, entry_paddr);
let rules = parse_ipt_entries(&reader, entry_vaddr, data.len() as u64, "filter").unwrap();
assert_eq!(rules.len(), 1);
assert!(
rules[0].target.is_empty(),
"zero target_offset must produce empty target name"
);
}
#[test]
fn walk_netfilter_rules_matching_table_name_calls_parse() {
let init_net_vaddr: u64 = 0xFFFF_8800_00A1_0000;
let init_net_paddr: u64 = 0x00A1_0000;
let af_inet_offset: u64 = 32; let af_inet_list_vaddr = init_net_vaddr + af_inet_offset;
let xt_table_off: u64 = 0x100;
let xt_table_vaddr = init_net_vaddr + xt_table_off;
let mut page = [0u8; 4096];
page[32..40].copy_from_slice(&xt_table_vaddr.to_le_bytes()); page[40..48].copy_from_slice(&af_inet_list_vaddr.to_le_bytes());
page[0x100..0x108].copy_from_slice(&af_inet_list_vaddr.to_le_bytes()); page[0x108..0x110].copy_from_slice(&af_inet_list_vaddr.to_le_bytes()); let name_bytes = b"filter\0";
page[0x110..0x110 + name_bytes.len()].copy_from_slice(name_bytes);
let isf = IsfBuilder::new()
.add_symbol("init_net", init_net_vaddr)
.add_struct("net", 256)
.add_field("net", "xt", 0x00u64, "netns_xt")
.add_struct("netns_xt", 256)
.add_field("netns_xt", "tables", 0x00u64, "pointer")
.add_struct("list_head", 16)
.add_field("list_head", "next", 0x00u64, "pointer")
.add_field("list_head", "prev", 0x08u64, "pointer")
.add_struct("xt_table", 128)
.add_field("xt_table", "list", 0x00u64, "list_head")
.add_field("xt_table", "name", 0x10u64, "char")
.build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let (cr3, mem) = PageTableBuilder::new()
.map_4k(init_net_vaddr, init_net_paddr, flags::WRITABLE)
.write_phys(init_net_paddr, &page)
.build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
let reader = ObjectReader::new(vas, Box::new(resolver));
let result = walk_netfilter_rules(&reader).unwrap_or_default();
assert!(
result.is_empty(),
"matching table name calls parse_table_rules (stub) → still empty"
);
}
#[allow(clippy::too_many_arguments)]
fn make_parse_table_rules_reader(
private_ptr: u64, table_vaddr: u64,
table_paddr: u64,
table_info_vaddr: u64,
table_info_paddr: u64,
entries_vaddr: u64,
entries_paddr: u64,
entry_data: &[u8],
) -> ObjectReader<SyntheticPhysMem> {
let isf = IsfBuilder::new()
.add_struct("xt_table", 256)
.add_field("xt_table", "private", 0x00u64, "pointer")
.add_struct("xt_table_info", 256)
.add_field("xt_table_info", "entries", 0x00u64, "pointer")
.add_field("xt_table_info", "size", 0x08u64, "unsigned long")
.build_json();
let resolver = IsfResolver::from_value(&isf).unwrap();
let entry_size = entry_data.len() as u64;
let mut table_page = [0u8; 4096];
table_page[0x00..0x08].copy_from_slice(&private_ptr.to_le_bytes());
let mut info_page = [0u8; 4096];
info_page[0x00..0x08].copy_from_slice(&entries_vaddr.to_le_bytes());
info_page[0x08..0x10].copy_from_slice(&entry_size.to_le_bytes());
let mut builder = PageTableBuilder::new()
.map_4k(table_vaddr, table_paddr, flags::PRESENT | flags::WRITABLE)
.write_phys(table_paddr, &table_page);
if private_ptr != 0 {
builder = builder
.map_4k(
table_info_vaddr,
table_info_paddr,
flags::PRESENT | flags::WRITABLE,
)
.write_phys(table_info_paddr, &info_page)
.map_4k(
entries_vaddr,
entries_paddr,
flags::PRESENT | flags::WRITABLE,
)
.write_phys(entries_paddr, entry_data);
}
let (cr3, mem) = builder.build();
let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
ObjectReader::new(vas, Box::new(resolver))
}
#[test]
fn parse_table_rules_returns_rules_from_xt_table() {
let entry_data = make_ipt_entry_data(0, 0, 6, "ACCEPT");
let table_vaddr: u64 = 0xFFFF_8000_0100_0000;
let table_paddr: u64 = 0x0010_0000;
let table_info_vaddr: u64 = 0xFFFF_8000_0101_0000;
let table_info_paddr: u64 = 0x0011_0000;
let entries_vaddr: u64 = 0xFFFF_8000_0102_0000;
let entries_paddr: u64 = 0x0012_0000;
let reader = make_parse_table_rules_reader(
table_info_vaddr, table_vaddr,
table_paddr,
table_info_vaddr,
table_info_paddr,
entries_vaddr,
entries_paddr,
&entry_data,
);
let rules = parse_table_rules(&reader, table_vaddr, "filter").unwrap();
assert!(
!rules.is_empty(),
"parse_table_rules should return at least one rule from the ipt_entry region"
);
assert_eq!(rules[0].target, "ACCEPT");
assert_eq!(rules[0].protocol, "tcp");
}
#[test]
fn parse_table_rules_empty_when_private_null() {
let table_vaddr: u64 = 0xFFFF_8000_0110_0000;
let table_paddr: u64 = 0x0013_0000;
let reader = make_parse_table_rules_reader(
0, table_vaddr,
table_paddr,
0, 0,
0,
0,
&[],
);
let rules = parse_table_rules(&reader, table_vaddr, "filter").unwrap();
assert!(
rules.is_empty(),
"null xt_table.private must produce an empty rule list"
);
}
#[test]
fn netfilter_rule_info_clone_debug() {
use crate::NetfilterRuleInfo;
let rule = NetfilterRuleInfo {
table: "filter".to_string(),
chain: "INPUT".to_string(),
target: "DROP".to_string(),
protocol: "tcp".to_string(),
source: Some("1.2.3.4".to_string()),
destination: None,
};
let cloned = rule.clone();
assert_eq!(cloned.table, "filter");
assert_eq!(cloned.target, "DROP");
let dbg = format!("{cloned:?}");
assert!(dbg.contains("DROP"));
assert!(dbg.contains("1.2.3.4"));
}
}