feat(zingela): M4 RX engine - classify + dedup + Io.Queue handoff

classify.zig pure frame parser: SYN-ACK -> open, RST/ACK -> closed,
ICMP type-3 {1,2,3,9,10,13} -> filtered, every verdict gated on a
wrapping SipHash cookie re-check (ack == seq+1 for replies, inner seq
== cookie for ICMP) so spoofed/stale frames are dropped state-free;
byte-offset readInt parsing, fully length-guarded. dedup.zig power-of-2
open-addressed (ip,port) set, single-owned allocator, grows at load
factor with a bounded probe (never wedges). rx.zig receive engine
generic over an injected Source + Sink (proven over hand-built frames),
Io.Queue found-host handoff across producer/consumer fibers, and a
privileged AF_PACKET Receiver (poll-bounded drain with a hard deadline,
ignore-outgoing, EINTR-retry, errno-gated, fail-closed on missing
CAP_NET_RAW). scan subcommand shares one per-run cookie across the TX
template + RX validation; consumer uses a dedicated allocator to keep
the producer/consumer fibers race-free. netutil.zig factors the shared
arg-parse/iface-resolve helpers out of txcmd. Proven end-to-end with a
live SYN scan inside a unshare -r -n netns over a veth pair
(open/closed against a real listener, dedup verified). Green Debug +
ReleaseSafe, leak-free, 61 unit tests.
This commit is contained in:
CarterPerez-dev 2026-06-30 09:37:59 -04:00
parent 8783227711
commit d05a7e6172
9 changed files with 950 additions and 117 deletions

View File

