M10 Phase 4 + 4.5: Userspace Networking and ext4

Two phases in one session: wiring userspace tools to our existing smoltcp network stack, and extending the ext2 driver to handle ext4 images.

Phase 4: Userspace Networking

The kernel already had a fully functional TCP/UDP/DHCP stack (smoltcp + virtio-net), but userspace couldn't see it. ifconfig failed, DNS didn't resolve, wget couldn't connect. The problem wasn't the network stack — it was the missing glue between userspace tools and kernel state.

Network interface ioctls

BusyBox ifconfig doesn't use netlink or /proc/net/ — it opens a socket and fires ioctl commands. A new net_ioctl.rs handles the full set:

#![allow(unused)]
fn main() {
if (cmd & 0xFF00) == 0x8900 {
    return self.sys_net_ioctl(cmd, arg);
}
}

This intercepts the 0x89xx ioctl range before it reaches FileLike::ioctl(). The handler reads ifr_name from the struct ifreq (16 bytes), validates "eth0" or "lo", and dispatches:

ioctlWhat we return
SIOCGIFFLAGSIFF_UP|IFF_RUNNING|IFF_BROADCAST (eth0) or IFF_LOOPBACK (lo)
SIOCGIFADDRIP from INTERFACE.lock().ip_addrs() as sockaddr_in
SIOCGIFNETMASKDerived from CIDR prefix length
SIOCGIFHWADDRMAC from virtio-net driver
SIOCGIFCONFList of both interfaces (for ifconfig -a)
SIOCSIF*Accept silently — kernel manages state

The IP address and netmask come directly from smoltcp's Interface, which is already configured via boot params or DHCP. No new state needed.

Some tools try netlink first, then fall back to ioctls. Returning EAFNOSUPPORT (a new errno, value 97) from socket(AF_NETLINK, ...) triggers this fallback cleanly:

#![allow(unused)]
fn main() {
(AF_NETLINK, _, _) | (AF_PACKET, _, _) => {
    Err(Errno::EAFNOSUPPORT.into())
}
}

/proc/net/ stubs

/proc/net/dev returns a two-header-line + eth0/lo table with zero counters. /proc/net/if_inet6 is empty (no IPv6). Tools like ifconfig and ip check these to discover interfaces.

OpenRC networking

With ioctls working, OpenRC's networking service can run. Config files:

# /etc/network/interfaces
auto eth0
iface eth0 inet static
    address 10.0.2.15
    netmask 255.255.255.0
    gateway 10.0.2.2
# /etc/resolv.conf
nameserver 10.0.2.3

Boot output now shows * Starting networking ... [ ok ].

Phase 4.5: ext4 Read-Only Support

The ext2 driver was 667 lines handling superblock, block groups, inode tables, direct/indirect block pointers, directories, and symlinks. ext4 extends this format with three key features we need to handle for read-only mounting.

Feature flags

ext4 puts three bitmasks in the superblock: compatible, incompatible, and read-only compatible features. The critical rule: if the feature_incompat field has bits we don't understand, we must not mount. This prevents silently misinterpreting on-disk structures.

#![allow(unused)]
fn main() {
const INCOMPAT_SUPPORTED: u32 = INCOMPAT_FILETYPE
    | INCOMPAT_RECOVER | INCOMPAT_JOURNAL_DEV
    | INCOMPAT_EXTENTS | INCOMPAT_64BIT
    | INCOMPAT_FLEX_BG | INCOMPAT_MMP
    | INCOMPAT_LARGEDIR | INCOMPAT_CSUM_SEED;

if sb.feature_incompat & !INCOMPAT_SUPPORTED != 0 {
    return None;  // refuse to mount
}
}

For read-only, we can ignore compatible and read-only-compatible features entirely. The journal (COMPAT_HAS_JOURNAL) is just another inode we skip. Checksums (RO_COMPAT_METADATA_CSUM) don't affect data reads. HTree directory indexing stores a hash tree alongside the standard linear directory entries, so our existing linear scan still works.

Extent trees

This is the core new data structure. ext2 uses 15 block pointers per inode (12 direct + 3 indirect). ext4 replaces this with an extent tree stored in the same 60-byte i_block area.

Each node has a 12-byte header followed by 12-byte entries:

ExtentHeader (12B): magic=0xF30A, entries, max, depth

At depth 0 (leaf), entries are Extent structs mapping contiguous ranges:

Extent (12B): logical_block, len, start_hi:start_lo

A single extent can cover thousands of contiguous blocks — much more efficient than one-pointer-per-block. At depth > 0, entries are ExtentIdx structs pointing to child blocks in a B-tree.

The resolution path:

#![allow(unused)]
fn main() {
fn resolve_extent_in_node(&self, node_data: &[u8], logical_block: u32, depth_limit: u16) -> Result<u64> {
    let header = ExtentHeader::parse(node_data);
    if header.depth == 0 {
        // Leaf: scan extents for one covering logical_block
        for i in 0..header.entries {
            let ext = Extent::parse(&node_data[12 + i * 12..]);
            if logical_block >= ext.logical_block
               && logical_block < ext.logical_block + ext.block_count() {
                return Ok(ext.physical_start() + offset_within);
            }
        }
        Ok(0)  // sparse hole
    } else {
        // Internal: find child, recurse
        // ...
    }
}
}

The dispatch in read_file_data checks inode flags:

#![allow(unused)]
fn main() {
let block_num = if inode.uses_extents() {
    self.resolve_extent(inode, block_index)?
} else {
    self.resolve_block_ptr(inode, block_index, ptrs_per_block)? as u64
};
}

64-bit group descriptors

When INCOMPAT_64BIT is set, group descriptors grow from 32 to 64 bytes, and the inode_table field becomes 48-bit (low 32 at offset 8, high 16 at offset 40). The superblock's desc_size field (offset 254) gives the exact stride.

What didn't change

The directory entry format is identical between ext2 and ext4. Symlink storage is the same (inline for <= 60 bytes, block-based otherwise — though ext4 symlinks with the extents flag need block-based reads even when small). The mount syscall now accepts "ext2", "ext3", and "ext4" — all routed to the same code path.

Total ext2 crate delta: +150 lines (667 -> ~810). Still #![forbid(unsafe_code)].

Files changed

FileChange
kernel/syscalls/net_ioctl.rsNew: network interface ioctls
kernel/syscalls/ioctl.rsIntercept 0x89xx range + FIONBIO
kernel/syscalls/socket.rsAF_NETLINK/AF_PACKET stubs
kernel/fs/procfs/mod.rs/proc/net/ directory
kernel/fs/procfs/system.rsProcNetDevFile
services/kevlar_ext2/src/lib.rsext4 extents, feature flags, 64-bit
kernel/syscalls/mount.rsAccept "ext3"/"ext4"
kernel/syscalls/statfs.rsAccept "ext3"/"ext4"
libs/kevlar_vfs/src/result.rsEAFNOSUPPORT errno
libs/kevlar_vfs/src/socket_types.rsAF_NETLINK, AF_PACKET
testing/Dockerfileext4 disk image, resolv.conf, network config