diff --git a/Cargo.lock b/Cargo.lock index 204a0c3f..166711e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ version = "0.1.0" dependencies = [ "chacha20poly1305", "hkdf", + "libc", "num-complex", "rand_core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4ed06460..896f9294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ num-complex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" + [dev-dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/gen/rust/tun_device.rs b/gen/rust/tun_device.rs new file mode 100644 index 00000000..eb5bf799 --- /dev/null +++ b/gen/rust/tun_device.rs @@ -0,0 +1,105 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c + +pub const IOC_NONE: u32 = 0; + +pub const IOC_WRITE: u32 = 1; + +pub const IOC_READ: u32 = 2; + +pub const IOC_DIRSHIFT: u32 = 30; + +pub const IOC_SIZESHIFT: u32 = 16; + +pub const IOC_TYPESHIFT: u32 = 8; + +pub const IOC_NRSHIFT: u32 = 0; + +pub const TUN_MAGIC: u32 = 84; + +pub const SIZEOF_INT: u32 = 4; + +pub const TUNSETIFF: u32 = 1074025674; + +pub const TUNSETPERSIST: u32 = 1074025675; + +pub const TUNSETOWNER: u32 = 1074025676; + +pub const TUNSETLINK: u32 = 1074025677; + +pub const IFF_TUN: u32 = 1; + +pub const IFF_TAP: u32 = 2; + +pub const IFF_NO_PI: u32 = 4096; + +pub const IFF_UP: u32 = 1; + +pub const IFF_RUNNING: u32 = 64; + +pub const PI_PREFIX_LEN: u32 = 4; + +pub const PI_FLAGS_OFFSET: u32 = 0; + +pub const PI_PROTO_OFFSET: u32 = 2; + +pub const IPV4_MIN_HDR: u32 = 20; + +pub const DEFAULT_MTU: u32 = 1500; + +pub const TUN_BUF_SIZE: u32 = 1600; + +pub const IFNAMSIZ: u32 = 16; + +pub const IFREQ_NAME_OFFSET: u32 = 0; + +pub const IFREQ_FLAGS_OFFSET: u32 = 16; + +pub const IFREQ_TOTAL_SIZE: u32 = 40; + +pub fn build_ioctl(dir: u32, type_val: u32, nr: u32, size_val: u32) -> u32 { + return ((((dir << IOC_DIRSHIFT) | (size_val << IOC_SIZESHIFT)) | (type_val << IOC_TYPESHIFT)) | nr); +} + +pub fn expected_tunsetiff() -> u32 { + return build_ioctl(IOC_WRITE, TUN_MAGIC, 202, SIZEOF_INT); +} + +pub fn expected_tunsetowner() -> u32 { + return build_ioctl(IOC_WRITE, TUN_MAGIC, 204, SIZEOF_INT); +} + +pub fn tun_flags_no_pi() -> u32 { + return (IFF_TUN | IFF_NO_PI); +} + +pub fn is_tun(flags: u32) -> bool { + return ((flags & IFF_TUN) != 0); +} + +pub fn has_no_pi(flags: u32) -> bool { + return ((flags & IFF_NO_PI) != 0); +} + +pub fn payload_offset(no_pi: bool) -> u32 { + if no_pi { + return 0; + } + return PI_PREFIX_LEN; +} + +pub fn buf_size(no_pi: bool) -> u32 { + if no_pi { + return DEFAULT_MTU; + } + return (DEFAULT_MTU + PI_PREFIX_LEN); +} + +pub fn is_valid_ipv4_packet(total_len: u32, no_pi: bool) -> bool { + if (total_len < payload_offset(no_pi)) { + return false; + } + let mut payload: u32 = (total_len - payload_offset(no_pi)); + return (payload >= IPV4_MIN_HDR); +} + diff --git a/specs/tun_device.t27 b/specs/tun_device.t27 new file mode 100644 index 00000000..2db53c1d --- /dev/null +++ b/specs/tun_device.t27 @@ -0,0 +1,226 @@ +// TUN device constants and pure logic for Linux /dev/net/tun interface. +// Specs the ioctl numbers, interface flags, and packet-info prefix handling. +// The actual open()/ioctl()/read()/write() syscalls live in src/tun_dev.rs (thin plumbing, L6-allowed). +// Reference: Linux kernel include/uapi/linux/if_tun.h +// phi^2 + phi^-2 = 3 | TRINITY + +module TunDevice { + use base::types; + + // ---- Linux ioctl direction codes (_IOC_NONE=0, _IOC_WRITE=1, _IOC_READ=2) ---- + const IOC_NONE: u32 = 0; + const IOC_WRITE: u32 = 1; + const IOC_READ: u32 = 2; + + const IOC_DIRSHIFT: u32 = 30; + const IOC_SIZESHIFT: u32 = 16; + const IOC_TYPESHIFT: u32 = 8; + const IOC_NRSHIFT: u32 = 0; + + // 'T' magic for TUN ioctls (ASCII 0x54) + const TUN_MAGIC: u32 = 84; + + // sizeof(int) = 4 — TUN ioctls encode the arg size as int, not struct ifreq + const SIZEOF_INT: u32 = 4; + + // ---- TUN ioctl command numbers (from if_tun.h) ---- + // TUNSETIFF = _IOW('T', 202, int) + const TUNSETIFF: u32 = 1074025674; // 0x400454CA + // TUNSETPERSIST = _IOW('T', 203, int) + const TUNSETPERSIST: u32 = 1074025675; // 0x400454CB + // TUNSETOWNER = _IOW('T', 204, int) + const TUNSETOWNER: u32 = 1074025676; // 0x400454CC + // TUNSETLINK = _IOW('T', 205, int) + const TUNSETLINK: u32 = 1074025677; // 0x400454CD + + // ---- Interface request flags (ifr_flags) ---- + const IFF_TUN: u32 = 1; // 0x0001 — TUN device (no Ethernet headers) + const IFF_TAP: u32 = 2; // 0x0002 — TAP device (with Ethernet headers) + const IFF_NO_PI: u32 = 4096; // 0x1000 — no packet-info prefix (4-byte header) + const IFF_UP: u32 = 1; // interface is up (for SIOCSIFFLAGS) + const IFF_RUNNING: u32 = 64; // 0x0040 + + // ---- Packet-info prefix (when IFF_NO_PI is NOT set) ---- + const PI_PREFIX_LEN: u32 = 4; // 4 bytes: flags(2) + proto(2) + const PI_FLAGS_OFFSET: u32 = 0; + const PI_PROTO_OFFSET: u32 = 2; + + // ---- IPv4 constants (mirrors tun.t27) ---- + const IPV4_MIN_HDR: u32 = 20; + const DEFAULT_MTU: u32 = 1500; + const TUN_BUF_SIZE: u32 = 1600; // MTU + 4 bytes PI prefix headroom + + // ---- ifreq layout constants ---- + const IFNAMSIZ: u32 = 16; // max interface name length (including NUL) + const IFREQ_NAME_OFFSET: u32 = 0; // ifr_name starts at byte 0 + const IFREQ_FLAGS_OFFSET: u32 = 16; // ifr_ifru.flags starts at byte 16 + const IFREQ_TOTAL_SIZE: u32 = 40; // sizeof(struct ifreq) on Linux + + // ---- Build the ioctl number from components ---- + // _IOC(dir, type, nr, size) = (dir<<30) | (size<<16) | (type<<8) | nr + fn build_ioctl(dir: u32, type_val: u32, nr: u32, size_val: u32) -> u32 { + return (dir << IOC_DIRSHIFT) | (size_val << IOC_SIZESHIFT) | (type_val << IOC_TYPESHIFT) | nr; + } + + // Verify TUNSETIFF = _IOW('T', 202, int) + fn expected_tunsetiff() -> u32 { + return build_ioctl(IOC_WRITE, TUN_MAGIC, 202, SIZEOF_INT); + } + + // Verify TUNSETOWNER = _IOW('T', 204, int) + fn expected_tunsetowner() -> u32 { + return build_ioctl(IOC_WRITE, TUN_MAGIC, 204, SIZEOF_INT); + } + + // Combine IFF_TUN with IFF_NO_PI — standard flags for IP-over-mesh TUN + fn tun_flags_no_pi() -> u32 { + return IFF_TUN | IFF_NO_PI; + } + + // Is this a TUN (not TAP)? + fn is_tun(flags: u32) -> bool { + return (flags & IFF_TUN) != 0; + } + + // Is IFF_NO_PI set? (no 4-byte prefix on packets) + fn has_no_pi(flags: u32) -> bool { + return (flags & IFF_NO_PI) != 0; + } + + // Packet payload offset: 0 if IFF_NO_PI, 4 if PI prefix present + fn payload_offset(no_pi: bool) -> u32 { + if (no_pi) { + return 0; + } + return PI_PREFIX_LEN; + } + + // Total buffer size needed: MTU + PI prefix (if present) + fn buf_size(no_pi: bool) -> u32 { + if (no_pi) { + return DEFAULT_MTU; + } + return DEFAULT_MTU + PI_PREFIX_LEN; + } + + // Is a packet long enough to contain an IPv4 header after the offset? + fn is_valid_ipv4_packet(total_len: u32, no_pi: bool) -> bool { + if (total_len < payload_offset(no_pi)) { + return false; + } + let payload: u32 = total_len - payload_offset(no_pi); + return payload >= IPV4_MIN_HDR; + } + + // ---- TDD (L4) ---- + + test tunsetiff_value { + assert(expected_tunsetiff() == TUNSETIFF, "TUNSETIFF = 0x400454CA"); + } + + test tunsetowner_value { + assert(expected_tunsetowner() == TUNSETOWNER, "TUNSETOWNER = 0x400454CC"); + } + + test tunsetiff_is_write_direction { + assert((TUNSETIFF >> IOC_DIRSHIFT) == IOC_WRITE, "TUNSETIFF is _IOW (write)"); + } + + test tunsetiff_magic_is_T { + let magic: u32 = (TUNSETIFF >> IOC_TYPESHIFT) & 255; + assert(magic == TUN_MAGIC, "magic byte is 'T' (84)"); + } + + test tunsetiff_nr_is_202 { + let nr: u32 = TUNSETIFF & 255; + assert(nr == 202, "TUNSETIFF nr = 202"); + } + + test tunsetiff_size_is_4 { + let sz: u32 = (TUNSETIFF >> IOC_SIZESHIFT) & 16383; + assert(sz == SIZEOF_INT, "TUNSETIFF size = sizeof(int) = 4"); + } + + test tun_no_pi_flags { + assert(tun_flags_no_pi() == (IFF_TUN | IFF_NO_PI), "IFF_TUN|IFF_NO_PI = 0x1001"); + } + + test tun_no_pi_value { + assert(tun_flags_no_pi() == 4097, "combined = 4097 decimal"); + } + + test is_tun_true_for_tun { + assert(is_tun(tun_flags_no_pi()) == true, "tun flags indicate TUN"); + } + + test is_tun_false_for_tap { + assert(is_tun(IFF_TAP) == false, "TAP is not TUN"); + } + + test has_no_pi_true { + assert(has_no_pi(tun_flags_no_pi()) == true, "NO_PI is set"); + } + + test has_no_pi_false { + assert(has_no_pi(IFF_TUN) == false, "NO_PI not set in plain IFF_TUN"); + } + + test payload_offset_with_no_pi { + assert(payload_offset(true) == 0, "no PI prefix = offset 0"); + } + + test payload_offset_with_pi { + assert(payload_offset(false) == 4, "PI present = skip 4 bytes"); + } + + test buf_size_no_pi { + assert(buf_size(true) == 1500, "buffer = MTU when no PI"); + } + + test buf_size_with_pi { + assert(buf_size(false) == 1504, "buffer = MTU + 4 when PI"); + } + + test valid_ipv4_packet_no_pi { + assert(is_valid_ipv4_packet(20, true) == true, "20 bytes, no PI = valid"); + } + + test valid_ipv4_packet_with_pi { + assert(is_valid_ipv4_packet(24, false) == true, "24 bytes, PI = 20 payload = valid"); + } + + test invalid_short_packet_no_pi { + assert(is_valid_ipv4_packet(10, true) == false, "10 bytes too short"); + } + + test invalid_short_packet_with_pi { + assert(is_valid_ipv4_packet(20, false) == false, "20 bytes minus 4 PI = 16 payload = too short"); + } + + test ifreq_name_is_16_bytes { + assert(IFNAMSIZ == 16, "IFNAMSIZ = 16"); + } + + test ifreq_flags_at_offset_16 { + assert(IFREQ_FLAGS_OFFSET == 16, "ifr_flags at byte 16"); + } + + test ifreq_total_40_bytes { + assert(IFREQ_TOTAL_SIZE == 40, "sizeof(struct ifreq) = 40"); + } + + test tunsetpersist_value { + assert(TUNSETPERSIST == expected_tunsetiff() + 1, "TUNSETPERSIST = TUNSETIFF + 1"); + } + + // ---- invariants ---- + + invariant tunsetiff_is_iow + assert (TUNSETIFF >> IOC_DIRSHIFT) == IOC_WRITE + + invariant tun_flags_combination + assert tun_flags_no_pi() == (IFF_TUN | IFF_NO_PI) + + invariant pi_prefix_is_4_bytes + assert PI_PREFIX_LEN == 4 +} diff --git a/src/lib.rs b/src/lib.rs index 600b6c12..95e7ba4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod modem; pub mod gf16; pub mod daemon; pub mod discovery; +pub mod tun_dev; // Re-export generated mesh components #[path = "../gen/rust/mesh_routing.rs"] @@ -45,6 +46,12 @@ pub mod anomaly_detector; #[path = "../gen/rust/quarantine_manager.rs"] pub mod quarantine_manager; +#[path = "../gen/rust/tun.rs"] +pub mod tun; + +#[path = "../gen/rust/tun_device.rs"] +pub mod tun_device; + // Types used across the crate pub type NodeId = u32; diff --git a/src/tun_dev.rs b/src/tun_dev.rs new file mode 100644 index 00000000..c638607b --- /dev/null +++ b/src/tun_dev.rs @@ -0,0 +1,317 @@ +//! Linux `/dev/net/tun` device wrapper — thin syscall plumbing (L6-allowed). +//! +//! Uses constants from the t27-generated `tun_device` module. This file contains +//! NO business logic: it only opens the TUN device, configures it via ioctl, and +//! provides read/write for IP packets. The routing decision (which mesh node to +//! send to) lives in `router.rs`; the TUN↔router wiring lives in the daemon. +//! +//! Platform: Linux only (`CONFIG_TUN=y` in kernel). On macOS this module exists +//! but `TunDevice::open()` returns an error — tests use a `MockTun` instead. + +#[allow(unused_imports)] +use crate::tun_device; +use std::io; +#[allow(unused_imports)] +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + +#[cfg(target_os = "linux")] +use std::ffi::CString; + +/// A TUN device handle: an open file descriptor to `/dev/net/tun` configured +/// with IFF_TUN | IFF_NO_PI. +pub struct TunDevice { + fd: OwnedFd, + name: String, +} + +/// Errors from TUN device operations. +#[derive(Debug)] +pub enum TunError { + /// `/dev/net/tun` not available (kernel lacks CONFIG_TUN=y on Linux, + /// or running on a non-Linux platform). + NotAvailable, + /// ioctl(TUNSETIFF) failed — usually means the interface name is too long + /// or already in use. + IoctlFailed(i32), + /// read/write I/O error. + Io(io::Error), +} + +impl From for TunError { + fn from(e: io::Error) -> Self { + TunError::Io(e) + } +} + +impl std::fmt::Display for TunError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TunError::NotAvailable => write!(f, "TUN device not available (CONFIG_TUN not set?)"), + TunError::IoctlFailed(errno) => write!(f, "TUNSETIFF ioctl failed (errno {})", errno), + TunError::Io(e) => write!(f, "TUN I/O error: {}", e), + } + } +} + +impl std::error::Error for TunError {} + +/// Build the `ifreq` struct for TUNSETIFF ioctl. +/// Layout: [ifr_name: 16 bytes][ifr_flags: 2 bytes][padding: 22 bytes] = 40 bytes total. +/// ifr_name is the interface name (e.g. "tritun0") NUL-padded to 16 bytes. +#[cfg(target_os = "linux")] +fn build_ifreq(name: &str) -> io::Result<[u8; 40]> { + let mut ifr = [0u8; 40]; + let name_bytes = name.as_bytes(); + if name_bytes.len() >= tun_device::IFNAMSIZ as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("interface name '{}' too long (max {})", name, tun_device::IFNAMSIZ - 1), + )); + } + // Copy name into ifr_name field (bytes 0..15), NUL-terminated + ifr[..name_bytes.len()].copy_from_slice(name_bytes); + // Set ifr_flags = IFF_TUN | IFF_NO_PI at byte offset 16 + let flags = tun_device::tun_flags_no_pi() as u16; + ifr[16] = (flags & 0xFF) as u8; + ifr[17] = (flags >> 8) as u8; + Ok(ifr) +} + +impl TunDevice { + /// Open `/dev/net/tun` and configure it as a TUN device with IFF_NO_PI. + /// Returns a handle for reading/writing raw IP packets. + /// + /// After opening, the caller must configure the interface address and bring + /// it up (e.g. `ip addr add 10.42.0.N/24 dev tritun0 && ip link set tritun0 up`). + #[cfg(target_os = "linux")] + pub fn open(name: &str) -> Result { + use libc::{c_int, close, open as c_open, ioctl, O_RDWR}; + + let path = CString::new("/dev/net/tun").unwrap(); + unsafe { + let fd: c_int = c_open(path.as_ptr(), O_RDWR); + if fd < 0 { + return Err(TunError::NotAvailable); + } + + let ifr = build_ifreq(name)?; + let ret: c_int = ioctl(fd, tun_device::TUNSETIFF as _, ifr.as_ptr() as _); + if ret < 0 { + let errno = *libc::__errno_location(); + close(fd); + return Err(TunError::IoctlFailed(errno)); + } + + // Read back the actual interface name (kernel may have assigned one) + let actual_name = { + let name_bytes = &ifr[0..16]; + let nul_pos = name_bytes.iter().position(|&b| b == 0).unwrap_or(16); + String::from_utf8_lossy(&name_bytes[..nul_pos]).into_owned() + }; + + let owned = OwnedFd::from_raw_fd(fd); + Ok(TunDevice { fd: owned, name: actual_name }) + } + } + + /// On non-Linux platforms, TUN is not available. + #[cfg(not(target_os = "linux"))] + pub fn open(_name: &str) -> Result { + Err(TunError::NotAvailable) + } + + /// The kernel-assigned interface name (e.g. "tritun0"). + pub fn name(&self) -> &str { + &self.name + } + + /// Read one IP packet from the TUN device into `buf`. + /// Returns the number of bytes read. + /// With IFF_NO_PI, the packet starts at byte 0 (no prefix). + #[cfg(target_os = "linux")] + pub fn read_packet(&mut self, buf: &mut [u8]) -> io::Result { + // SAFETY: self.fd is a valid open TUN file descriptor. + let n = unsafe { + libc::read( + self.fd.as_raw_fd(), + buf.as_mut_ptr() as *mut _, + buf.len(), + ) + }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } + } + + /// Read one IP packet — stub on non-Linux (MockTun is used instead). + #[cfg(not(target_os = "linux"))] + pub fn read_packet(&mut self, _buf: &mut [u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "TUN read not available on this platform")) + } + + /// Write one IP packet to the TUN device (delivered locally). + /// With IFF_NO_PI, `pkt` should be the raw IP packet with no prefix. + #[cfg(target_os = "linux")] + pub fn write_packet(&mut self, pkt: &[u8]) -> io::Result { + // SAFETY: self.fd is a valid open TUN file descriptor. + let n = unsafe { + libc::write( + self.fd.as_raw_fd(), + pkt.as_ptr() as *const _, + pkt.len(), + ) + }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } + } + + /// Write one IP packet — stub on non-Linux (MockTun is used instead). + #[cfg(not(target_os = "linux"))] + pub fn write_packet(&mut self, _pkt: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "TUN write not available on this platform")) + } + + /// Configure the interface: set IP address and bring it up. + /// This shells out to `ip` command — simplest approach that works on + /// embedded Linux without requiring netlink bindings. + pub fn configure(&self, ip_addr: &str) -> io::Result<()> { + let name = &self.name; + std::process::Command::new("ip") + .args(["addr", "add", ip_addr, "dev", name]) + .output()?; + std::process::Command::new("ip") + .args(["link", "set", name, "up"]) + .output()?; + Ok(()) + } +} + +/// A mock TUN device for testing — no real fd, just an in-memory buffer. +/// Implements the same read/write interface as `TunDevice`. +pub struct MockTun { + pub tx_buf: std::sync::Arc>>>, + pub rx_buf: std::sync::Arc>>>, +} + +impl MockTun { + pub fn new() -> Self { + MockTun { + tx_buf: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + rx_buf: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())), + } + } + + /// Queue a packet for the read side to consume (simulates incoming from kernel). + pub fn inject_rx(&self, pkt: &[u8]) { + self.rx_buf.lock().unwrap().push_back(pkt.to_vec()); + } + + /// Read a packet (returns WouldBlock if empty). + pub fn read_packet(&self, buf: &mut [u8]) -> io::Result { + let mut rx = self.rx_buf.lock().unwrap(); + match rx.pop_front() { + Some(pkt) => { + let n = pkt.len().min(buf.len()); + buf[..n].copy_from_slice(&pkt[..n]); + Ok(n) + } + None => Err(io::Error::new(io::ErrorKind::WouldBlock, "empty")), + } + } + + /// Write a packet (captures it for test assertions). + pub fn write_packet(&self, pkt: &[u8]) -> io::Result { + self.tx_buf.lock().unwrap().push(pkt.to_vec()); + Ok(pkt.len()) + } + + /// Get all packets written to the TUN (delivered locally). + pub fn delivered(&self) -> Vec> { + self.tx_buf.lock().unwrap().clone() + } +} + +impl Default for MockTun { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_tun_roundtrip() { + let tun = MockTun::new(); + // Inject a fake IP packet (version 4, dst 10.42.0.5) + let pkt = [0x45, 0, 0, 20, 0, 0, 0, 0, 64, 17, 0, 0, 10, 42, 0, 1, 10, 42, 0, 5]; + tun.inject_rx(&pkt); + + let mut buf = [0u8; 1600]; + let n = tun.read_packet(&mut buf).unwrap(); + assert_eq!(n, 20); + assert_eq!(&buf[..20], &pkt[..]); + + // Write a response packet + tun.write_packet(&pkt).unwrap(); + assert_eq!(tun.delivered().len(), 1); + } + + #[test] + fn mock_tun_empty_returns_wouldblock() { + let tun = MockTun::new(); + let mut buf = [0u8; 1600]; + assert!(tun.read_packet(&mut buf).is_err()); + } + + #[test] + #[cfg(target_os = "linux")] + fn ifreq_layout() { + let ifr = build_ifreq("tritun0").unwrap(); + // Name at offset 0 + assert_eq!(&ifr[0..7], b"tritun0"); + assert_eq!(ifr[7], 0); // NUL terminator + // Flags at offset 16: IFF_TUN(1) | IFF_NO_PI(0x1000) = 0x1001 (little-endian) + assert_eq!(ifr[16], 0x01); // low byte + assert_eq!(ifr[17], 0x10); // high byte + // Total size = 40 + assert_eq!(ifr.len(), 40); + } + + #[test] + #[cfg(not(target_os = "linux"))] + fn open_fails_on_non_linux() { + let result = TunDevice::open("tritun0"); + assert!(matches!(result, Err(TunError::NotAvailable))); + } + + #[test] + fn tun_device_constants_from_spec() { + // Verify spec-generated constants are correct + assert_eq!(tun_device::TUNSETIFF, 0x400454CA); + assert_eq!(tun_device::IFF_TUN, 0x0001); + assert_eq!(tun_device::IFF_NO_PI, 0x1000); + assert_eq!(tun_device::tun_flags_no_pi(), 0x1001); + assert_eq!(tun_device::IFNAMSIZ, 16); + assert_eq!(tun_device::IFREQ_FLAGS_OFFSET, 16); + } + + #[test] + fn tun_device_ioctl_verification() { + // The spec verifies that build_ioctl reproduces TUNSETIFF + assert_eq!(tun_device::expected_tunsetiff(), tun_device::TUNSETIFF); + assert_eq!(tun_device::expected_tunsetowner(), tun_device::TUNSETOWNER); + } + + #[test] + fn tun_device_payload_offset() { + assert_eq!(tun_device::payload_offset(true), 0); + assert_eq!(tun_device::payload_offset(false), 4); + } +}