@ -8,7 +8,7 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const opts = b.addOptions();
opts.addOption([]const u8, "version", "0.0.0-m3");
opts.addOption([]const u8, "version", "0.0.0-m4");
const packet_mod = b.createModule(.{
.root_source_file = b.path("src/packet.zig"),
@ -81,6 +81,35 @@ pub fn build(b: *std.Build) void {
tx_mod.addImport("cookie", cookie_mod);
tx_mod.addImport("packet", packet_mod);
const classify_mod = b.createModule(.{
.root_source_file = b.path("src/classify.zig"),
.target = target,
.optimize = optimize,
});
classify_mod.addImport("packet", packet_mod);
classify_mod.addImport("cookie", cookie_mod);
const dedup_mod = b.createModule(.{
.root_source_file = b.path("src/dedup.zig"),
.target = target,
.optimize = optimize,
});
const rx_mod = b.createModule(.{
.root_source_file = b.path("src/rx.zig"),
.target = target,
.optimize = optimize,
});
rx_mod.addImport("classify", classify_mod);
rx_mod.addImport("dedup", dedup_mod);
rx_mod.addImport("cookie", cookie_mod);
const netutil_mod = b.createModule(.{
.root_source_file = b.path("src/netutil.zig"),
.target = target,
.optimize = optimize,
});
const txcmd_mod = b.createModule(.{
.root_source_file = b.path("src/txcmd.zig"),
.target = target,
@ -92,6 +121,22 @@ pub fn build(b: *std.Build) void {
txcmd_mod.addImport("afpacket", afpacket_mod);
txcmd_mod.addImport("cookie", cookie_mod);
txcmd_mod.addImport("tx", tx_mod);
txcmd_mod.addImport("netutil", netutil_mod);
const scancmd_mod = b.createModule(.{
.root_source_file = b.path("src/scancmd.zig"),
.target = target,
.optimize = optimize,
});
scancmd_mod.addImport("targets", targets_mod);
scancmd_mod.addImport("template", template_mod);
scancmd_mod.addImport("ratelimit", ratelimit_mod);
scancmd_mod.addImport("afpacket", afpacket_mod);
scancmd_mod.addImport("cookie", cookie_mod);
scancmd_mod.addImport("tx", tx_mod);
scancmd_mod.addImport("rx", rx_mod);
scancmd_mod.addImport("dedup", dedup_mod);
scancmd_mod.addImport("netutil", netutil_mod);
const exe = b.addExecutable(.{
.name = "zingela",
@ -105,6 +150,7 @@ pub fn build(b: *std.Build) void {
exe.root_module.addImport("cli", cli_mod);
exe.root_module.addImport("smoke", smoke_mod);
exe.root_module.addImport("txcmd", txcmd_mod);
exe.root_module.addImport("scancmd", scancmd_mod);
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
@ -121,7 +167,7 @@ pub fn build(b: *std.Build) void {
smoke_step.dependOn(&smoke_cmd.step);
const test_step = b.step("test", "Run unit tests");
const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, afpacket_mod, tx_mod, txcmd_mod };
const test_mods = [_]*std.Build.Module{ packet_mod, cli_mod, smoke_mod, cookie_mod, numtheory_mod, targets_mod, ratelimit_mod, template_mod, afpacket_mod, tx_mod, txcmd_mod, classify_mod, dedup_mod, rx_mod, netutil_mod, scancmd_mod };
for (test_mods) |mod| {
const t = b.addTest(.{ .root_module = mod });
const rt = b.addRunArtifact(t);

View File

@ -0,0 +1,255 @@
// ©AngelaMos | 2026
// classify.zig
const std = @import("std");
const packet = @import("packet");
const cookie = @import("cookie");
pub const State = enum { open, closed, filtered };
pub const Result = struct {
ip: u32,
port: u16,
state: State,
};
const ETH_HDR_LEN: usize = 14;
const ETH_OFF_TYPE: usize = 12;
const ETHERTYPE_IPV4: u16 = 0x0800;
const IPPROTO_TCP: u8 = 6;
const IPPROTO_ICMP: u8 = 1;
const IP_MIN_IHL: usize = 20;
const IP_IHL_MASK: u8 = 0x0f;
const IP_WORD_BYTES: usize = 4;
const IP_OFF_PROTO: usize = 9;
const IP_OFF_SRC: usize = 12;
const IP_OFF_DST: usize = 16;
const INNER_TCP_MIN_LEN: usize = 8;
const TCP_OFF_SPORT: usize = 0;
const TCP_OFF_DPORT: usize = 2;
const TCP_OFF_SEQ: usize = 4;
const TCP_OFF_ACK: usize = 8;
const TCP_OFF_FLAGS: usize = 13;
const TCP_MIN_LEN: usize = 20;
const TCP_FLAG_SYN: u8 = 0x02;
const TCP_FLAG_RST: u8 = 0x04;
const TCP_FLAG_ACK: u8 = 0x10;
const ICMP_HDR_LEN: usize = 8;
const ICMP_TYPE_DEST_UNREACH: u8 = 3;
const ICMP_OFF_TYPE: usize = 0;
const ICMP_OFF_CODE: usize = 1;
fn icmpCodeIsFilteredForSyn(code: u8) bool {
return switch (code) {
1, 2, 3, 9, 10, 13 => true,
else => false,
};
}
fn ihlBytes(first_byte: u8) usize {
return @as(usize, first_byte & IP_IHL_MASK) * IP_WORD_BYTES;
}
pub fn classify(frame: []const u8, ck: cookie.Cookie) ?Result {
if (frame.len < ETH_HDR_LEN + IP_MIN_IHL) return null;
if (std.mem.readInt(u16, frame[ETH_OFF_TYPE..][0..2], .big) != ETHERTYPE_IPV4) return null;
const ip = ETH_HDR_LEN;
const ihl = ihlBytes(frame[ip]);
if (ihl < IP_MIN_IHL or frame.len < ip + ihl) return null;
const proto = frame[ip + IP_OFF_PROTO];
const ip_src = std.mem.readInt(u32, frame[ip + IP_OFF_SRC ..][0..4], .big);
const ip_dst = std.mem.readInt(u32, frame[ip + IP_OFF_DST ..][0..4], .big);
if (proto == IPPROTO_TCP) {
const tcp = ip + ihl;
if (frame.len < tcp + TCP_MIN_LEN) return null;
const sport = std.mem.readInt(u16, frame[tcp + TCP_OFF_SPORT ..][0..2], .big);
const dport = std.mem.readInt(u16, frame[tcp + TCP_OFF_DPORT ..][0..2], .big);
const ackno = std.mem.readInt(u32, frame[tcp + TCP_OFF_ACK ..][0..4], .big);
const flags = frame[tcp + TCP_OFF_FLAGS];
const is_synack = (flags & (TCP_FLAG_SYN | TCP_FLAG_ACK)) == (TCP_FLAG_SYN | TCP_FLAG_ACK);
if (is_synack) {
if (ck.validateSynAck(ackno, ip_src, sport, ip_dst, dport))
return .{ .ip = ip_src, .port = sport, .state = .open };
return null;
}
if ((flags & TCP_FLAG_RST) != 0 and (flags & TCP_FLAG_ACK) != 0) {
if (ck.validateSynAck(ackno, ip_src, sport, ip_dst, dport))
return .{ .ip = ip_src, .port = sport, .state = .closed };
}
return null;
}
if (proto == IPPROTO_ICMP) {
const icmp = ip + ihl;
if (frame.len < icmp + ICMP_HDR_LEN) return null;
if (frame[icmp + ICMP_OFF_TYPE] != ICMP_TYPE_DEST_UNREACH) return null;
if (!icmpCodeIsFilteredForSyn(frame[icmp + ICMP_OFF_CODE])) return null;
const inner = icmp + ICMP_HDR_LEN;
if (frame.len < inner + IP_MIN_IHL) return null;
const inner_ihl = ihlBytes(frame[inner]);
if (inner_ihl < IP_MIN_IHL) return null;
if (frame[inner + IP_OFF_PROTO] != IPPROTO_TCP) return null;
const inner_src = std.mem.readInt(u32, frame[inner + IP_OFF_SRC ..][0..4], .big);
const inner_dst = std.mem.readInt(u32, frame[inner + IP_OFF_DST ..][0..4], .big);
const inner_tcp = inner + inner_ihl;
if (frame.len < inner_tcp + INNER_TCP_MIN_LEN) return null;
const inner_sport = std.mem.readInt(u16, frame[inner_tcp + TCP_OFF_SPORT ..][0..2], .big);
const inner_dport = std.mem.readInt(u16, frame[inner_tcp + TCP_OFF_DPORT ..][0..2], .big);
const inner_seq = std.mem.readInt(u32, frame[inner_tcp + TCP_OFF_SEQ ..][0..4], .big);
if (inner_seq == ck.seq(inner_dst, inner_dport, inner_src, inner_sport))
return .{ .ip = inner_dst, .port = inner_dport, .state = .filtered };
return null;
}
return null;
}
comptime {
std.debug.assert(@sizeOf(packet.EthHdr) == ETH_HDR_LEN);
std.debug.assert(@sizeOf(packet.Ipv4Hdr) == IP_MIN_IHL);
std.debug.assert(@sizeOf(packet.TcpHdr) == TCP_MIN_LEN);
}
const test_key = [16]u8{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
};
const our_ip: u32 = 0x0a000001;
const our_port: u16 = 40000;
const their_ip: u32 = 0x08080808;
const their_port: u16 = 80;
fn buildTcpReply(buf: *[54]u8, ip_src: u32, ip_dst: u32, sport: u16, dport: u16, seq: u32, ack: u32, flags: u8) void {
@memset(buf, 0);
std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big);
buf[ETH_HDR_LEN] = 0x45;
buf[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_TCP;
std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], ip_src, .big);
std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_DST ..][0..4], ip_dst, .big);
const tcp = ETH_HDR_LEN + IP_MIN_IHL;
std.mem.writeInt(u16, buf[tcp + TCP_OFF_SPORT ..][0..2], sport, .big);
std.mem.writeInt(u16, buf[tcp + TCP_OFF_DPORT ..][0..2], dport, .big);
std.mem.writeInt(u32, buf[tcp + TCP_OFF_SEQ ..][0..4], seq, .big);
std.mem.writeInt(u32, buf[tcp + TCP_OFF_ACK ..][0..4], ack, .big);
buf[tcp + TCP_OFF_FLAGS] = flags;
}
fn buildIcmpUnreach(buf: *[128]u8, code: u8, inner_src: u32, inner_dst: u32, inner_sport: u16, inner_dport: u16, inner_seq: u32) usize {
@memset(buf, 0);
std.mem.writeInt(u16, buf[ETH_OFF_TYPE..][0..2], ETHERTYPE_IPV4, .big);
buf[ETH_HDR_LEN] = 0x45;
buf[ETH_HDR_LEN + IP_OFF_PROTO] = IPPROTO_ICMP;
std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_SRC ..][0..4], inner_dst, .big);
std.mem.writeInt(u32, buf[ETH_HDR_LEN + IP_OFF_DST ..][0..4], inner_src, .big);
const icmp = ETH_HDR_LEN + IP_MIN_IHL;
buf[icmp + ICMP_OFF_TYPE] = ICMP_TYPE_DEST_UNREACH;
buf[icmp + ICMP_OFF_CODE] = code;
const inner = icmp + ICMP_HDR_LEN;
buf[inner] = 0x45;
buf[inner + IP_OFF_PROTO] = IPPROTO_TCP;
std.mem.writeInt(u32, buf[inner + IP_OFF_SRC ..][0..4], inner_src, .big);
std.mem.writeInt(u32, buf[inner + IP_OFF_DST ..][0..4], inner_dst, .big);
const inner_tcp = inner + IP_MIN_IHL;
std.mem.writeInt(u16, buf[inner_tcp + TCP_OFF_SPORT ..][0..2], inner_sport, .big);
std.mem.writeInt(u16, buf[inner_tcp + TCP_OFF_DPORT ..][0..2], inner_dport, .big);
std.mem.writeInt(u32, buf[inner_tcp + TCP_OFF_SEQ ..][0..4], inner_seq, .big);
return inner_tcp + ICMP_HDR_LEN;
}
test "validated SYN-ACK classifies as open" {
const ck = cookie.Cookie.init(test_key);
const our_seq = ck.seq(their_ip, their_port, our_ip, our_port);
var f: [54]u8 = undefined;
buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, our_seq +% 1, TCP_FLAG_SYN | TCP_FLAG_ACK);
const r = classify(&f, ck).?;
try std.testing.expectEqual(State.open, r.state);
try std.testing.expectEqual(their_ip, r.ip);
try std.testing.expectEqual(their_port, r.port);
}
test "SYN-ACK with a wrong ack is rejected (anti-spoof)" {
const ck = cookie.Cookie.init(test_key);
var f: [54]u8 = undefined;
buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0xCAFEBABE, 0xDEADBEEF, TCP_FLAG_SYN | TCP_FLAG_ACK);
try std.testing.expect(classify(&f, ck) == null);
}
test "validated RST/ACK classifies as closed" {
const ck = cookie.Cookie.init(test_key);
const our_seq = ck.seq(their_ip, their_port, our_ip, our_port);
var f: [54]u8 = undefined;
buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, our_seq +% 1, TCP_FLAG_RST | TCP_FLAG_ACK);
const r = classify(&f, ck).?;
try std.testing.expectEqual(State.closed, r.state);
try std.testing.expectEqual(their_ip, r.ip);
try std.testing.expectEqual(their_port, r.port);
}
test "RST/ACK with a wrong ack is rejected" {
const ck = cookie.Cookie.init(test_key);
var f: [54]u8 = undefined;
buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, 0x11112222, TCP_FLAG_RST | TCP_FLAG_ACK);
try std.testing.expect(classify(&f, ck) == null);
}
test "bare RST without ACK is dropped as unvalidated" {
const ck = cookie.Cookie.init(test_key);
var f: [54]u8 = undefined;
buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, 0, TCP_FLAG_RST);
try std.testing.expect(classify(&f, ck) == null);
}
test "validated ICMP dest-unreachable classifies as filtered" {
const ck = cookie.Cookie.init(test_key);
const our_seq = ck.seq(their_ip, their_port, our_ip, our_port);
var f: [128]u8 = undefined;
const len = buildIcmpUnreach(&f, 3, our_ip, their_ip, our_port, their_port, our_seq);
const r = classify(f[0..len], ck).?;
try std.testing.expectEqual(State.filtered, r.state);
try std.testing.expectEqual(their_ip, r.ip);
try std.testing.expectEqual(their_port, r.port);
}
test "ICMP with a mismatched inner seq is rejected" {
const ck = cookie.Cookie.init(test_key);
var f: [128]u8 = undefined;
const len = buildIcmpUnreach(&f, 3, our_ip, their_ip, our_port, their_port, 0x99999999);
try std.testing.expect(classify(f[0..len], ck) == null);
}
test "ICMP with a non-filtered code is ignored" {
const ck = cookie.Cookie.init(test_key);
const our_seq = ck.seq(their_ip, their_port, our_ip, our_port);
var f: [128]u8 = undefined;
const len = buildIcmpUnreach(&f, 4, our_ip, their_ip, our_port, their_port, our_seq);
try std.testing.expect(classify(f[0..len], ck) == null);
}
test "non-IPv4 ethertype is ignored" {
const ck = cookie.Cookie.init(test_key);
var f: [54]u8 = undefined;
buildTcpReply(&f, their_ip, our_ip, their_port, our_port, 0, 0, TCP_FLAG_SYN | TCP_FLAG_ACK);
std.mem.writeInt(u16, f[ETH_OFF_TYPE..][0..2], 0x0806, .big);
try std.testing.expect(classify(&f, ck) == null);
}
test "runt frames return null instead of reading out of bounds" {
const ck = cookie.Cookie.init(test_key);
var tiny = [_]u8{0} ** 20;
try std.testing.expect(classify(&tiny, ck) == null);
var empty = [_]u8{};
try std.testing.expect(classify(&empty, ck) == null);
}

