//! exFAT through the VFS. Cargo.toml's test-args attach two exFAT images //! that build.rs makes (snapshot=on, so writes vanish after the test): //! //! vdc target/exfat.img /mnt/disk2 4 KiB clusters, disk3/ + more //! vdg target/exfat-512.img /mnt/disk6 512-byte clusters, small tree //! //! scripts/exfat-check.sh runs this kernel on *writable* copies and then //! checks them with the host's fsck.exfat. #![no_std] #![no_main] #![feature(custom_test_frameworks)] #![test_runner(rustbelt::test_runner)] #![reexport_test_harness_main = "test_main"] extern crate alloc; use alloc::{format, string::String, vec, vec::Vec}; use bootloader::{BootInfo, entry_point}; use core::panic::PanicInfo; use rustbelt::block; use rustbelt::errno::Errno; use rustbelt::fs::{self, InodeKind, exfat::ExfatFs}; entry_point!(main); fn main(boot_info: &'static BootInfo) -> ! { rustbelt::init(); rustbelt::init_memory(boot_info); fs::init(); rustbelt::init_storage(); test_main(); rustbelt::hlt_loop(); } const DISK: &str = "/mnt/disk2"; const SMALL: &str = "/mnt/disk6"; /// The small disk's root is one 512-byte cluster with 1 free entry left, so /// its write tests run in Photos/ (which has room): growing the root is /// tested on its own, because a directory never shrinks again. const SMALL_DIR: &str = "/mnt/disk6/Photos"; fn read(path: &str, offset: usize, len: usize) -> Vec { let mut buf = vec![0u8; len]; let n = fs::lookup(path).unwrap().read_at(offset, &mut buf).unwrap(); buf.truncate(n); buf } fn names(path: &str) -> Vec { let mut names: Vec = fs::lookup(path).unwrap().list().unwrap().into_iter().map(|e| e.name).collect(); names.sort(); names } /// (clusters marked used in the allocation bitmap, PercentInUse), read /// straight from the disk with a few lines of independent parsing: boot /// sector -> root directory -> bitmap entry (0x81). On a freshly made /// volume the bitmap and root are both single, contiguous runs. fn usage(device: &str) -> (u32, u8) { let dev = block::get(device).unwrap(); let mut boot = [0u8; 512]; dev.read_sectors(0, &mut boot).unwrap(); let get32 = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()); let sector_shift = boot[108] as u32; let cluster_bytes = 1usize << (sector_shift + boot[109] as u32); let heap = (get32(&boot, 88) as u64) << sector_shift; let count = get32(&boot, 92); let pos = |cluster: u32| heap + (cluster as u64 - 2) * cluster_bytes as u64; let read = |at: u64, len: usize| { let mut b = vec![0u8; len.div_ceil(512) * 512]; dev.read_sectors(at / 512, &mut b).unwrap(); b }; let root = read(pos(get32(&boot, 96)), cluster_bytes); let e = root.chunks(32).find(|e| e[0] == 0x81).unwrap(); let bitmap = read(pos(get32(e, 20)), count.div_ceil(8) as usize); let used = (0..count as usize).filter(|&i| bitmap[i / 8] & (1 << (i % 8)) != 0).count(); (used as u32, boot[112]) } #[test_case] fn mounted_as_exfat() { let mounts = fs::mounts(); for path in [DISK, SMALL] { assert!(mounts.iter().any(|(p, name)| p == path && *name == "exfat"), "{} not exFAT", path); } // A second instance on the same device, just to read its description. let d = ExfatFs::mount(block::get("vdc").unwrap()).unwrap().describe(); assert_eq!(d, "exFAT volume \"TYLEREXFAT\" (4 KiB clusters, 7680 clusters)"); let d = ExfatFs::mount(block::get("vdg").unwrap()).unwrap().describe(); assert!(d.starts_with("exFAT volume \"SMALLCLUST\" (512 byte clusters"), "{}", d); } #[test_case] fn root_listing_keeps_case_and_long_names() { let long = format!("a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-{}.txt", "x".repeat(20)); let want = vec![ String::from("Long File Name With Spaces.txt"), "MixedCase.TXT".into(), "Photos".into(), long.clone(), "frag-chain.txt".into(), "numbers.txt".into(), "readme.txt".into(), "Ünïcödé café.txt".into(), ]; // (Contains, not equals: tests run in name order, and the copy test // adds files to this directory first.) let have = names(DISK); for name in &want { assert!(have.contains(name), "{} missing from {:?}", name, have); } assert_eq!(read(&format!("{}/{}", DISK, long), 0, 13), b"A name longer"); assert_eq!(fs::lookup(&format!("{}/Photos", DISK)).unwrap().kind(), InodeKind::Directory); } #[test_case] fn lookup_ignores_case() { assert!(read("/mnt/disk2/mixedcase.txt", 0, 100).starts_with(b"MixedCase.TXT: exFAT keeps")); assert!(read("/mnt/disk2/LONG FILE NAME WITH SPACES.TXT", 0, 100).starts_with(b"This file has a long name")); // Non-ASCII: the volume's up-case table maps ü -> Ü, é -> É. assert!(read("/mnt/disk2/ÜNÏCÖDÉ CAFÉ.TXT", 0, 100).starts_with("Ünïcödé names work".as_bytes())); assert_eq!(read("/mnt/disk2/photos/2026 TRIP/Trip Notes.txt", 0, 100), b"Notes from the 2026 trip, two directories down.\n"); assert_eq!(fs::lookup("/mnt/disk2/mixedcase.tx").err(), Some(Errno::ENOENT)); assert_eq!(fs::lookup("/mnt/disk2/readme.txt/x").err(), Some(Errno::ENOTDIR)); } #[test_case] fn contiguous_file() { // numbers.txt: 20 000 lines "exfat line NNNNN\n", one NoFatChain run. let path = "/mnt/disk2/numbers.txt"; assert_eq!(fs::lookup(path).unwrap().size(), 340_000); assert_eq!(read(path, 12345 * 17, 17), b"exfat line 12345\n"); assert_eq!(read(path, 4090, 12), b" 00240\nexfat"); // across a cluster boundary assert_eq!(read(path, 339_983, 100), b"exfat line 19999\n"); // the end assert_eq!(read(path, 400_000, 10), b""); } #[test_case] fn fragmented_file() { // frag-chain.txt uses every other cluster, linked through the FAT. let want: String = (0..2000).map(|i| format!("fragment line {:04}\n", i)).collect(); for disk in [DISK, SMALL] { assert_eq!(fs::read_all(&format!("{}/frag-chain.txt", disk)).unwrap(), want.as_bytes(), "{}", disk); } } #[test_case] fn bad_names() { assert_eq!(fs::create("/mnt/disk2/a:b.txt", InodeKind::File).err(), Some(Errno::EINVAL)); assert_eq!(fs::create("/mnt/disk2/what?.txt", InodeKind::File).err(), Some(Errno::EINVAL)); assert_eq!(fs::create(&format!("/mnt/disk2/{}", "n".repeat(256)), InodeKind::File).err(), Some(Errno::ENAMETOOLONG)); // Same name in other case = the same file. assert_eq!(fs::create("/mnt/disk2/README.TXT", InodeKind::File).err(), Some(Errno::EEXIST)); } /// Create, write across clusters, append, truncate, mkdir, rmdir, unlink: /// afterwards the bitmap and PercentInUse are back where they started. fn write_round_trip(disk: &str, device: &str) { let before = usage(device); let dir = format!("{}/New Folder", disk); fs::create(&dir, InodeKind::Directory).unwrap(); let path = format!("{}/Data File.BIN", dir); let f = fs::create(&path, InodeKind::File).unwrap(); assert_eq!(f.size(), 0); let data: Vec = (0..10_000u32).map(|i| (i * 7 % 251) as u8).collect(); f.write_at(0, &data[..3000]).unwrap(); f.write_at(3000, &data[3000..]).unwrap(); assert_eq!(fs::read_all(&path).unwrap(), data); // Same file, other case. f.write_at(10_000, b"appended").unwrap(); assert_eq!(read(&format!("{}/new folder/data file.bin", disk), 9_998, 20), [&data[9998..], b"appended"].concat()); assert_eq!(fs::remove(&dir, true), Err(Errno::ENOTEMPTY)); f.truncate().unwrap(); assert_eq!(f.size(), 0); f.write_at(0, b"short").unwrap(); assert_eq!(fs::read_all(&path).unwrap(), b"short"); // Writing past the end leaves a gap of zeros. f.write_at(5000, b"!").unwrap(); let all = fs::read_all(&path).unwrap(); assert_eq!(all.len(), 5001); assert!(all[5..5000].iter().all(|&b| b == 0)); fs::remove(&path, false).unwrap(); assert_eq!(names(&dir), Vec::::new()); fs::remove(&dir, true).unwrap(); assert_eq!(fs::lookup(&dir).err(), Some(Errno::ENOENT)); assert_eq!(usage(device), before); } #[test_case] fn write_4k_clusters() { write_round_trip(DISK, "vdc"); } #[test_case] fn write_512_byte_clusters() { write_round_trip(SMALL_DIR, "vdg"); } #[test_case] fn contiguous_file_turns_into_a_fat_chain() { // a.bin gets a cluster, b.bin the very next one. Growing a.bin can't // stay contiguous, so its clusters get FAT links (NoFatChain cleared). let before = usage("vdg"); let a = fs::create("/mnt/disk6/Photos/a.bin", InodeKind::File).unwrap(); a.write_at(0, &[b'a'; 500]).unwrap(); let b = fs::create("/mnt/disk6/Photos/b.bin", InodeKind::File).unwrap(); b.write_at(0, &[b'b'; 1500]).unwrap(); a.write_at(500, &[b'A'; 3000]).unwrap(); let all = fs::read_all("/mnt/disk6/Photos/a.bin").unwrap(); assert_eq!(all.len(), 3500); assert!(all[..500].iter().all(|&c| c == b'a') && all[500..].iter().all(|&c| c == b'A')); assert_eq!(fs::read_all("/mnt/disk6/Photos/b.bin").unwrap(), [b'b'; 1500]); fs::remove("/mnt/disk6/Photos/a.bin", false).unwrap(); fs::remove("/mnt/disk6/Photos/b.bin", false).unwrap(); assert_eq!(usage("vdg"), before); } #[test_case] fn many_files_grow_a_directory() { // 200 entry sets of 3 entries need 38 clusters of 512 bytes: the // directory grows (and, with file data in between, becomes a FAT chain). let before = usage("vdg"); fs::create("/mnt/disk6/Photos/many", InodeKind::Directory).unwrap(); for i in 0..200 { let f = fs::create(&format!("/mnt/disk6/Photos/many/File Number {:03}.txt", i), InodeKind::File).unwrap(); f.write_at(0, format!("{}\n", i).as_bytes()).unwrap(); } assert_eq!(fs::lookup("/mnt/disk6/Photos/many").unwrap().size(), 200); assert_eq!(read("/mnt/disk6/PHOTOS/MANY/file number 123.TXT", 0, 10), b"123\n"); assert_eq!(read("/mnt/disk6/Photos/many/File Number 199.txt", 0, 10), b"199\n"); for i in 0..200 { fs::remove(&format!("/mnt/disk6/Photos/many/file number {:03}.txt", i), false).unwrap(); } fs::remove("/mnt/disk6/Photos/many", true).unwrap(); assert_eq!(usage("vdg"), before); } #[test_case] fn root_directory_grows() { // The root has no entry set: its size is its FAT chain. Two new files // don't fit in its one 512-byte cluster, so it gains a second one. let (used, _) = usage("vdg"); for name in ["First New File.txt", "Second New File.txt"] { let f = fs::create(&format!("{}/{}", SMALL, name), InodeKind::File).unwrap(); f.write_at(0, name.as_bytes()).unwrap(); } assert_eq!(read("/mnt/disk6/second new file.txt", 0, 100), b"Second New File.txt"); assert!(names(SMALL).contains(&String::from("First New File.txt"))); assert_eq!(usage("vdg").0, used + 3); // one root cluster + one per file for name in ["First New File.txt", "Second New File.txt"] { fs::remove(&format!("{}/{}", SMALL, name), false).unwrap(); } assert_eq!(usage("vdg").0, used + 1); // the root keeps its new cluster assert_eq!(read("/mnt/disk6/numbers.txt", 17, 17), b"exfat line 00001\n"); } #[test_case] fn copy_from_fat_and_ext2() { // What `cp B:/notes/todo.txt D:/` and `cp C:/hello.txt D:/` do. for (from, to) in [("/mnt/disk0/notes/todo.txt", "/mnt/disk2/todo.txt"), ("/mnt/disk1/hello.txt", "/mnt/disk2/Copied From Ext2.txt")] { let bytes = fs::read_all(from).unwrap(); fs::create(to, InodeKind::File).unwrap().write_at(0, &bytes).unwrap(); assert_eq!(fs::read_all(to).unwrap(), bytes); } // And the 340 KB file the other way, onto ext2. let numbers = fs::read_all("/mnt/disk2/numbers.txt").unwrap(); fs::create("/mnt/disk1/numbers.txt", InodeKind::File).unwrap().write_at(0, &numbers).unwrap(); assert_eq!(fs::read_all("/mnt/disk1/numbers.txt").unwrap(), numbers); } #[test_case] fn written_files_stay_for_fsck() { // Runs last (tests go in name order) and deletes nothing, so that // scripts/exfat-check.sh's fsck.exfat sees the tricky cases on disk: a // grown directory that became a FAT chain, a file that went from // NoFatChain to a FAT chain, deleted entries in the middle of a // directory, and the grown root. for (disk, n) in [(SMALL_DIR, 40usize), (DISK, 300)] { let dir = format!("{}/Kept For fsck", disk); fs::create(&dir, InodeKind::Directory).unwrap(); for i in 0..n { let f = fs::create(&format!("{}/Kept File {:03}.txt", dir, i), InodeKind::File).unwrap(); f.write_at(0, format!("kept {}\n", i).as_bytes()).unwrap(); } for i in (0..n).step_by(3) { fs::remove(&format!("{}/kept file {:03}.TXT", dir, i), false).unwrap(); } let a = fs::create(&format!("{}/Grows Into A Chain.bin", dir), InodeKind::File).unwrap(); a.write_at(0, &[1; 100]).unwrap(); fs::create(&format!("{}/Blocker.txt", dir), InodeKind::File).unwrap().write_at(0, b"in the way\n").unwrap(); a.write_at(100, &[2; 9000]).unwrap(); assert_eq!(a.size(), 9100); assert_eq!(fs::lookup(&dir).unwrap().size(), n - n.div_ceil(3) + 2); } assert_eq!(read("/mnt/disk2/kept for FSCK/KEPT FILE 299.txt", 0, 20), b"kept 299\n"); } #[panic_handler] fn panic(info: &PanicInfo) -> ! { rustbelt::test_panic_handler(info) }