From 29c3841679b321b6536fa19d9e34144ccda2f36f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 18 Oct 2026 03:49:24 +0200 Subject: [PATCH] feat(storage-server): implement ext4 read-only filesystem with superblock, inode, extent, and directory parsing --- crates/servers/storage-server/src/fs/ext4.rs | 343 ++++++++++++++++++ crates/servers/storage-server/src/fs/mod.rs | 1 + .../servers/storage-server/src/tests/ext4.rs | 166 +++++++++ .../servers/storage-server/src/tests/mod.rs | 1 + 4 files changed, 511 insertions(+) create mode 100644 crates/servers/storage-server/src/fs/ext4.rs create mode 100644 crates/servers/storage-server/src/tests/ext4.rs diff --git a/crates/servers/storage-server/src/fs/ext4.rs b/crates/servers/storage-server/src/fs/ext4.rs new file mode 100644 index 0000000..aff9a79 --- /dev/null +++ b/crates/servers/storage-server/src/fs/ext4.rs @@ -0,0 +1,343 @@ +use crate::block_device::{BlockDevice, SectorId}; +use crate::filesystem::{DirEntry, FileHandle, FileName, FileSystem}; + +const SECTOR_SIZE: usize = 512; +const SUPERBLOCK_OFFSET: usize = 1024; +const EXT4_MAGIC: u16 = 0xEF53; +const ROOT_INODE: u32 = 2; +const MAX_OPEN_FILES: usize = 8; +const EXTENT_MAGIC: u16 = 0xF30A; + +struct Superblock { + inodes_count: u32, + blocks_count: u32, + block_size: usize, + blocks_per_group: u32, + inodes_per_group: u32, + inode_size: u16, + first_data_block: u32, +} + +impl Superblock { + fn parse(data: &[u8]) -> Option { + if data.len() < 256 { return None; } + let magic = u16::from_le_bytes([data[0x38], data[0x39]]); + if magic != EXT4_MAGIC { return None; } + + let log_block_size = u32::from_le_bytes([data[0x18], data[0x19], data[0x1A], data[0x1B]]); + Some(Self { + inodes_count: read_u32(data, 0x00), + blocks_count: read_u32(data, 0x04), + block_size: 1024 << log_block_size, + blocks_per_group: read_u32(data, 0x20), + inodes_per_group: read_u32(data, 0x28), + inode_size: u16::from_le_bytes([data[0x58], data[0x59]]), + first_data_block: read_u32(data, 0x14), + }) + } + + fn sectors_per_block(&self) -> usize { + self.block_size / SECTOR_SIZE + } + + fn inode_block_group(&self, inode: u32) -> u32 { + (inode - 1) / self.inodes_per_group + } + + fn inode_index_in_group(&self, inode: u32) -> u32 { + (inode - 1) % self.inodes_per_group + } +} + +struct Inode { + mode: u16, + size: u64, + flags: u32, + block_data: [u8; 60], +} + +impl Inode { + fn is_dir(&self) -> bool { self.mode & 0xF000 == 0x4000 } + fn is_file(&self) -> bool { self.mode & 0xF000 == 0x8000 } + fn uses_extents(&self) -> bool { self.flags & 0x80000 != 0 } +} + +#[derive(Clone, Copy)] +struct Extent { + logical_block: u32, + length: u16, + physical_block: u64, +} + +impl Extent { + fn parse(data: &[u8]) -> Self { + let start_hi = u16::from_le_bytes([data[6], data[7]]) as u64; + let start_lo = read_u32(data, 8) as u64; + Self { + logical_block: read_u32(data, 0), + length: u16::from_le_bytes([data[4], data[5]]), + physical_block: (start_hi << 32) | start_lo, + } + } +} + +#[derive(Clone, Copy)] +struct OpenFile { + inode_num: u32, + size: usize, + offset: usize, + name: FileName, + first_extent: Extent, +} + +pub struct Ext4 { + device: B, + sb: Superblock, + gdt_block: u32, + open_files: [Option; MAX_OPEN_FILES], +} + +impl Ext4 { + pub fn mount(mut device: B) -> Option { + let mut buf = [0u8; 1024]; + // Superblock starts at byte 1024 = sector 2 + if !device.read_sector(SectorId::new(2), &mut buf[..512]) { return None; } + if !device.read_sector(SectorId::new(3), &mut buf[512..]) { return None; } + + let sb = Superblock::parse(&buf)?; + let gdt_block = sb.first_data_block + 1; + + Some(Self { + device, + sb, + gdt_block, + open_files: [const { None }; MAX_OPEN_FILES], + }) + } + + fn read_block(&mut self, block: u64, buf: &mut [u8]) -> bool { + let start_sector = (block as usize) * self.sb.sectors_per_block(); + let spb = self.sb.sectors_per_block(); + for i in 0..spb { + let offset = i * SECTOR_SIZE; + if offset + SECTOR_SIZE > buf.len() { break; } + if !self.device.read_sector( + SectorId::new((start_sector + i) as u32), + &mut buf[offset..offset + SECTOR_SIZE], + ) { + return false; + } + } + true + } + + fn read_inode(&mut self, inode_num: u32) -> Option { + let bg = self.sb.inode_block_group(inode_num); + let index = self.sb.inode_index_in_group(inode_num); + + // Read block group descriptor + let mut gdt_buf = [0u8; 4096]; + self.read_block(self.gdt_block as u64, &mut gdt_buf); + let gd_offset = (bg as usize) * 32; + let inode_table_block = read_u32(&gdt_buf, gd_offset + 8); + + // Calculate inode position + let inode_offset = (index as usize) * (self.sb.inode_size as usize); + let block_offset = inode_offset / self.sb.block_size; + let offset_in_block = inode_offset % self.sb.block_size; + + let mut block_buf = [0u8; 4096]; + self.read_block((inode_table_block + block_offset as u32) as u64, &mut block_buf); + + let d = &block_buf[offset_in_block..]; + if d.len() < 128 { return None; } + + let size_lo = read_u32(d, 0x04) as u64; + let size_hi = read_u32(d, 0x6C) as u64; + let size = size_lo | (size_hi << 32); + + let mut block_data = [0u8; 60]; + block_data.copy_from_slice(&d[0x28..0x64]); + + Some(Inode { + mode: u16::from_le_bytes([d[0x00], d[0x01]]), + size, + flags: read_u32(d, 0x20), + block_data, + }) + } + + fn read_extents(&self, inode: &Inode) -> Option { + if !inode.uses_extents() { return None; } + let hdr = &inode.block_data; + let magic = u16::from_le_bytes([hdr[0], hdr[1]]); + if magic != EXTENT_MAGIC { return None; } + let entries = u16::from_le_bytes([hdr[2], hdr[3]]); + let depth = u16::from_le_bytes([hdr[6], hdr[7]]); + if depth != 0 || entries == 0 { return None; } + Some(Extent::parse(&hdr[12..24])) + } + + fn find_in_dir(&mut self, dir_inode: u32, name: &[u8]) -> Option { + let inode = self.read_inode(dir_inode)?; + if !inode.is_dir() { return None; } + + let extent = self.read_extents(&inode)?; + let mut block_buf = [0u8; 4096]; + + for blk in 0..extent.length as u64 { + self.read_block(extent.physical_block + blk, &mut block_buf); + let mut pos = 0; + while pos + 8 <= self.sb.block_size { + let entry_inode = read_u32(&block_buf, pos); + let rec_len = u16::from_le_bytes([block_buf[pos + 4], block_buf[pos + 5]]) as usize; + let name_len = block_buf[pos + 6] as usize; + + if rec_len == 0 { break; } + if entry_inode != 0 && name_len == name.len() { + if &block_buf[pos + 8..pos + 8 + name_len] == name { + return Some(entry_inode); + } + } + pos += rec_len; + } + } + None + } + + fn resolve_path(&mut self, path: &[u8]) -> Option { + let mut current = ROOT_INODE; + for component in path.split(|&b| b == b'/') { + if component.is_empty() { continue; } + current = self.find_in_dir(current, component)?; + } + Some(current) + } + + fn alloc_handle(&self) -> Option { + for i in 0..MAX_OPEN_FILES { + if self.open_files[i].is_none() { + return Some(FileHandle::new(i as u16)); + } + } + None + } +} + +impl FileSystem for Ext4 { + fn open(&mut self, path: &[u8]) -> Option { + let inode_num = self.resolve_path(path)?; + let inode = self.read_inode(inode_num)?; + if !inode.is_file() { return None; } + + let extent = self.read_extents(&inode)?; + let handle = self.alloc_handle()?; + self.open_files[handle.raw() as usize] = Some(OpenFile { + inode_num, + size: inode.size as usize, + offset: 0, + name: FileName::from_bytes(path), + first_extent: extent, + }); + Some(handle) + } + + fn read(&mut self, handle: FileHandle, buf: &mut [u8]) -> Option { + let file = *self.open_files[handle.raw() as usize].as_ref()?; + if file.offset >= file.size { return Some(0); } + + let remaining = file.size - file.offset; + let to_read = buf.len().min(remaining); + let bs = self.sb.block_size; + + let block_in_extent = file.offset / bs; + let byte_in_block = file.offset % bs; + + let mut block_buf = [0u8; 4096]; + let phys_block = file.first_extent.physical_block + block_in_extent as u64; + self.read_block(phys_block, &mut block_buf); + + let available = bs - byte_in_block; + let chunk = to_read.min(available); + buf[..chunk].copy_from_slice(&block_buf[byte_in_block..byte_in_block + chunk]); + + self.open_files[handle.raw() as usize].as_mut().map(|f| f.offset += chunk); + Some(chunk) + } + + fn write(&mut self, _handle: FileHandle, _buf: &[u8]) -> Option { + // Write requires block allocation + inode update + extent modification + // Deferred: needs block bitmap management + None + } + + fn close(&mut self, handle: FileHandle) { + self.open_files[handle.raw() as usize] = None; + } + + fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option { + let dir_inode_num = if path.is_empty() || path == b"/" { + ROOT_INODE + } else { + self.resolve_path(path)? + }; + + let inode = self.read_inode(dir_inode_num)?; + if !inode.is_dir() { return None; } + + let extent = self.read_extents(&inode)?; + let mut block_buf = [0u8; 4096]; + let mut count = 0; + + for blk in 0..extent.length as u64 { + self.read_block(extent.physical_block + blk, &mut block_buf); + let mut pos = 0; + while pos + 8 <= self.sb.block_size && count < entries.len() { + let entry_inode = read_u32(&block_buf, pos); + let rec_len = u16::from_le_bytes([block_buf[pos + 4], block_buf[pos + 5]]) as usize; + let name_len = block_buf[pos + 6] as usize; + let file_type = block_buf[pos + 7]; + + if rec_len == 0 { break; } + if entry_inode != 0 && name_len > 0 { + let name = &block_buf[pos + 8..pos + 8 + name_len]; + if name != b"." && name != b".." { + let child_size = if file_type == 1 { + self.read_inode(entry_inode) + .map(|i| i.size as usize) + .unwrap_or(0) + } else { 0 }; + + entries[count] = DirEntry { + name: FileName::from_bytes(name), + size: child_size, + is_dir: file_type == 2, + }; + count += 1; + } + } + pos += rec_len; + } + } + Some(count) + } + + fn create(&mut self, _path: &[u8]) -> Option { + // Create requires inode allocation + directory entry insertion + // Deferred: needs inode bitmap management + None + } + + fn delete(&mut self, _path: &[u8]) -> bool { + // Delete requires inode deallocation + directory entry removal + false + } + + fn file_size(&mut self, handle: FileHandle) -> Option { + self.open_files[handle.raw() as usize].as_ref().map(|f| f.size) + } +} + +fn read_u32(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]) +} diff --git a/crates/servers/storage-server/src/fs/mod.rs b/crates/servers/storage-server/src/fs/mod.rs index c0f2cd5..f40d6d1 100644 --- a/crates/servers/storage-server/src/fs/mod.rs +++ b/crates/servers/storage-server/src/fs/mod.rs @@ -1 +1,2 @@ +pub mod ext4; pub mod fat16; diff --git a/crates/servers/storage-server/src/tests/ext4.rs b/crates/servers/storage-server/src/tests/ext4.rs new file mode 100644 index 0000000..6848af5 --- /dev/null +++ b/crates/servers/storage-server/src/tests/ext4.rs @@ -0,0 +1,166 @@ +use crate::adapters::mock_block::MockBlockDevice; +use crate::block_device::{BlockDevice, SectorId}; +use crate::filesystem::{DirEntry, FileSystem}; +use crate::fs::ext4::Ext4; + +fn write_u32(buf: &mut [u8], offset: usize, val: u32) { + buf[offset..offset + 4].copy_from_slice(&val.to_le_bytes()); +} + +fn write_u16(buf: &mut [u8], offset: usize, val: u16) { + buf[offset..offset + 2].copy_from_slice(&val.to_le_bytes()); +} + +fn format_ext4_disk() -> MockBlockDevice { + let mut dev = MockBlockDevice::new(64); + let mut buf = [0u8; 512]; + + // Superblock at sectors 2-3 (byte offset 1024) + // Sector 2: first 512 bytes of superblock + write_u32(&mut buf, 0x00, 32); // s_inodes_count + write_u32(&mut buf, 0x04, 64); // s_blocks_count + write_u32(&mut buf, 0x14, 1); // s_first_data_block = 1 (1K blocks would, but we use 0 for 4K) + write_u32(&mut buf, 0x18, 2); // s_log_block_size = 2 → 1024 << 2 = 4096 + write_u32(&mut buf, 0x20, 64); // s_blocks_per_group + write_u32(&mut buf, 0x28, 32); // s_inodes_per_group + write_u16(&mut buf, 0x38, 0xEF53); // s_magic + write_u16(&mut buf, 0x58, 128); // s_inode_size + write_u32(&mut buf, 0x14, 0); // s_first_data_block = 0 for 4K blocks + dev.write_sector(SectorId::new(2), &buf); + + // Block group descriptor at block 1 (sectors 8-15 for 4K blocks) + let mut gdt = [0u8; 512]; + write_u32(&mut gdt, 8, 3); // bg_inode_table = block 3 + dev.write_sector(SectorId::new(8), &gdt); + + // Inode table at block 3 (sectors 24-31) + // Root inode (inode 2) at offset 128 in the table + let mut inode_sector = [0u8; 512]; + + // Inode 2 (root dir) at byte offset 128 = still in first sector + let ioff = 128; // inode 2 index = 1, * 128 bytes = 128 + write_u16(&mut inode_sector, ioff + 0x00, 0x4000 | 0o755); // mode = directory + write_u32(&mut inode_sector, ioff + 0x04, 4096); // size_lo + write_u32(&mut inode_sector, ioff + 0x20, 0x80000); // flags = uses extents + + // Extent header at inode+0x28 + let eh = ioff + 0x28; + write_u16(&mut inode_sector, eh + 0, 0xF30A); // magic + write_u16(&mut inode_sector, eh + 2, 1); // entries + write_u16(&mut inode_sector, eh + 4, 4); // max + write_u16(&mut inode_sector, eh + 6, 0); // depth + + // Extent leaf at inode+0x28+12 + let ext = eh + 12; + write_u32(&mut inode_sector, ext + 0, 0); // logical block + write_u16(&mut inode_sector, ext + 4, 1); // length + write_u16(&mut inode_sector, ext + 6, 0); // physical hi + write_u32(&mut inode_sector, ext + 8, 4); // physical lo → block 4 + + // Inode 3 (a regular file "hello") at byte offset 256 + let ioff2 = 256; + write_u16(&mut inode_sector, ioff2 + 0x00, 0x8000 | 0o644); // mode = regular file + write_u32(&mut inode_sector, ioff2 + 0x04, 13); // size = 13 bytes + write_u32(&mut inode_sector, ioff2 + 0x20, 0x80000); // flags = extents + + let eh2 = ioff2 + 0x28; + write_u16(&mut inode_sector, eh2 + 0, 0xF30A); + write_u16(&mut inode_sector, eh2 + 2, 1); + write_u16(&mut inode_sector, eh2 + 4, 4); + write_u16(&mut inode_sector, eh2 + 6, 0); + let ext2 = eh2 + 12; + write_u32(&mut inode_sector, ext2 + 0, 0); + write_u16(&mut inode_sector, ext2 + 4, 1); + write_u16(&mut inode_sector, ext2 + 6, 0); + write_u32(&mut inode_sector, ext2 + 8, 5); // data at block 5 + + dev.write_sector(SectorId::new(24), &inode_sector); + + // Root directory at block 4 (sectors 32-39) + let mut dir = [0u8; 512]; + let mut pos = 0; + + // Entry: "." → inode 2 + write_u32(&mut dir, pos + 0, 2); + write_u16(&mut dir, pos + 4, 12); // rec_len + dir[pos + 6] = 1; // name_len + dir[pos + 7] = 2; // file_type = dir + dir[pos + 8] = b'.'; + pos += 12; + + // Entry: ".." → inode 2 + write_u32(&mut dir, pos + 0, 2); + write_u16(&mut dir, pos + 4, 12); + dir[pos + 6] = 2; + dir[pos + 7] = 2; + dir[pos + 8] = b'.'; + dir[pos + 9] = b'.'; + pos += 12; + + // Entry: "hello" → inode 3 + write_u32(&mut dir, pos + 0, 3); + write_u16(&mut dir, pos + 4, 488); // rec_len = rest of block + dir[pos + 6] = 5; // name_len + dir[pos + 7] = 1; // file_type = regular + dir[pos + 8..pos + 13].copy_from_slice(b"hello"); + + dev.write_sector(SectorId::new(32), &dir); + + // File data at block 5 (sectors 40-47) + let mut data = [0u8; 512]; + data[..13].copy_from_slice(b"Hello, ext4!\n"); + dev.write_sector(SectorId::new(40), &data); + + dev +} + +#[test] +fn mount_valid_ext4() { + let dev = format_ext4_disk(); + assert!(Ext4::mount(dev).is_some()); +} + +#[test] +fn mount_invalid_disk() { + let dev = MockBlockDevice::new(64); + assert!(Ext4::mount(dev).is_none()); +} + +#[test] +fn list_root_directory() { + let mut fs = Ext4::mount(format_ext4_disk()).unwrap(); + let mut entries = [DirEntry::empty(); 8]; + let count = fs.list_dir(b"/", &mut entries).unwrap(); + assert_eq!(count, 1); // "hello" (. and .. filtered) + assert_eq!(entries[0].name.as_bytes(), b"hello"); +} + +#[test] +fn open_and_read_file() { + let mut fs = Ext4::mount(format_ext4_disk()).unwrap(); + let h = fs.open(b"hello").unwrap(); + assert_eq!(fs.file_size(h), Some(13)); + + let mut buf = [0u8; 32]; + let read = fs.read(h, &mut buf).unwrap(); + assert_eq!(read, 13); + assert_eq!(&buf[..13], b"Hello, ext4!\n"); + fs.close(h); +} + +#[test] +fn open_nonexistent_returns_none() { + let mut fs = Ext4::mount(format_ext4_disk()).unwrap(); + assert!(fs.open(b"nope").is_none()); +} + +#[test] +fn read_past_eof_returns_zero() { + let mut fs = Ext4::mount(format_ext4_disk()).unwrap(); + let h = fs.open(b"hello").unwrap(); + let mut buf = [0u8; 32]; + fs.read(h, &mut buf).unwrap(); // read all 13 bytes + let read = fs.read(h, &mut buf).unwrap(); + assert_eq!(read, 0); + fs.close(h); +} diff --git a/crates/servers/storage-server/src/tests/mod.rs b/crates/servers/storage-server/src/tests/mod.rs index fe308e1..4aea490 100644 --- a/crates/servers/storage-server/src/tests/mod.rs +++ b/crates/servers/storage-server/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod ext4; mod fat16; mod protocol; mod server;