View File

@ -50,10 +50,11 @@ pub fn printHelp(io: std.Io) !void {
\\commands:
\\ smoke [ifname] send one hand-built SYN via AF_PACKET (default ifname: lo)
\\ tx [options] PACKET_TX_RING SYN blast over a target range (privileged)
\\ scan [options] SYN scan: transmit + classify replies open/closed/filtered (privileged)
\\ --version, -V print version
\\ --help, -h print this help
\\
\\tx options:
\\tx / scan options:
\\ --target <cidr> target range, required (e.g. 10.0.0.0/24)
\\ --ports <list> comma-separated dst ports (default 80)
\\ --rate <pps> token-bucket rate, packets per second (default 10000)
@ -64,6 +65,9 @@ pub fn printHelp(io: std.Io) !void {
\\ --gw-mac <mac> gateway/dst MAC aa:bb:cc:dd:ee:ff (default 00:..:00)
\\ --seed <n> permutation seed (default: per-scan CSPRNG)
\\
\\scan-only options:
\\ --wait <ms> receive drain window after transmit (default 2000)
\\
\\authorized use only. responsible default rate; needs CAP_NET_RAW
\\(grant once: sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela)
\\

View File

@ -0,0 +1,122 @@
// ©AngelaMos | 2026
// dedup.zig
const std = @import("std");
const EMPTY: u64 = std.math.maxInt(u64);
const LOAD_NUM: usize = 7;
const LOAD_DEN: usize = 10;
const FMIX64_MUL: u64 = 0xff51afd7ed558ccd;
const FMIX64_SHIFT: u6 = 33;
pub const Dedup = struct {
allocator: std.mem.Allocator,
slots: []u64,
mask: usize,
len: usize,
pub fn init(allocator: std.mem.Allocator, capacity_pow2: usize) !Dedup {
std.debug.assert(capacity_pow2 >= 2 and std.math.isPowerOfTwo(capacity_pow2));
const slots = try allocator.alloc(u64, capacity_pow2);
@memset(slots, EMPTY);
return .{ .allocator = allocator, .slots = slots, .mask = capacity_pow2 - 1, .len = 0 };
}
pub fn deinit(self: *Dedup) void {
self.allocator.free(self.slots);
self.* = undefined;
}
fn mix(k: u64) u64 {
var x = k;
x ^= x >> FMIX64_SHIFT;
x *%= FMIX64_MUL;
x ^= x >> FMIX64_SHIFT;
return x;
}
const PlaceResult = enum { inserted, duplicate, full };
fn place(slots: []u64, mask: usize, k: u64) PlaceResult {
var i: usize = @intCast(mix(k) & mask);
var probes: usize = 0;
while (probes <= mask) : (probes += 1) {
if (slots[i] == EMPTY) {
slots[i] = k;
return .inserted;
}
if (slots[i] == k) return .duplicate;
i = (i + 1) & mask;
}
return .full;
}
fn grow(self: *Dedup) !void {
const new_cap = (self.mask + 1) * 2;
const new_slots = try self.allocator.alloc(u64, new_cap);
@memset(new_slots, EMPTY);
const new_mask = new_cap - 1;
for (self.slots) |s| {
if (s != EMPTY) _ = place(new_slots, new_mask, s);
}
self.allocator.free(self.slots);
self.slots = new_slots;
self.mask = new_mask;
}
pub fn insert(self: *Dedup, k: u64) bool {
if ((self.len + 1) * LOAD_DEN > (self.mask + 1) * LOAD_NUM) {
self.grow() catch {};
}
return switch (place(self.slots, self.mask, k)) {
.inserted => blk: {
self.len += 1;
break :blk true;
},
.duplicate => false,
.full => true,
};
}
};
fn key(ip: u32, port: u16) u64 {
return (@as(u64, ip) << 16) | port;
}
test "first insert is new, second of same key is duplicate" {
var d = try Dedup.init(std.testing.allocator, 16);
defer d.deinit();
try std.testing.expect(d.insert(key(0x08080808, 80)));
try std.testing.expect(!d.insert(key(0x08080808, 80)));
try std.testing.expectEqual(@as(usize, 1), d.len);
}
test "same ip different ports are distinct" {
var d = try Dedup.init(std.testing.allocator, 16);
defer d.deinit();
try std.testing.expect(d.insert(key(0x08080808, 80)));
try std.testing.expect(d.insert(key(0x08080808, 443)));
try std.testing.expect(!d.insert(key(0x08080808, 80)));
try std.testing.expectEqual(@as(usize, 2), d.len);
}
test "distinct keys are all retained across growth" {
var d = try Dedup.init(std.testing.allocator, 4);
defer d.deinit();
var i: u32 = 0;
while (i < 1000) : (i += 1) {
try std.testing.expect(d.insert(key(0x08080000 + i, 80)));
}
try std.testing.expectEqual(@as(usize, 1000), d.len);
i = 0;
while (i < 1000) : (i += 1) {
try std.testing.expect(!d.insert(key(0x08080000 + i, 80)));
}
try std.testing.expectEqual(@as(usize, 1000), d.len);
}
test "the all-ones sentinel cannot collide with a real key" {
const max_key = key(0xffffffff, 0xffff);
try std.testing.expect(max_key != EMPTY);
try std.testing.expectEqual(@as(u64, 0x0000ffffffffffff), max_key);
}

View File

@ -5,6 +5,7 @@ const std = @import("std");
const cli = @import("cli");
const smoke = @import("smoke");
const txcmd = @import("txcmd");
const scancmd = @import("scancmd");
pub fn main(init: std.process.Init) !void {
const io = init.io;
@ -23,5 +24,8 @@ pub fn main(init: std.process.Init) !void {
if (std.mem.eql(u8, cmd, "tx")) {
return txcmd.run(io, arena, args);
}
if (std.mem.eql(u8, cmd, "scan")) {
return scancmd.run(io, arena, args);
}
return cli.printHelp(io);
}

View File

@ -0,0 +1,118 @@
// ©AngelaMos | 2026
// netutil.zig
const std = @import("std");
const linux = std.os.linux;
pub fn getFlag(args: []const []const u8, name: []const u8) ?[]const u8 {
var i: usize = 0;
while (i + 1 < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], name)) return args[i + 1];
}
return null;
}
pub fn parseIpv4(text: []const u8) !u32 {
var addr: u32 = 0;
var octets: usize = 0;
var it = std.mem.splitScalar(u8, text, '.');
while (it.next()) |part| {
if (octets == 4) return error.InvalidIpv4;
const octet = std.fmt.parseInt(u8, part, 10) catch return error.InvalidIpv4;
addr = (addr << 8) | octet;
octets += 1;
}
if (octets != 4) return error.InvalidIpv4;
return addr;
}
pub fn parseMac(text: []const u8) ![6]u8 {
var mac: [6]u8 = undefined;
var octets: usize = 0;
var it = std.mem.splitScalar(u8, text, ':');
while (it.next()) |part| {
if (octets == 6) return error.InvalidMac;
mac[octets] = std.fmt.parseInt(u8, part, 16) catch return error.InvalidMac;
octets += 1;
}
if (octets != 6) return error.InvalidMac;
return mac;
}
pub fn parsePorts(allocator: std.mem.Allocator, text: []const u8) ![]u16 {
var list: std.ArrayList(u16) = .empty;
errdefer list.deinit(allocator);
var it = std.mem.splitScalar(u8, text, ',');
while (it.next()) |part| {
if (part.len == 0) continue;
const port = std.fmt.parseInt(u16, part, 10) catch return error.InvalidPort;
try list.append(allocator, port);
}
if (list.items.len == 0) return error.InvalidPort;
return list.toOwnedSlice(allocator);
}
fn ifaceQuery(ifname: []const u8, request: u32) !linux.sockaddr {
const rc_sock = linux.socket(linux.AF.INET, linux.SOCK.DGRAM, 0);
if (linux.errno(rc_sock) != .SUCCESS) return error.ResolveSocketFailed;
const fd: i32 = @intCast(rc_sock);
defer _ = linux.close(fd);
var ifr = std.mem.zeroes(linux.ifreq);
if (ifname.len >= ifr.ifrn.name.len) return error.IfNameTooLong;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, request, @intFromPtr(&ifr))) != .SUCCESS) return error.IfQueryFailed;
return ifr.ifru.addr;
}
pub fn resolveSrcIp(ifname: []const u8) !u32 {
const sa = try ifaceQuery(ifname, linux.SIOCGIFADDR);
return std.mem.readInt(u32, sa.data[2..6], .big);
}
pub fn resolveSrcMac(ifname: []const u8) ![6]u8 {
const sa = try ifaceQuery(ifname, linux.SIOCGIFHWADDR);
return sa.data[0..6].*;
}
pub const RealClock = struct {
pub fn now(_: *RealClock) u64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
}
pub fn sleepNs(_: *RealClock, ns: u64) void {
const ts = linux.timespec{
.sec = @intCast(ns / 1_000_000_000),
.nsec = @intCast(ns % 1_000_000_000),
};
_ = linux.nanosleep(&ts, null);
}
};
test "parseIpv4 round-trips dotted quads" {
try std.testing.expectEqual(@as(u32, 0x7f000001), try parseIpv4("127.0.0.1"));
try std.testing.expectEqual(@as(u32, 0x08080808), try parseIpv4("8.8.8.8"));
try std.testing.expectError(error.InvalidIpv4, parseIpv4("1.2.3"));
try std.testing.expectError(error.InvalidIpv4, parseIpv4("256.0.0.1"));
}
test "parseMac parses colon-separated hex" {
try std.testing.expectEqual([6]u8{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff }, try parseMac("aa:bb:cc:dd:ee:ff"));
try std.testing.expectEqual([_]u8{0} ** 6, try parseMac("00:00:00:00:00:00"));
try std.testing.expectError(error.InvalidMac, parseMac("aa:bb:cc"));
}
test "parsePorts parses a comma list and rejects empty" {
const ports = try parsePorts(std.testing.allocator, "80,443,22");
defer std.testing.allocator.free(ports);
try std.testing.expectEqualSlices(u16, &.{ 80, 443, 22 }, ports);
try std.testing.expectError(error.InvalidPort, parsePorts(std.testing.allocator, ""));
}
test "getFlag finds values and tolerates missing" {
const args = [_][]const u8{ "tx", "--iface", "eth0", "--rate", "5000" };
try std.testing.expectEqualStrings("eth0", getFlag(&args, "--iface").?);
try std.testing.expectEqualStrings("5000", getFlag(&args, "--rate").?);
try std.testing.expect(getFlag(&args, "--target") == null);
}

