feat(storage): ext4-only disk via mkfs.ext4 -d; drop FAT16; feature-gated mount (ro on unknown ro_compat); ext4 read spans blocks, delete clears inode, mkdir counts dir; virtio-blk writes; test-qemu e2fsck gate
This commit is contained in:
172
crates/servers/storage-server/src/tests/ext4_real.rs
Normal file
172
crates/servers/storage-server/src/tests/ext4_real.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! The ext4 driver against images made by the real `mkfs.ext4`, checked by
|
||||
//! the real `e2fsck`. Skipped (with a note) where e2fsprogs is missing.
|
||||
|
||||
extern crate std;
|
||||
|
||||
use crate::filesystem::{DirEntry, FileSystem};
|
||||
use crate::fs::ext4::Ext4;
|
||||
use port_block_device::{BlockDevice, SectorId};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::vec::Vec;
|
||||
|
||||
/// The features the driver does not handle, switched off; see
|
||||
/// scripts/make-disk-image.sh, which formats the real disk the same way.
|
||||
pub const MKFS_FEATURES: &str = "^has_journal,^metadata_csum,^64bit,^dir_index,^huge_file,^orphan_file,^metadata_csum_seed";
|
||||
|
||||
struct FileDisk(Vec<u8>);
|
||||
|
||||
impl BlockDevice for FileDisk {
|
||||
fn sector_size(&self) -> usize {
|
||||
512
|
||||
}
|
||||
fn sector_count(&self) -> usize {
|
||||
self.0.len() / 512
|
||||
}
|
||||
fn read_sector(&mut self, s: SectorId, buf: &mut [u8]) -> bool {
|
||||
let at = s.raw() as usize * 512;
|
||||
match self.0.get(at..at + 512) {
|
||||
Some(src) => {
|
||||
buf[..512].copy_from_slice(src);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
fn write_sector(&mut self, s: SectorId, buf: &[u8]) -> bool {
|
||||
let at = s.raw() as usize * 512;
|
||||
match self.0.get_mut(at..at + 512) {
|
||||
Some(dst) => {
|
||||
dst.copy_from_slice(&buf[..512]);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(std::format!("kos-ext4-{}-{}", name, std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("root/docs")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// An 8 MiB image of `root`, or `None` without mkfs.ext4.
|
||||
fn mkfs(dir: &Path, features: &str) -> Option<Vec<u8>> {
|
||||
let img = dir.join("disk.img");
|
||||
let ok = Command::new("mkfs.ext4")
|
||||
.args(["-q", "-F", "-b", "4096", "-O", features, "-d"])
|
||||
.arg(dir.join("root"))
|
||||
.arg(&img)
|
||||
.arg("8M")
|
||||
.status()
|
||||
.ok()?
|
||||
.success();
|
||||
assert!(ok, "mkfs.ext4 failed");
|
||||
Some(std::fs::read(img).unwrap())
|
||||
}
|
||||
|
||||
/// e2fsck's verdict on `image`: true when it finds nothing to fix.
|
||||
fn fsck_clean(dir: &Path, image: &[u8]) -> (bool, std::string::String) {
|
||||
let img = dir.join("after.img");
|
||||
std::fs::write(&img, image).unwrap();
|
||||
let out = Command::new("e2fsck").args(["-fn"]).arg(&img).output().unwrap();
|
||||
(out.status.success(), std::string::String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn setup(name: &str) -> Option<(PathBuf, Vec<u8>)> {
|
||||
let dir = scratch(name);
|
||||
std::fs::write(dir.join("root/hello.txt"), b"hello ext4\n").unwrap();
|
||||
let numbers: std::string::String = (1..=3000).map(|i| std::format!("{}\n", i)).collect();
|
||||
std::fs::write(dir.join("root/docs/numbers.txt"), numbers).unwrap();
|
||||
match mkfs(&dir, MKFS_FEATURES) {
|
||||
Some(img) => Some((dir, img)),
|
||||
None => {
|
||||
std::eprintln!("mkfs.ext4 not found; skipping");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn names(fs: &mut Ext4<FileDisk>, path: &[u8]) -> Vec<std::string::String> {
|
||||
let mut entries = [DirEntry::empty(); 32];
|
||||
let n = fs.list_dir(path, &mut entries).expect("list");
|
||||
entries[..n].iter().map(|e| std::string::String::from_utf8_lossy(e.name.as_bytes()).into_owned()).collect()
|
||||
}
|
||||
|
||||
fn read_all(fs: &mut Ext4<FileDisk>, path: &[u8]) -> Vec<u8> {
|
||||
let h = fs.open(path).expect("open");
|
||||
let mut out = Vec::new();
|
||||
let mut buf = [0u8; 1000];
|
||||
loop {
|
||||
let n = fs.read(h, &mut buf).expect("read");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
fs.close(h);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_what_mkfs_wrote() {
|
||||
let Some((_, img)) = setup("read") else { return };
|
||||
let mut fs = Ext4::mount(FileDisk(img)).expect("mount");
|
||||
let root = names(&mut fs, b"/");
|
||||
assert!(root.iter().any(|n| n == "hello.txt") && root.iter().any(|n| n == "docs"), "{root:?}");
|
||||
assert_eq!(read_all(&mut fs, b"/hello.txt"), b"hello ext4\n");
|
||||
let numbers = read_all(&mut fs, b"/docs/numbers.txt");
|
||||
assert_eq!(numbers.len(), (1..=3000).map(|i: u32| std::format!("{i}").len() + 1).sum::<usize>());
|
||||
assert!(numbers.ends_with(b"2999\n3000\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_writes_passes_e2fsck() {
|
||||
let Some((dir, img)) = setup("write") else { return };
|
||||
let mut fs = Ext4::mount(FileDisk(img)).expect("mount");
|
||||
assert!(fs.mkdir(b"/notes"));
|
||||
let h = fs.create(b"/notes/today.txt").expect("create");
|
||||
let text: Vec<u8> = (0..9000).map(|i| b'a' + (i % 26) as u8).collect();
|
||||
assert_eq!(fs.write(h, &text), Some(text.len()));
|
||||
fs.close(h);
|
||||
assert!(fs.delete(b"/hello.txt"));
|
||||
assert_eq!(read_all(&mut fs, b"/notes/today.txt"), text);
|
||||
let image = fs.into_device().0;
|
||||
let (clean, report) = fsck_clean(&dir, &image);
|
||||
assert!(clean, "e2fsck found problems:\n{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disk_with_features_it_cannot_handle_is_refused() {
|
||||
let dir = scratch("refuse");
|
||||
std::fs::write(dir.join("root/x"), b"x").unwrap();
|
||||
let Some(img) = mkfs(&dir, "metadata_csum,64bit") else { return };
|
||||
assert!(Ext4::mount(FileDisk(img)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disk_it_could_read_but_not_keep_consistent_is_read_only() {
|
||||
let dir = scratch("readonly");
|
||||
std::fs::write(dir.join("root/x"), b"x").unwrap();
|
||||
// Checksums it would not update, and a journal.
|
||||
let Some(img) = mkfs(&dir, "^64bit,^metadata_csum_seed,metadata_csum,has_journal") else { return };
|
||||
let mut fs = Ext4::mount(FileDisk(img)).expect("readable");
|
||||
assert!(!fs.writable());
|
||||
assert_eq!(read_all(&mut fs, b"/x"), b"x");
|
||||
let Some((_, img)) = setup("writable") else { return };
|
||||
assert!(Ext4::mount(FileDisk(img)).unwrap().writable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_read_fills_the_buffer_across_blocks() {
|
||||
let Some((_, img)) = setup("bigread") else { return };
|
||||
let mut fs = Ext4::mount(FileDisk(img)).expect("mount");
|
||||
let h = fs.open(b"docs/numbers.txt").expect("open");
|
||||
let mut buf = std::vec![0u8; 10_000];
|
||||
assert_eq!(fs.read(h, &mut buf), Some(10_000));
|
||||
let total = fs.file_size(h).unwrap();
|
||||
assert_eq!(fs.read(h, &mut buf), Some(total - 10_000));
|
||||
assert_eq!(fs.read(h, &mut buf), Some(0));
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
use crate::adapters::mock_block::MockBlockDevice;
|
||||
use crate::filesystem::{DirEntry, FileSystem};
|
||||
use crate::fs::fat16::Fat16;
|
||||
use port_block_device::{BlockDevice, SectorId};
|
||||
|
||||
fn format_fat16_disk(sectors: usize) -> MockBlockDevice {
|
||||
let mut dev = MockBlockDevice::new(sectors);
|
||||
let mut boot = [0u8; 512];
|
||||
|
||||
// BPB
|
||||
boot[11] = 0x00;
|
||||
boot[12] = 0x02; // bytes_per_sector = 512
|
||||
boot[13] = 1; // sectors_per_cluster = 1
|
||||
boot[14] = 1;
|
||||
boot[15] = 0; // reserved_sectors = 1
|
||||
boot[16] = 1; // num_fats = 1
|
||||
boot[17] = 16;
|
||||
boot[18] = 0; // root_entry_count = 16
|
||||
boot[19] = sectors as u8; // total_sectors
|
||||
boot[20] = (sectors >> 8) as u8;
|
||||
boot[22] = 1;
|
||||
boot[23] = 0; // sectors_per_fat = 1
|
||||
|
||||
// boot signature
|
||||
boot[510] = 0x55;
|
||||
boot[511] = 0xAA;
|
||||
|
||||
dev.write_sector(SectorId::new(0), &boot);
|
||||
|
||||
// FAT sector (sector 1) — all zeros = all free
|
||||
let fat = [0u8; 512];
|
||||
dev.write_sector(SectorId::new(1), &fat);
|
||||
|
||||
dev
|
||||
}
|
||||
|
||||
fn mount_test_disk() -> Fat16<MockBlockDevice> {
|
||||
let dev = format_fat16_disk(32);
|
||||
Fat16::mount(dev).expect("mount failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_valid_disk() {
|
||||
let dev = format_fat16_disk(32);
|
||||
assert!(Fat16::mount(dev).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_invalid_disk() {
|
||||
let dev = MockBlockDevice::new(32); // no boot signature
|
||||
assert!(Fat16::mount(dev).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_open_file() {
|
||||
let mut fs = mount_test_disk();
|
||||
let h = fs.create(b"hello.txt").expect("create");
|
||||
fs.close(h);
|
||||
|
||||
let h2 = fs.open(b"hello.txt").expect("open");
|
||||
assert_eq!(fs.file_size(h2), Some(0));
|
||||
fs.close(h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_file() {
|
||||
let mut fs = mount_test_disk();
|
||||
let h = fs.create(b"data.bin").expect("create");
|
||||
|
||||
let data = b"Hello, FAT16!";
|
||||
let written = fs.write(h, data).expect("write");
|
||||
assert_eq!(written, data.len());
|
||||
|
||||
fs.close(h);
|
||||
|
||||
let h2 = fs.open(b"data.bin").expect("open");
|
||||
assert_eq!(fs.file_size(h2), Some(data.len()));
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
let read = fs.read(h2, &mut buf).expect("read");
|
||||
assert_eq!(read, data.len());
|
||||
assert_eq!(&buf[..read], data);
|
||||
fs.close(h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_file() {
|
||||
let mut fs = mount_test_disk();
|
||||
fs.create(b"temp.txt").map(|h| fs.close(h));
|
||||
assert!(fs.delete(b"temp.txt"));
|
||||
assert!(fs.open(b"temp.txt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_directory() {
|
||||
let mut fs = mount_test_disk();
|
||||
fs.create(b"a.txt").map(|h| fs.close(h));
|
||||
fs.create(b"b.txt").map(|h| fs.close(h));
|
||||
|
||||
let mut entries = [DirEntry::empty(); 16];
|
||||
let count = fs.list_dir(b"/", &mut entries).expect("list");
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_nonexistent_returns_none() {
|
||||
let mut fs = mount_test_disk();
|
||||
assert!(fs.open(b"nope.txt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_duplicate_returns_none() {
|
||||
let mut fs = mount_test_disk();
|
||||
fs.create(b"dup.txt").map(|h| fs.close(h));
|
||||
assert!(fs.create(b"dup.txt").is_none());
|
||||
}
|
||||
|
||||
// ---- hostile input: handles from clients, geometry and chains from disk ----
|
||||
|
||||
fn with_boot_byte(sectors: usize, index: usize, value: u8) -> MockBlockDevice {
|
||||
let mut dev = format_fat16_disk(sectors);
|
||||
let mut boot = [0u8; 512];
|
||||
dev.read_sector(SectorId::new(0), &mut boot);
|
||||
boot[index] = value;
|
||||
dev.write_sector(SectorId::new(0), &boot);
|
||||
dev
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_rejects_geometry_the_driver_cannot_use() {
|
||||
// sectors_per_cluster 0 or 3, bytes_per_sector 513 or 0, no FATs, no
|
||||
// FAT sectors
|
||||
for (index, value) in [(13, 0), (13, 3), (11, 1), (12, 0), (16, 0), (22, 0)] {
|
||||
let dev = with_boot_byte(32, index, value);
|
||||
assert!(
|
||||
Fat16::mount(dev).is_none(),
|
||||
"byte {index} = {value} accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_past_the_table_are_rejected_not_indexed() {
|
||||
use crate::filesystem::FileHandle;
|
||||
let mut fs = mount_test_disk();
|
||||
let mut buf = [0u8; 16];
|
||||
for raw in [8, 9, u16::MAX] {
|
||||
let h = FileHandle::new(raw);
|
||||
assert_eq!(fs.read(h, &mut buf), None);
|
||||
assert_eq!(fs.write(h, b"x"), None);
|
||||
assert_eq!(fs.file_size(h), None);
|
||||
fs.close(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a root-directory entry for `name` at `cluster` with `size`.
|
||||
fn plant_entry(dev: &mut MockBlockDevice, name: &[u8; 11], cluster: u16, size: u32) {
|
||||
// root dir starts after 1 reserved + 1 FAT sector
|
||||
let mut dir = [0u8; 512];
|
||||
dir[..11].copy_from_slice(name);
|
||||
dir[11] = 0x20;
|
||||
dir[26..28].copy_from_slice(&cluster.to_le_bytes());
|
||||
dir[28..32].copy_from_slice(&size.to_le_bytes());
|
||||
dev.write_sector(SectorId::new(2), &dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_pointing_at_cluster_zero_reads_nothing() {
|
||||
let mut dev = format_fat16_disk(32);
|
||||
plant_entry(&mut dev, b"BAD BIN", 0, 4096);
|
||||
let mut fs = Fat16::mount(dev).unwrap();
|
||||
let h = fs.open(b"bad.bin").expect("open");
|
||||
let mut buf = [0u8; 64];
|
||||
assert_eq!(fs.read(h, &mut buf), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_file_with_a_cyclic_chain_terminates() {
|
||||
let mut dev = format_fat16_disk(32);
|
||||
// cluster 2 -> 3 -> 2 -> ...
|
||||
let mut fat = [0u8; 512];
|
||||
fat[4..6].copy_from_slice(&3u16.to_le_bytes());
|
||||
fat[6..8].copy_from_slice(&2u16.to_le_bytes());
|
||||
dev.write_sector(SectorId::new(1), &fat);
|
||||
plant_entry(&mut dev, b"LOOP BIN", 2, 4096);
|
||||
let mut fs = Fat16::mount(dev).unwrap();
|
||||
assert!(fs.delete(b"loop.bin"));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
mod ext4;
|
||||
mod fat16;
|
||||
mod ext4_real;
|
||||
mod protocol;
|
||||
mod server;
|
||||
|
||||
Reference in New Issue
Block a user