View File

@ -0,0 +1,234 @@
// ©AngelaMos | 2026
// rx.zig
const std = @import("std");
const classify = @import("classify");
const dedup = @import("dedup");
const cookie = @import("cookie");
pub const Result = classify.Result;
pub const State = classify.State;
const RECV_BUF_LEN: usize = 2048;
const PORT_BITS: u6 = 16;
const NS_PER_MS: u64 = 1_000_000;
const NS_PER_SEC: u64 = 1_000_000_000;
pub fn resultKey(r: Result) u64 {
return (@as(u64, r.ip) << PORT_BITS) | r.port;
}
pub fn run(source: anytype, ck: cookie.Cookie, dd: *dedup.Dedup, sink: anytype) void {
var buf: [RECV_BUF_LEN]u8 = undefined;
while (source.recv(&buf)) |n| {
if (classify.classify(buf[0..n], ck)) |r| {
if (dd.insert(resultKey(r))) sink.emit(r);
}
}
}
pub const QueueSink = struct {
queue: *std.Io.Queue(Result),
io: std.Io,
pub fn emit(self: *QueueSink, r: Result) void {
self.queue.putOne(self.io, r) catch {};
}
};
const linux = std.os.linux;
pub const OpenError = error{
NeedCapNetRaw,
SocketFailed,
IfIndexFailed,
BindFailed,
};
pub const Receiver = struct {
fd: i32,
quiet_ms: i32,
deadline_ns: u64,
fn monoNow() u64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) * NS_PER_SEC + @as(u64, @intCast(ts.nsec));
}
pub fn open(ifname: []const u8, quiet_ms: i32, total_budget_ns: u64) OpenError!Receiver {
const rc_sock = linux.socket(
linux.AF.PACKET,
linux.SOCK.RAW,
std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP)),
);
switch (linux.errno(rc_sock)) {
.SUCCESS => {},
.PERM, .ACCES => return error.NeedCapNetRaw,
else => return error.SocketFailed,
}
const fd: i32 = @intCast(rc_sock);
errdefer _ = linux.close(fd);
var ifr = std.mem.zeroes(linux.ifreq);
if (ifname.len >= ifr.ifrn.name.len) return error.IfIndexFailed;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, linux.SIOCGIFINDEX, @intFromPtr(&ifr))) != .SUCCESS)
return error.IfIndexFailed;
const ifindex: i32 = ifr.ifru.ivalue;
var ignore_outgoing: u32 = 1;
_ = linux.setsockopt(fd, linux.SOL.PACKET, linux.PACKET.IGNORE_OUTGOING, std.mem.asBytes(&ignore_outgoing), @sizeOf(u32));
var sll = std.mem.zeroes(linux.sockaddr.ll);
sll.family = linux.AF.PACKET;
sll.protocol = std.mem.nativeToBig(u16, @as(u16, linux.ETH.P.IP));
sll.ifindex = ifindex;
if (linux.errno(linux.bind(fd, @ptrCast(&sll), @sizeOf(linux.sockaddr.ll))) != .SUCCESS)
return error.BindFailed;
return .{ .fd = fd, .quiet_ms = quiet_ms, .deadline_ns = monoNow() + total_budget_ns };
}
pub fn recv(self: *Receiver, buf: []u8) ?usize {
const quiet: u64 = if (self.quiet_ms > 0) @intCast(self.quiet_ms) else 0;
while (true) {
const now = monoNow();
if (now >= self.deadline_ns) return null;
const remaining_ms = (self.deadline_ns - now) / NS_PER_MS;
const timeout: i32 = if (remaining_ms >= quiet) @intCast(quiet) else @intCast(remaining_ms);
var pfd = [_]linux.pollfd{.{ .fd = self.fd, .events = linux.POLL.IN, .revents = 0 }};
const pr = linux.poll(&pfd, 1, timeout);
switch (linux.errno(pr)) {
.SUCCESS => {},
.INTR => continue,
else => return null,
}
if (pr == 0) return null;
const rc = linux.recvfrom(self.fd, buf.ptr, buf.len, 0, null, null);
switch (linux.errno(rc)) {
.SUCCESS => return @intCast(rc),
.INTR, .AGAIN => continue,
else => return null,
}
}
}
pub fn close(self: *Receiver) void {
_ = linux.close(self.fd);
}
};
const test_key = [16]u8{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
};
const our_ip: u32 = 0x0a000001;
const our_port: u16 = 40000;
const target_ip: u32 = 0x08080808;
fn buildReply(buf: *[54]u8, sport: u16, ack: u32, flags: u8) void {
@memset(buf, 0);
std.mem.writeInt(u16, buf[12..14], 0x0800, .big);
buf[14] = 0x45;
buf[14 + 9] = 6;
std.mem.writeInt(u32, buf[14 + 12 ..][0..4], target_ip, .big);
std.mem.writeInt(u32, buf[14 + 16 ..][0..4], our_ip, .big);
std.mem.writeInt(u16, buf[34..36], sport, .big);
std.mem.writeInt(u16, buf[36..38], our_port, .big);
std.mem.writeInt(u32, buf[42..46], ack, .big);
buf[47] = flags;
}
const FakeSource = struct {
frames: []const []const u8,
idx: usize = 0,
fn recv(self: *FakeSource, buf: []u8) ?usize {
if (self.idx >= self.frames.len) return null;
const f = self.frames[self.idx];
self.idx += 1;
@memcpy(buf[0..f.len], f);
return f.len;
}
};
const CollectSink = struct {
list: *std.ArrayList(Result),
allocator: std.mem.Allocator,
fn emit(self: *CollectSink, r: Result) void {
self.list.append(self.allocator, r) catch {};
}
};
test "engine classifies, dedups, and emits each found host once" {
const ck = cookie.Cookie.init(test_key);
const seq80 = ck.seq(target_ip, 80, our_ip, our_port);
const seq81 = ck.seq(target_ip, 81, our_ip, our_port);
var open_a: [54]u8 = undefined;
var open_b: [54]u8 = undefined;
var closed_c: [54]u8 = undefined;
buildReply(&open_a, 80, seq80 +% 1, 0x12);
buildReply(&open_b, 80, seq80 +% 1, 0x12);
buildReply(&closed_c, 81, seq81 +% 1, 0x14);
const frames = [_][]const u8{ &open_a, &open_b, &closed_c };
var dd = try dedup.Dedup.init(std.testing.allocator, 16);
defer dd.deinit();
var list: std.ArrayList(Result) = .empty;
defer list.deinit(std.testing.allocator);
var src = FakeSource{ .frames = &frames };
var sink = CollectSink{ .list = &list, .allocator = std.testing.allocator };
run(&src, ck, &dd, &sink);
try std.testing.expectEqual(@as(usize, 2), list.items.len);
try std.testing.expectEqual(State.open, list.items[0].state);
try std.testing.expectEqual(@as(u16, 80), list.items[0].port);
try std.testing.expectEqual(State.closed, list.items[1].state);
try std.testing.expectEqual(@as(u16, 81), list.items[1].port);
}
fn drainConsumer(io: std.Io, queue: *std.Io.Queue(Result), out: *std.ArrayList(Result), allocator: std.mem.Allocator) void {
while (true) {
const r = queue.getOne(io) catch break;
out.append(allocator, r) catch {};
}
}
test "Io.Queue hands the deduped set from a producer to a consumer fiber" {
var threaded = std.Io.Threaded.init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const ck = cookie.Cookie.init(test_key);
const seq80 = ck.seq(target_ip, 80, our_ip, our_port);
const seq81 = ck.seq(target_ip, 81, our_ip, our_port);
var open_a: [54]u8 = undefined;
var open_b: [54]u8 = undefined;
var closed_c: [54]u8 = undefined;
buildReply(&open_a, 80, seq80 +% 1, 0x12);
buildReply(&open_b, 80, seq80 +% 1, 0x12);
buildReply(&closed_c, 81, seq81 +% 1, 0x14);
const frames = [_][]const u8{ &open_a, &open_b, &closed_c };
var dd = try dedup.Dedup.init(std.testing.allocator, 16);
defer dd.deinit();
var out: std.ArrayList(Result) = .empty;
defer out.deinit(std.testing.allocator);
var qbuf: [8]Result = undefined;
var queue = std.Io.Queue(Result).init(&qbuf);
var consumer = io.async(drainConsumer, .{ io, &queue, &out, std.testing.allocator });
var src = FakeSource{ .frames = &frames };
var sink = QueueSink{ .queue = &queue, .io = io };
run(&src, ck, &dd, &sink);
queue.close(io);
consumer.await(io);
try std.testing.expectEqual(@as(usize, 2), out.items.len);
}

View File

@ -0,0 +1,155 @@
// ©AngelaMos | 2026
// scancmd.zig
const std = @import("std");
const targets = @import("targets");
const template = @import("template");
const ratelimit = @import("ratelimit");
const afpacket = @import("afpacket");
const cookie = @import("cookie");
const tx = @import("tx");
const rx = @import("rx");
const dedup = @import("dedup");
const netutil = @import("netutil");
const default_iface = "lo";
const default_rate: u64 = 10_000;
const default_src_port: u16 = 40_000;
const default_wait_ms: i32 = 2_000;
const rx_max_drain_ms: i32 = 60_000;
const ns_per_ms: u64 = 1_000_000;
const default_ports = [_]u16{80};
const dedup_capacity: usize = 1024;
const queue_capacity: usize = 256;
const need_cap_hint =
"scan: need CAP_NET_RAW + CAP_NET_ADMIN. Grant once, then re-run (no sudo):\n" ++
" sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela\nSkipping.\n";
fn stateLabel(s: rx.State) []const u8 {
return switch (s) {
.open => "OPEN",
.closed => "CLOSED",
.filtered => "FILTERED",
};
}
fn consume(io: std.Io, queue: *std.Io.Queue(rx.Result), out: *std.ArrayList(rx.Result), allocator: std.mem.Allocator) void {
while (true) {
const r = queue.getOne(io) catch break;
out.append(allocator, r) catch {};
}
}
pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) !void {
var buf: [1024]u8 = undefined;
var fw = std.Io.File.stdout().writer(io, &buf);
const out = &fw.interface;
const target_text = netutil.getFlag(args, "--target") orelse {
try out.writeAll("scan: --target <cidr> is required (e.g. --target 10.0.0.0/24)\n");
try out.flush();
return;
};
const ifname = netutil.getFlag(args, "--iface") orelse default_iface;
const rate = if (netutil.getFlag(args, "--rate")) |r| try std.fmt.parseInt(u64, r, 10) else default_rate;
const src_port = if (netutil.getFlag(args, "--src-port")) |p| try std.fmt.parseInt(u16, p, 10) else default_src_port;
const wait_ms = if (netutil.getFlag(args, "--wait")) |w| try std.fmt.parseInt(i32, w, 10) else default_wait_ms;
const ports = if (netutil.getFlag(args, "--ports")) |p| try netutil.parsePorts(allocator, p) else try allocator.dupe(u16, &default_ports);
const gw_mac = if (netutil.getFlag(args, "--gw-mac")) |m| try netutil.parseMac(m) else [_]u8{0} ** 6;
const src_ip = if (netutil.getFlag(args, "--src-ip")) |s| try netutil.parseIpv4(s) else try netutil.resolveSrcIp(ifname);
const src_mac = try netutil.resolveSrcMac(ifname);
var seed: u64 = undefined;
if (netutil.getFlag(args, "--seed")) |s| {
seed = try std.fmt.parseInt(u64, s, 10);
} else {
var seed_bytes: [8]u8 = undefined;
try io.randomSecure(&seed_bytes);
seed = std.mem.readInt(u64, &seed_bytes, .little);
}
const cidr = try targets.parseCidr(target_text);
var eng = try targets.Engine.init(allocator, &.{cidr}, ports, seed);
defer eng.deinit();
const count = if (netutil.getFlag(args, "--count")) |c| try std.fmt.parseInt(u64, c, 10) else eng.total;
const ck = try cookie.Cookie.random(io);
const tmpl = template.SynTemplate.init(.{
.src_mac = src_mac,
.dst_mac = gw_mac,
.src_ip = src_ip,
.src_port = src_port,
.cookie = ck,
});
var bucket = ratelimit.TokenBucket.init(rate, rate);
var backend = afpacket.Backend.open(ifname, .{}) catch |err| switch (err) {
error.NeedCapNetRaw => {
try out.writeAll(need_cap_hint);
try out.flush();
return;
},
else => return err,
};
defer backend.close();
const rx_budget_ns: u64 = @as(u64, @intCast(@max(wait_ms, rx_max_drain_ms))) * ns_per_ms;
var receiver = rx.Receiver.open(ifname, wait_ms, rx_budget_ns) catch |err| switch (err) {
error.NeedCapNetRaw => {
try out.writeAll(need_cap_hint);
try out.flush();
return;
},
else => return err,
};
defer receiver.close();
var dd = try dedup.Dedup.init(allocator, dedup_capacity);
defer dd.deinit();
var qbuf: [queue_capacity]rx.Result = undefined;
var queue = std.Io.Queue(rx.Result).init(&qbuf);
var found_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer found_arena.deinit();
const found_alloc = found_arena.allocator();
var found: std.ArrayList(rx.Result) = .empty;
var clock = netutil.RealClock{};
const t0 = clock.now();
var consumer = io.async(consume, .{ io, &queue, &found, found_alloc });
const sent = tx.run(&eng, &tmpl, &bucket, &backend, &clock, count);
var sink = rx.QueueSink{ .queue = &queue, .io = io };
rx.run(&receiver, ck, &dd, &sink);
queue.close(io);
consumer.await(io);
const elapsed_ns = clock.now() - t0;
const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000_000.0;
var open_n: usize = 0;
var closed_n: usize = 0;
var filtered_n: usize = 0;
for (found.items) |r| {
const ip = r.ip;
try out.print("{d}.{d}.{d}.{d}:{d} {s}\n", .{
(ip >> 24) & 0xff, (ip >> 16) & 0xff, (ip >> 8) & 0xff, ip & 0xff, r.port, stateLabel(r.state),
});
switch (r.state) {
.open => open_n += 1,
.closed => closed_n += 1,
.filtered => filtered_n += 1,
}
}
try out.print(
"scan: sent {d} SYN on {s} in {d:.3}s; {d} open, {d} closed, {d} filtered ({d} replies)\n",
.{ sent, ifname, elapsed_s, open_n, closed_n, filtered_n, found.items.len },
);
try out.flush();
}

View File

@ -2,105 +2,27 @@
// txcmd.zig
const std = @import("std");
const linux = std.os.linux;
const targets = @import("targets");
const template = @import("template");
const ratelimit = @import("ratelimit");
const afpacket = @import("afpacket");
const cookie = @import("cookie");
const tx = @import("tx");
const netutil = @import("netutil");
const getFlag = netutil.getFlag;
const parseIpv4 = netutil.parseIpv4;
const parseMac = netutil.parseMac;
const parsePorts = netutil.parsePorts;
const resolveSrcIp = netutil.resolveSrcIp;
const resolveSrcMac = netutil.resolveSrcMac;
const RealClock = netutil.RealClock;
const default_iface = "lo";
const default_rate: u64 = 10_000;
const default_src_port: u16 = 40_000;
const default_ports = [_]u16{80};
const RealClock = struct {
pub fn now(_: *RealClock) u64 {
var ts: linux.timespec = undefined;
_ = linux.clock_gettime(.MONOTONIC, &ts);
return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
}
pub fn sleepNs(_: *RealClock, ns: u64) void {
const ts = linux.timespec{
.sec = @intCast(ns / 1_000_000_000),
.nsec = @intCast(ns % 1_000_000_000),
};
_ = linux.nanosleep(&ts, null);
}
};
fn getFlag(args: []const []const u8, name: []const u8) ?[]const u8 {
var i: usize = 0;
while (i + 1 < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], name)) return args[i + 1];
}
return null;
}
fn parseIpv4(text: []const u8) !u32 {
var addr: u32 = 0;
var octets: usize = 0;
var it = std.mem.splitScalar(u8, text, '.');
while (it.next()) |part| {
if (octets == 4) return error.InvalidIpv4;
const octet = std.fmt.parseInt(u8, part, 10) catch return error.InvalidIpv4;
addr = (addr << 8) | octet;
octets += 1;
}
if (octets != 4) return error.InvalidIpv4;
return addr;
}
fn parseMac(text: []const u8) ![6]u8 {
var mac: [6]u8 = undefined;
var octets: usize = 0;
var it = std.mem.splitScalar(u8, text, ':');
while (it.next()) |part| {
if (octets == 6) return error.InvalidMac;
mac[octets] = std.fmt.parseInt(u8, part, 16) catch return error.InvalidMac;
octets += 1;
}
if (octets != 6) return error.InvalidMac;
return mac;
}
fn parsePorts(allocator: std.mem.Allocator, text: []const u8) ![]u16 {
var list: std.ArrayList(u16) = .empty;
errdefer list.deinit(allocator);
var it = std.mem.splitScalar(u8, text, ',');
while (it.next()) |part| {
if (part.len == 0) continue;
const port = std.fmt.parseInt(u16, part, 10) catch return error.InvalidPort;
try list.append(allocator, port);
}
if (list.items.len == 0) return error.InvalidPort;
return list.toOwnedSlice(allocator);
}
fn ifaceQuery(ifname: []const u8, request: u32) !linux.sockaddr {
const rc_sock = linux.socket(linux.AF.INET, linux.SOCK.DGRAM, 0);
if (linux.errno(rc_sock) != .SUCCESS) return error.ResolveSocketFailed;
const fd: i32 = @intCast(rc_sock);
defer _ = linux.close(fd);
var ifr = std.mem.zeroes(linux.ifreq);
if (ifname.len >= ifr.ifrn.name.len) return error.IfNameTooLong;
@memcpy(ifr.ifrn.name[0..ifname.len], ifname);
if (linux.errno(linux.ioctl(fd, request, @intFromPtr(&ifr))) != .SUCCESS) return error.IfQueryFailed;
return ifr.ifru.addr;
}
fn resolveSrcIp(ifname: []const u8) !u32 {
const sa = try ifaceQuery(ifname, linux.SIOCGIFADDR);
return std.mem.readInt(u32, sa.data[2..6], .big);
}
fn resolveSrcMac(ifname: []const u8) ![6]u8 {
const sa = try ifaceQuery(ifname, linux.SIOCGIFHWADDR);
return sa.data[0..6].*;
}
pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) !void {
var buf: [512]u8 = undefined;
var fw = std.Io.File.stdout().writer(io, &buf);
@ -165,30 +87,3 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, args: []const []const u8) !
try out.print("tx: sent {d} SYN frames on {s} in {d:.3}s ({d:.0} pps)\n", .{ sent, ifname, elapsed_s, pps });
try out.flush();
}
test "parseIpv4 round-trips dotted quads" {
try std.testing.expectEqual(@as(u32, 0x7f000001), try parseIpv4("127.0.0.1"));
try std.testing.expectEqual(@as(u32, 0x08080808), try parseIpv4("8.8.8.8"));
try std.testing.expectError(error.InvalidIpv4, parseIpv4("1.2.3"));
try std.testing.expectError(error.InvalidIpv4, parseIpv4("256.0.0.1"));
}
test "parseMac parses colon-separated hex" {
try std.testing.expectEqual([6]u8{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff }, try parseMac("aa:bb:cc:dd:ee:ff"));
try std.testing.expectEqual([_]u8{0} ** 6, try parseMac("00:00:00:00:00:00"));
try std.testing.expectError(error.InvalidMac, parseMac("aa:bb:cc"));
}
test "parsePorts parses a comma list and rejects empty" {
const ports = try parsePorts(std.testing.allocator, "80,443,22");
defer std.testing.allocator.free(ports);
try std.testing.expectEqualSlices(u16, &.{ 80, 443, 22 }, ports);
try std.testing.expectError(error.InvalidPort, parsePorts(std.testing.allocator, ""));
}
test "getFlag finds values and tolerates missing" {
const args = [_][]const u8{ "tx", "--iface", "eth0", "--rate", "5000" };
try std.testing.expectEqualStrings("eth0", getFlag(&args, "--iface").?);
try std.testing.expectEqualStrings("5000", getFlag(&args, "--rate").?);
try std.testing.expect(getFlag(&args, "--target") == null);
}