From a31dab22465e610b76adaf7d9c5b6abc7f04dd45 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 09:35:54 +0100 Subject: [PATCH 01/14] toyos-gpt writes tables in UEFI's order; the stamp encoder is bcachefs's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toyos_gpt::edit` is the one GPT writer in the tree, which the disk drivers will edit their own tables with: a `Table` of 128 entries of 128 bytes, checked against everything UEFI 2.10 §5.3 requires (usable range, ranges, overlaps, zero and duplicate unique GUIDs, unused entries all zero), encoded with both CRCs, and laid by `Encoded::steps` as §5.3.2 requires: "Software must update the backup GPT before the primary GPT" — backup array and header, a flush, then LBA 0 when asked and the primary, then a flush. `read_copies` reads LBA 0 and each copy alone, so a caller sees which copy is in force and whether the two agree, rather than the first one that parsed. The flush between the copies is what keeps a crash from tearing both: the host model cuts the write after every step and inside every write at any subset of its sectors, and each cut reads through `toyos_gpt::list` as the old table or the new one, never neither or a mixture, with the primary never newer than the backup. Without the middle flush a cut leaves both copies failing their CRCs; with the primary first, the primary is newer than the backup — under this reader alone that still reads as old or new, which is why the order is asserted as the specification's own invariant. `build/gptwrite.rs` goes: the build and the harness lay their tables with `image::write_gpt` over this writer, and the byte-for-byte oracle against the `gpt` crate moves to `lib/gpt/tests/edit.rs`, where that crate is already a dev-dependency. The designation stamp's encoder and reader are `bcachefs::designation` and `bcachefs::designated`, beside the magic they share, so the build, the file server and the drivers that will write it have one definition. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - Cargo.toml | 2 - build/gptwrite.rs | 237 ------------------ build/image.rs | 67 +++--- build/lib.rs | 1 - lib/bcachefs/src/lib.rs | 2 +- lib/bcachefs/src/superblock.rs | 53 +++- lib/gpt/src/edit.rs | 425 +++++++++++++++++++++++++++++++++ lib/gpt/src/guid.rs | 10 + lib/gpt/src/lib.rs | 12 +- lib/gpt/tests/edit.rs | 382 +++++++++++++++++++++++++++++ system/fileserver/src/data.rs | 7 +- tests/common/hostdisk.rs | 8 +- tests/metal/src/internal.rs | 17 +- tests/metal/src/lib.rs | 17 +- 15 files changed, 923 insertions(+), 318 deletions(-) delete mode 100644 build/gptwrite.rs create mode 100644 lib/gpt/src/edit.rs create mode 100644 lib/gpt/tests/edit.rs diff --git a/Cargo.lock b/Cargo.lock index ff8266e5bee..bc2e1363e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3563,7 +3563,6 @@ dependencies = [ "fatfs", "flate2", "getrandom 0.3.4", - "gpt", "iced-x86", "kernel", "libc", diff --git a/Cargo.toml b/Cargo.toml index 729e066502f..135aa2ffdd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,8 +301,6 @@ pem = "4" [dev-dependencies] # The FAT implementation `build/fatformat.rs` is judged against, byte for byte. fatfs = "0.3.6" -# The GPT writer `build/gptwrite.rs` is judged against, byte for byte. -gpt = "3.1.0" # `USER_TOP`, so the harness judges a held `rsp` against the bound the kernel # checks every user pointer against and not against a second copy of it. toyos-userbound = { path = "lib/userbound" } diff --git a/build/gptwrite.rs b/build/gptwrite.rs deleted file mode 100644 index a01c19f6b88..00000000000 --- a/build/gptwrite.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! The partition table the build writes onto every disk it makes: a -//! protective MBR, the primary GPT header and its entry array at the front, -//! and the backup array and header at the back, in 512-byte logical blocks -//! with the 128 entries of 128 bytes UEFI §5.3 requires room for. -//! -//! Here and not in `toyos-gpt`, which is the parser the kernel and the loader -//! read the table with; the build is the one program that writes one. -//! `toyos-gpt`'s CRC-32 and GUIDs are shared, because they are the format's -//! own and the parser is what proves them. `the_gpt_crate_writes_the_same_table` -//! holds every byte against the `gpt` crate this replaced. - -use toyos_gpt::Guid; - -const LBA: u64 = crate::image::LBA as u64; -const ENTRIES: u64 = 128; -const ENTRY_BYTES: u64 = 128; -const ARRAY_LBAS: u64 = ENTRIES * ENTRY_BYTES / LBA; -/// The MBR, the header and the array before it. -const FIRST_USABLE: u64 = 2 + ARRAY_LBAS; -const HEADER_BYTES: usize = 92; -/// A name is at most 36 UTF-16 code units. -const NAME_UNITS: usize = 36; - -/// One partition: its type, its own GUID, its first and last block, and its name. -pub struct Entry<'a> { - pub kind: Guid, - pub unique: Guid, - pub first_lba: u64, - pub last_lba: u64, - pub name: &'a str, -} - -/// A table's two halves: `primary` goes at byte 0 of the disk and `backup` -/// ends at its last byte. -pub struct Table { - pub primary: Vec, - pub backup: Vec, -} - -/// The last block a partition on a disk of `lbas` blocks may use. -fn last_usable(lbas: u64) -> u64 { - lbas - 2 - ARRAY_LBAS -} - -/// The table of a disk of `lbas` blocks named `disk`, carrying `entries` in -/// their order. -pub fn table(lbas: u64, disk: Guid, entries: &[Entry<'_>]) -> Table { - assert!(entries.len() as u64 <= ENTRIES, "{} partitions in a table of {ENTRIES}", entries.len()); - let last_usable = last_usable(lbas); - let mut array = vec![0u8; (ENTRIES * ENTRY_BYTES) as usize]; - for (entry, slot) in entries.iter().zip(array.chunks_mut(ENTRY_BYTES as usize)) { - assert!( - FIRST_USABLE <= entry.first_lba && entry.first_lba <= entry.last_lba && entry.last_lba <= last_usable, - "partition {:?} at blocks {}..={} is not inside {FIRST_USABLE}..={last_usable}", - entry.name, - entry.first_lba, - entry.last_lba - ); - let name: Vec = entry.name.encode_utf16().collect(); - assert!(name.len() <= NAME_UNITS, "the partition name {:?} is over {NAME_UNITS} UTF-16 units", entry.name); - slot[..16].copy_from_slice(&entry.kind.0); - slot[16..32].copy_from_slice(&entry.unique.0); - slot[32..40].copy_from_slice(&entry.first_lba.to_le_bytes()); - slot[40..48].copy_from_slice(&entry.last_lba.to_le_bytes()); - for (unit, at) in name.iter().zip((56..).step_by(2)) { - slot[at..at + 2].copy_from_slice(&unit.to_le_bytes()); - } - } - let array_crc = toyos_gpt::crc32(&array); - let header = |at: u64, other: u64, array_at: u64| { - let mut block = vec![0u8; LBA as usize]; - block[..8].copy_from_slice(b"EFI PART"); - block[8..12].copy_from_slice(&0x0001_0000u32.to_le_bytes()); - block[12..16].copy_from_slice(&(HEADER_BYTES as u32).to_le_bytes()); - block[24..32].copy_from_slice(&at.to_le_bytes()); - block[32..40].copy_from_slice(&other.to_le_bytes()); - block[40..48].copy_from_slice(&FIRST_USABLE.to_le_bytes()); - block[48..56].copy_from_slice(&last_usable.to_le_bytes()); - block[56..72].copy_from_slice(&disk.0); - block[72..80].copy_from_slice(&array_at.to_le_bytes()); - block[80..84].copy_from_slice(&(ENTRIES as u32).to_le_bytes()); - block[84..88].copy_from_slice(&(ENTRY_BYTES as u32).to_le_bytes()); - block[88..92].copy_from_slice(&array_crc.to_le_bytes()); - let crc = toyos_gpt::crc32(&block[..HEADER_BYTES]); - block[16..20].copy_from_slice(&crc.to_le_bytes()); - block - }; - - let mut primary = protective_mbr(lbas); - primary.extend(header(1, lbas - 1, 2)); - primary.extend(&array); - let mut backup = array; - backup.extend(header(lbas - 1, 1, last_usable + 1)); - Table { primary, backup } -} - -/// LBA 0: one partition of type 0xEE over the whole disk but its first -/// block, or as much of it as 32 bits count, at the CHS UEFI §5.2.3 gives it. -fn protective_mbr(lbas: u64) -> Vec { - let mut block = vec![0u8; LBA as usize]; - block[446..454].copy_from_slice(&[0x00, 0x00, 0x02, 0x00, 0xEE, 0xFF, 0xFF, 0xFF]); - block[454..458].copy_from_slice(&1u32.to_le_bytes()); - block[458..462].copy_from_slice(&u32::try_from(lbas - 1).unwrap_or(u32::MAX).to_le_bytes()); - block[510..512].copy_from_slice(&[0x55, 0xAA]); - block -} - -/// A version 4 GUID from the operating system's random source, in the -/// on-disk order: the version is the high nibble of byte 7, the third field -/// being little-endian, and the variant the top two bits of byte 8. -pub fn random_guid() -> Guid { - let mut bytes = [0u8; 16]; - getrandom::fill(&mut bytes).expect("the operating system's random source"); - bytes[7] = (bytes[7] & 0x0F) | 0x40; - bytes[8] = (bytes[8] & 0x3F) | 0x80; - Guid(bytes) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::image::ImageSectors; - use std::collections::BTreeMap; - use std::io::{Cursor, Read, Seek, SeekFrom}; - - const ALIGN: u64 = 2048; - - /// The partitions of a boot image with the DATA an install puts first, - /// one of them a length the 1 MiB alignment does not round. - const ROWS: [(&str, Guid, u64); 6] = [ - ("ToyOS data", Guid::TOYOS_DATA, 3 << 20), - ("ESP", Guid::EFI_SYSTEM, 34 << 20), - ("ToyOS slots", Guid::TOYOS_SLOTS, 1 << 20), - ("ToyOS log", Guid::MICROSOFT_BASIC, 34 << 20), - ("ToyOS slot A", Guid::TOYOS_BOOT, (40 << 20) + 4096), - ("ToyOS root A", Guid::TOYOS_ROOT, 7 << 20), - ]; - - /// `ROWS` under `uniques`, each on the first 1 MiB boundary after the last. - fn entries(uniques: &[Guid]) -> Vec> { - let mut at = ALIGN; - ROWS.iter() - .zip(uniques) - .map(|((name, kind, len), unique)| { - let first = at; - at = (first + len / LBA).next_multiple_of(ALIGN); - Entry { kind: *kind, unique: *unique, first_lba: first, last_lba: first + len / LBA - 1, name } - }) - .collect() - } - - fn disk_of(total: u64, table: &Table) -> Vec { - let mut disk = vec![0u8; total as usize]; - disk[..table.primary.len()].copy_from_slice(&table.primary); - let back = disk.len() - table.backup.len(); - disk[back..].copy_from_slice(&table.backup); - disk - } - - /// The `gpt` crate's table for `ROWS`, laid as `build/image.rs` laid it with - /// that crate, on a disk of `total` bytes: its bytes, its disk GUID and - /// the unique GUIDs it drew. - fn theirs(total: u64) -> (Vec, Guid, Vec) { - let mut bytes = vec![0u8; total as usize]; - let mut cursor = Cursor::new(&mut bytes); - gpt::mbr::ProtectiveMBR::with_lb_size(u32::try_from(total / LBA - 1).unwrap_or(u32::MAX)) - .overwrite_lba0(&mut cursor) - .expect("the protective MBR"); - let mut disk = gpt::GptConfig::default() - .initialized(false) - .writable(true) - .logical_block_size(gpt::disk::LogicalBlockSize::Lb512) - .create_from_device(Box::new(cursor), None) - .expect("a table"); - disk.update_partitions(BTreeMap::new()).expect("an empty table"); - for (name, kind, len) in ROWS { - let kind = gpt::partition_types::Type { - guid: kind.to_string().leak(), - os: gpt::partition_types::OperatingSystem::None, - }; - disk.add_partition(name, len, kind, 0, Some(ALIGN)).expect("a partition"); - } - let mut device = disk.write().expect("the table"); - let mut out = vec![0u8; total as usize]; - device.seek(SeekFrom::Start(0)).and_then(|_| device.read_exact(&mut out)).expect("read back"); - let guid = |at: usize| Guid(out[at..at + 16].try_into().unwrap()); - let disk_guid = guid(LBA as usize + 56); - let uniques = (0..ROWS.len()).map(|i| guid(2 * LBA as usize + i * ENTRY_BYTES as usize + 16)).collect(); - (out, disk_guid, uniques) - } - - /// **The differential oracle**: the `gpt` crate, a third party's GPT - /// writer sharing no code with this one, lays the same partitions on the - /// same disk under the same GUIDs into the same bytes, every one of them, - /// on a disk the alignment rounds and on one it does not. - #[test] - fn the_gpt_crate_writes_the_same_table() { - for total in [128u64 << 20, (160 << 20) + 3 * 4096] { - let (theirs, disk, uniques) = theirs(total); - let ours = disk_of(total, &table(total / LBA, disk, &entries(&uniques))); - let differing = (0..total as usize).filter(|&i| ours[i] != theirs[i]).count(); - assert_eq!(differing, 0, "{total}: {differing} bytes differ from the gpt crate's"); - } - } - - /// RFC 9562's version and variant, where the canonical text shows them. - #[test] - fn a_random_guid_is_version_4_of_the_rfc_variant() { - let text = random_guid().to_string(); - assert_eq!(&text[14..15], "4", "{text}"); - assert!(matches!(&text[19..20], "8" | "9" | "A" | "B"), "{text}"); - } - - /// `toyos-gpt`, the parser the loader and the kernel read with, finds - /// every partition at its blocks under its type, from the primary and, - /// with the primary torn, from the backup. - #[test] - fn toyos_gpt_reads_it_from_either_copy() { - let total: u64 = 128 << 20; - let uniques: Vec = ROWS.iter().map(|_| random_guid()).collect(); - let entries = entries(&uniques); - let disk = disk_of(total, &table(total / LBA, random_guid(), &entries)); - let mut torn = disk.clone(); - torn[LBA as usize + 30] ^= 1; - for image in [&disk, &torn] { - for entry in &entries { - let found = toyos_gpt::locate(&mut ImageSectors(image), entry.unique).expect("the partition").partition(); - assert_eq!( - (found.type_guid(), found.first_lba(), found.lba_count().get()), - (entry.kind, entry.first_lba, entry.last_lba - entry.first_lba + 1), - "{}", - entry.name - ); - } - } - } -} diff --git a/build/image.rs b/build/image.rs index 409fc64efed..18752106a37 100644 --- a/build/image.rs +++ b/build/image.rs @@ -9,6 +9,7 @@ use crate::arch::Arch; use toyos_blockdev::{block_on_ready, Bytes, ReadOnlyBytes, BLOCK}; use toyos_crypto::Random; use toyos_fat32::{Fat32, FatTime}; +use toyos_gpt::edit::{Geometry, Slot, Step, Table}; use toyos_gpt::Guid; /// The image that goes on the ROOT partition: a bcachefs volume holding @@ -246,9 +247,9 @@ pub fn create_boot_image( // kernel. The kernel is given the partition by name; nothing anywhere goes // looking for one by type or by format. Every slot partition is named the // same way, by the slot table. - let (log_guid, esp_guid, table_guid) = (crate::gptwrite::random_guid(), crate::gptwrite::random_guid(), crate::gptwrite::random_guid()); - let a = (crate::gptwrite::random_guid(), crate::gptwrite::random_guid()); - let b = second.map(|room| (crate::gptwrite::random_guid(), crate::gptwrite::random_guid(), room)); + let (log_guid, esp_guid, table_guid) = (random_guid(), random_guid(), random_guid()); + let a = (random_guid(), random_guid()); + let b = second.map(|room| (random_guid(), random_guid(), room)); let s = signed(release, params, signing); // Room for the slot's files twice over, so an update can write new ones @@ -659,8 +660,8 @@ pub fn designate_data_disk(path: &Path, len: u64) -> (u64, u64) { .write(true) .open(path) .unwrap_or_else(|e| panic!("open {} to partition it: {e}", path.display())); - let starts = lay(&mut file, len, &[data_row(crate::gptwrite::random_guid(), data_bytes)]); - write_at(&mut file, starts[0], &designation(data_bytes / SECTOR as u64), "DATA partition's designation"); + let starts = lay(&mut file, len, &[data_row(random_guid(), data_bytes)]); + write_at(&mut file, starts[0], &bcachefs::designation(data_bytes / SECTOR as u64), "DATA partition's designation"); (starts[0], data_bytes) } @@ -670,6 +671,28 @@ fn data_row(guid: Guid, len: u64) -> Row { } /// Write `bytes` at byte `at` of `disk`; `what` they are is what a refusal names. +/// A version 4 GUID from the host's random source. +pub fn random_guid() -> Guid { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("the operating system's random source"); + Guid::random_v4(bytes) +} + +/// A protective MBR and both copies of a GPT carrying `slots` in their +/// order, under `disk_guid`, on `disk` of `total` bytes. +pub fn write_gpt(disk: &mut (impl Write + Seek), total: u64, disk_guid: Guid, slots: &[Slot]) { + let geometry = Geometry::new(LBA, total / u64::from(LBA)).expect("a disk that holds a table"); + let mut table = Table::empty(&geometry, disk_guid); + assert!(slots.len() <= table.slots.len(), "{} partitions in a table of {}", slots.len(), table.slots.len()); + table.slots[..slots.len()].copy_from_slice(slots); + let encoded = table.encode(&geometry).unwrap_or_else(|why| panic!("the partition table is not one UEFI admits: {why:?}")); + for step in encoded.steps(true) { + if let Step::Write { lba, bytes } = step { + write_at(disk, lba * u64::from(LBA), bytes, "partition table"); + } + } +} + fn write_at(disk: &mut (impl Write + Seek), at: u64, bytes: &[u8], what: &str) { disk.seek(SeekFrom::Start(at)) .and_then(|_| disk.write_all(bytes)) @@ -708,7 +731,7 @@ pub fn install(image: &[u8], disk: &Path, data_bytes: u64) { .collect(); let (mut rows, volumes): (Vec, Vec<&[u8]>) = carried.into_iter().unzip(); - rows.insert(0, data_row(crate::gptwrite::random_guid(), data_bytes)); + rows.insert(0, data_row(random_guid(), data_bytes)); let total = disk_bytes(rows.iter().map(|row| row.len)); let mut file = std::fs::OpenOptions::new() @@ -720,7 +743,7 @@ pub fn install(image: &[u8], disk: &Path, data_bytes: u64) { file.set_len(total).unwrap_or_else(|e| panic!("size {} to {total} bytes: {e}", disk.display())); let starts = lay(&mut file, total, &rows); - write_at(&mut file, starts[0], &designation(data_bytes / SECTOR as u64), "DATA partition's designation"); + write_at(&mut file, starts[0], &bcachefs::designation(data_bytes / SECTOR as u64), "DATA partition's designation"); for ((row, volume), start) in rows[1..].iter().zip(volumes).zip(&starts[1..]) { for (i, block) in volume.chunks(PARTITION_ALIGN).enumerate().filter(|(_, block)| block.iter().any(|byte| *byte != 0)) { write_at(&mut file, start + (i * PARTITION_ALIGN) as u64, block, &row.what); @@ -733,15 +756,6 @@ fn name_of(part: &toyos_gpt::Partition) -> String { String::from_utf16(part.name()).unwrap_or_else(|e| panic!("partition {}'s name is no UTF-16: {e}", part.unique_guid())) } -/// Block 0 of a volume: the magic and its block count. -fn designation(blocks: u64) -> [u8; SECTOR] { - let mut block = [0u8; SECTOR]; - block[..bcachefs::DESIGNATION_MAGIC.len()].copy_from_slice(&bcachefs::DESIGNATION_MAGIC); - let at = bcachefs::DESIGNATION_BLOCKS_OFFSET; - block[at..at + 8].copy_from_slice(&blocks.to_le_bytes()); - block -} - /// A disk file as logical blocks, for a reader that may not hold the image. pub struct FileSectors<'a>(pub &'a mut std::fs::File); @@ -821,20 +835,15 @@ fn lay(disk: &mut (impl Write + Seek), total: u64, rows: &[Row]) -> Vec { }) .collect(); let lba = u64::from(LBA); - let entries: Vec> = rows + let slots: Vec = rows .iter() .zip(&starts) - .map(|(row, start)| crate::gptwrite::Entry { - kind: row.kind, - unique: row.guid, - first_lba: start / lba, - last_lba: (start + row.len) / lba - 1, - name: &row.name, + .map(|(row, start)| { + Slot::new(row.kind, row.guid, start / lba, (start + row.len) / lba - 1, &row.name) + .unwrap_or_else(|| panic!("the partition name {:?} is over {} UTF-16 units", row.name, toyos_gpt::edit::NAME_UNITS)) }) .collect(); - let table = crate::gptwrite::table(total / lba, crate::gptwrite::random_guid(), &entries); - write_at(disk, 0, &table.primary, "partition table"); - write_at(disk, total - table.backup.len() as u64, &table.backup, "backup partition table"); + write_gpt(disk, total, random_guid(), &slots); starts } @@ -1026,7 +1035,7 @@ mod tests { let root = tiny_root(); let s = signed(release(b"kernel", &root), "", signing(&key)); for (what, volume) in [ - ("ESP", create_esp_volume(Arch::X86_64, b"bootloader", crate::gptwrite::random_guid())), + ("ESP", create_esp_volume(Arch::X86_64, b"bootloader", random_guid())), ("slot volume", create_slot_volume(Some(slot_files(release(b"kernel", &root), &s)), FAT32_MIN_BYTES)), ("empty slot volume", create_slot_volume(None, FAT32_MIN_BYTES)), ("log volume", create_log_volume()), @@ -1114,7 +1123,7 @@ mod tests { #[test] fn the_esp_and_a_slot_carry_what_the_bootloader_looks_for() { assert_eq!( - files_of(create_esp_volume(Arch::X86_64, b"bootloader", crate::gptwrite::random_guid())), + files_of(create_esp_volume(Arch::X86_64, b"bootloader", random_guid())), ["EFI/BOOT/BOOTx64.EFI", "toyos/log.guid"] ); let key = key(); @@ -1235,7 +1244,7 @@ mod tests { ); let mut stamp = [0u8; SECTOR]; disk.seek(SeekFrom::Start(PARTITION_ALIGN as u64)).and_then(|_| disk.read_exact(&mut stamp)).expect("read DATA"); - assert_eq!(stamp, designation(DATA / SECTOR as u64)); + assert_eq!(stamp, bcachefs::designation(DATA / SECTOR as u64)); drop(disk); let theirs = dir.join("theirs.img"); diff --git a/build/lib.rs b/build/lib.rs index e3dc4360e14..35b292200a1 100644 --- a/build/lib.rs +++ b/build/lib.rs @@ -19,7 +19,6 @@ pub mod flash; pub mod fpgate; #[cfg(test)] pub mod gitfixture; -pub mod gptwrite; pub mod hostedclang; pub mod flags; pub mod hostws; diff --git a/lib/bcachefs/src/lib.rs b/lib/bcachefs/src/lib.rs index 2d01bf93eb1..778891ba61c 100644 --- a/lib/bcachefs/src/lib.rs +++ b/lib/bcachefs/src/lib.rs @@ -27,7 +27,7 @@ pub use block_io::{BlockIO, BlockBuf, BlockNum, DeviceError, TransferError}; #[cfg(feature = "std")] pub use block_io::VecBlockIO; pub use fs::{file_entry_fits, Formatted, Mounted, ReadOnly, ReadWrite, Recorded, FsError, Extent}; -pub use superblock::{DESIGNATION_BLOCKS_OFFSET, DESIGNATION_MAGIC, FsUuid, Superblock}; +pub use superblock::{designated, designation, FsUuid, Superblock}; /// Records the largest single allocation each test thread makes, so a test can /// assert what parsing a crafted block asks the allocator for. diff --git a/lib/bcachefs/src/superblock.rs b/lib/bcachefs/src/superblock.rs index ace3abef5b2..47d3592701d 100644 --- a/lib/bcachefs/src/superblock.rs +++ b/lib/bcachefs/src/superblock.rs @@ -8,7 +8,7 @@ pub const MAGIC: [u8; 4] = *b"BCFS"; pub const VERSION: u32 = 2; /// The designation stamp: the *only* thing that authorises ToyOS to destroy -/// what is on a block device. +/// what is on a partition. /// /// Not a bcachefs concept, and it lives here anyway, because it occupies the /// same block as the superblock and the one invariant that matters is that @@ -17,16 +17,29 @@ pub const VERSION: u32 = 2; /// /// Bytes 0..16 are this magic and bytes 16..24 the little-endian block count /// of the device the stamp designates, so a stamped image copied onto a -/// different disk designates nothing. The rest of the block is ignored. -/// -/// Reading it is safe on any disk; writing it is the act of designation, and -/// it destroys whatever partition table was there. That is the point: there -/// is no way to designate a disk by accident, and no way to do it without -/// having already decided to lose its contents. -pub const DESIGNATION_MAGIC: [u8; 16] = *b"TOYOS-FORMAT-ME\0"; +/// different disk designates nothing. The rest of the block is zero when +/// written and ignored when read. +const DESIGNATION_MAGIC: [u8; 16] = *b"TOYOS-FORMAT-ME\0"; + +const DESIGNATION_BLOCKS_OFFSET: usize = 16; + +/// Block 0 of a device of `blocks` blocks that designates it, whole. +pub fn designation(blocks: u64) -> [u8; BLOCK_SIZE] { + let mut block = [0u8; BLOCK_SIZE]; + block[..DESIGNATION_MAGIC.len()].copy_from_slice(&DESIGNATION_MAGIC); + block[DESIGNATION_BLOCKS_OFFSET..DESIGNATION_BLOCKS_OFFSET + 8].copy_from_slice(&blocks.to_le_bytes()); + block +} -/// Bytes 16..24 of a designation stamp. -pub const DESIGNATION_BLOCKS_OFFSET: usize = 16; +/// The block count a stamp at the front of `block0` names, or `None` where +/// `block0` carries none. +pub fn designated(block0: &[u8]) -> Option { + if block0.get(..DESIGNATION_MAGIC.len())? != DESIGNATION_MAGIC { + return None; + } + let count = block0.get(DESIGNATION_BLOCKS_OFFSET..DESIGNATION_BLOCKS_OFFSET + 8)?; + Some(u64::from_le_bytes(count.try_into().ok()?)) +} const _: () = assert!( DESIGNATION_MAGIC[0] != MAGIC[0] @@ -298,3 +311,23 @@ fn write_u32(buf: &mut [u8], off: usize, val: u32) { fn write_u64(buf: &mut [u8], off: usize, val: u64) { buf[off..off + 8].copy_from_slice(&val.to_le_bytes()); } + +#[cfg(test)] +mod designation_tests { + use super::*; + + /// A stamp names the count it was made for, and a block that does not + /// begin with the magic names nothing, a superblock's first among them. + #[test] + fn a_stamp_names_its_block_count_and_nothing_else_is_one() { + for blocks in [1, 43_000_000, u64::MAX] { + assert_eq!(designated(&designation(blocks)), Some(blocks)); + } + let mut superblock = [0u8; BLOCK_SIZE]; + superblock[..4].copy_from_slice(&MAGIC); + assert_eq!(designated(&superblock), None); + assert_eq!(designated(&[0u8; BLOCK_SIZE]), None); + assert_eq!(designated(&designation(7)[..20]), None, "a block too short for its count"); + assert!(designation(7)[24..].iter().all(|b| *b == 0), "the rest of the stamp's block is zero"); + } +} diff --git a/lib/gpt/src/edit.rs b/lib/gpt/src/edit.rs new file mode 100644 index 00000000000..ea27460e36b --- /dev/null +++ b/lib/gpt/src/edit.rs @@ -0,0 +1,425 @@ +//! A whole table: read from either copy, checked, and written back as UEFI +//! 2.10 §5.3.2 requires — the backup before the primary, every CRC resealed. +//! +//! Only the entry array every table in practice has is read or written, +//! [`ENTRIES`] entries of [`ENTRY_BYTES`]; a copy of another shape reads as +//! [`CopyError::Shape`]. The writer lays the arrays where §5.3.1 puts them: +//! the primary's at LBA 2, the backup's ending just before the device's last +//! LBA, which holds the backup header. The device's true last LBA: a +//! [`Sectors`] that floors its count reads a copy this writes at the wrong +//! place. + +use crate::{check_protective_mbr, header_crc, parse_header, read, Block, Crc32, Disk, GptError, Guid, LbaSize, Sectors}; + +pub const ENTRIES: usize = 128; +pub const ENTRY_BYTES: usize = 128; +/// The array's bytes, which UEFI requires be at least 16,384 (§5.3.1). +pub const ARRAY_BYTES: usize = 16_384; +/// A name is at most 36 UTF-16 code units (§5.3.3). +pub const NAME_UNITS: usize = 36; +const HEADER_BYTES: u32 = 92; +const ENTRIES_U32: u32 = 128; +const ENTRY_BYTES_U32: u32 = 128; +const ARRAY_BYTES_U64: u64 = 16_384; + +/// One entry, every field the table stores. A zero type is an unused entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Slot { + pub type_guid: Guid, + pub unique_guid: Guid, + pub first: u64, + /// Inclusive, as GPT stores it. + pub last: u64, + pub attributes: u64, + pub name: [u16; NAME_UNITS], +} + +impl Slot { + pub const UNUSED: Self = + Self { type_guid: Guid::ZERO, unique_guid: Guid::ZERO, first: 0, last: 0, attributes: 0, name: [0; NAME_UNITS] }; + + /// A used slot of no attributes; `None` for a name longer than + /// [`NAME_UNITS`]. + pub fn new(type_guid: Guid, unique_guid: Guid, first: u64, last: u64, name: &str) -> Option { + Some(Self { type_guid, unique_guid, first, last, attributes: 0, name: Self::named(name)? }) + } + + pub const fn is_used(&self) -> bool { + !self.type_guid.is_zero() + } + + /// The name up to its first zero unit. + pub fn name(&self) -> &[u16] { + self.name.split(|unit| *unit == 0).next().unwrap_or_default() + } + + /// `text` as the name field, `None` for one longer than [`NAME_UNITS`]. + pub fn named(text: &str) -> Option<[u16; NAME_UNITS]> { + let mut name = [0; NAME_UNITS]; + let mut units = text.encode_utf16(); + for unit in name.iter_mut() { + *unit = units.next().unwrap_or(0); + } + units.next().is_none().then_some(name) + } + + /// The number of blocks it spans, for a used slot whose range is ordered. + pub fn lba_count(&self) -> Option { + self.last.checked_sub(self.first)?.checked_add(1) + } + + fn decode(raw: &[u8]) -> Option { + let name = crate::name(raw)?; + Some(Self { + type_guid: crate::bytes(raw, 0).map(Guid)?, + unique_guid: crate::bytes(raw, 16).map(Guid)?, + first: crate::le_u64(raw, 32)?, + last: crate::le_u64(raw, 40)?, + attributes: crate::le_u64(raw, 48)?, + name, + }) + } + + fn encode(&self, out: &mut [u8]) { + put(out, 0, &self.type_guid.0); + put(out, 16, &self.unique_guid.0); + put(out, 32, &self.first.to_le_bytes()); + put(out, 40, &self.last.to_le_bytes()); + put(out, 48, &self.attributes.to_le_bytes()); + if let Some(field) = out.get_mut(56..) { + for (pair, unit) in field.as_chunks_mut::<2>().0.iter_mut().zip(self.name) { + *pair = unit.to_le_bytes(); + } + } + } +} + +/// A disk's size as the table is laid on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Geometry { + lba: LbaSize, + lba_count: u64, + array_lbas: u64, +} + +impl Geometry { + /// A device of `lba_count` blocks of `lba_bytes`: `None` for a block size + /// [`crate::LbaSize`] does not parse, or a device with no room for an MBR, + /// both copies and one usable block. + pub fn new(lba_bytes: u32, lba_count: u64) -> Option { + let lba = LbaSize::of(lba_bytes)?; + let array_lbas = ARRAY_BYTES_U64.checked_div(u64::from(lba_bytes))?; + let table = array_lbas.checked_add(1)?.checked_mul(2)?.checked_add(1)?; + (lba_count > table).then_some(Self { lba, lba_count, array_lbas }) + } + + pub const fn lba_bytes(&self) -> u32 { + self.lba.bytes() + } + + pub const fn lba_count(&self) -> u64 { + self.lba_count + } + + /// Where the backup header is: the device's last LBA. + pub const fn backup_header_lba(&self) -> u64 { + // `new` proved `lba_count` past the whole table. + self.lba_count.saturating_sub(1) + } + + pub const fn backup_array_lba(&self) -> u64 { + self.backup_header_lba().saturating_sub(self.array_lbas) + } + + /// The widest usable range: from just past the primary array to just + /// before the backup's. + pub const fn usable(&self) -> (u64, u64) { + (self.array_lbas.saturating_add(2), self.backup_array_lba().saturating_sub(1)) + } +} + +/// Why a table is not one this writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Invalid { + /// The disk GUID is zero. + DiskGuid, + /// The usable range is empty or reaches into either copy. + UsableRange { first: u64, last: u64 }, + /// A used entry's range is reversed or leaves the usable range. + OutsideUsable { index: u32 }, + /// An entry of zero type with any other field set: unused entries are + /// all zero (§5.3.3). + UnusedNotZero { index: u32 }, + ZeroUnique { index: u32 }, + DuplicateUnique { first: u32, second: u32 }, + Overlap { first: u32, second: u32 }, +} + +/// A table's contents, apart from where its two copies lie. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Table { + pub disk_guid: Guid, + pub first_usable: u64, + pub last_usable: u64, + pub slots: [Slot; ENTRIES], +} + +impl Table { + /// An empty table across the whole of `geometry`. + pub fn empty(geometry: &Geometry, disk_guid: Guid) -> Self { + let (first_usable, last_usable) = geometry.usable(); + Self { disk_guid, first_usable, last_usable, slots: [Slot::UNUSED; ENTRIES] } + } + + /// Each used slot with its index. + pub fn used(&self) -> impl Iterator { + (0u32..).zip(self.slots.iter()).filter(|(_, s)| s.is_used()) + } + + /// Everything §5.3 requires of a table on `geometry`. + pub fn check(&self, geometry: &Geometry) -> Result<(), Invalid> { + if self.disk_guid.is_zero() { + return Err(Invalid::DiskGuid); + } + let (first, last) = geometry.usable(); + if self.first_usable < first || self.last_usable > last || self.first_usable > self.last_usable { + return Err(Invalid::UsableRange { first: self.first_usable, last: self.last_usable }); + } + for (index, slot) in (0u32..).zip(self.slots.iter()) { + if !slot.is_used() { + if *slot != Slot::UNUSED { + return Err(Invalid::UnusedNotZero { index }); + } + continue; + } + if slot.unique_guid.is_zero() { + return Err(Invalid::ZeroUnique { index }); + } + if slot.first > slot.last || slot.first < self.first_usable || slot.last > self.last_usable { + return Err(Invalid::OutsideUsable { index }); + } + } + for (first, a) in self.used() { + for (second, b) in self.used().filter(|(i, _)| *i > first) { + if a.unique_guid == b.unique_guid { + return Err(Invalid::DuplicateUnique { first, second }); + } + if a.first <= b.last && b.first <= a.last { + return Err(Invalid::Overlap { first, second }); + } + } + } + Ok(()) + } + + /// The bytes of both copies, once [`Self::check`] holds. + pub fn encode(&self, geometry: &Geometry) -> Result { + self.check(geometry)?; + let mut array = [0u8; ARRAY_BYTES]; + for (slot, raw) in self.slots.iter().zip(array.as_chunks_mut::().0) { + slot.encode(raw); + } + let array_crc = crate::crc32(&array); + let last = geometry.backup_header_lba(); + let primary = self.header(1, last, 2, array_crc); + let backup = self.header(last, 1, geometry.backup_array_lba(), array_crc); + Ok(Encoded { geometry: *geometry, array, primary, backup, mbr: protective_mbr(geometry) }) + } + + fn header(&self, my: u64, alternate: u64, array_lba: u64, array_crc: u32) -> Block { + let mut block: Block = [0; 4096]; + put(&mut block, 0, crate::HEADER_SIGNATURE); + put(&mut block, 8, &crate::HEADER_REVISION_1_0.to_le_bytes()); + put(&mut block, 12, &HEADER_BYTES.to_le_bytes()); + put(&mut block, 24, &my.to_le_bytes()); + put(&mut block, 32, &alternate.to_le_bytes()); + put(&mut block, 40, &self.first_usable.to_le_bytes()); + put(&mut block, 48, &self.last_usable.to_le_bytes()); + put(&mut block, 56, &self.disk_guid.0); + put(&mut block, 72, &array_lba.to_le_bytes()); + put(&mut block, 80, &ENTRIES_U32.to_le_bytes()); + put(&mut block, 84, &ENTRY_BYTES_U32.to_le_bytes()); + put(&mut block, 88, &array_crc.to_le_bytes()); + let crc = block.first_chunk::<92>().and_then(|h| header_crc(h)).unwrap_or(0); + put(&mut block, 16, &crc.to_le_bytes()); + block + } +} + +/// LBA 0 as §5.2.3 lays it for a GPT disk: one record of type 0xEE from LBA +/// 1 over the rest of the disk, saturating at what 32 bits count. +fn protective_mbr(geometry: &Geometry) -> Block { + let mut block: Block = [0; 4096]; + put(&mut block, 446, &[0x00, 0x00, 0x02, 0x00, crate::MBR_TYPE_PROTECTIVE, 0xFF, 0xFF, 0xFF]); + put(&mut block, 454, &1u32.to_le_bytes()); + let size = u32::try_from(geometry.backup_header_lba()).unwrap_or(u32::MAX); + put(&mut block, 458, &size.to_le_bytes()); + put(&mut block, 510, &[0x55, 0xAA]); + block +} + +/// `bytes` into `buf` at `at`; the offsets here are constants inside the +/// buffers they name. +fn put(buf: &mut [u8], at: usize, bytes: &[u8]) { + if let Some(dst) = buf.get_mut(at..).and_then(|rest| rest.get_mut(..bytes.len())) { + dst.copy_from_slice(bytes); + } +} + +/// Both copies of a table, encoded. +pub struct Encoded { + geometry: Geometry, + array: [u8; ARRAY_BYTES], + primary: Block, + backup: Block, + mbr: Block, +} + +/// One thing a writer does, in [`Encoded::steps`]'s order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step<'a> { + /// Write `bytes`, whole logical blocks, from LBA `lba`. + Write { lba: u64, bytes: &'a [u8] }, + /// Every write before this is on the medium before any after it is + /// issued: a device with a volatile cache is flushed. + Flush, +} + +impl Encoded { + /// The writes and flushes that put this table on the disk, in the order + /// they must reach it: the backup copy, a flush, then the protective MBR + /// when `mbr` asks for it and the primary copy, and a flush. + /// + /// The backup goes first because §5.3.2 requires it. The flush between is + /// what keeps a crash from tearing both copies at once: the primary is + /// not touched until the backup is whole on the medium, so at every + /// instant one copy is whole — the old primary while the backup is + /// written, the new backup while the primary is. + pub fn steps<'a>(&'a self, mbr: bool) -> impl Iterator> { + let len = self.geometry.lba.bytes_usize(); + let block = |b: &'a Block| -> &'a [u8] { b.get(..len).unwrap_or_default() }; + let backup_array = Step::Write { lba: self.geometry.backup_array_lba(), bytes: &self.array }; + let backup_header = Step::Write { lba: self.geometry.backup_header_lba(), bytes: block(&self.backup) }; + let protective = mbr.then_some(Step::Write { lba: 0, bytes: block(&self.mbr) }); + let primary_array = Step::Write { lba: 2, bytes: &self.array }; + let primary_header = Step::Write { lba: 1, bytes: block(&self.primary) }; + [Some(backup_array), Some(backup_header), Some(Step::Flush), protective, Some(primary_array), Some(primary_header), Some(Step::Flush)] + .into_iter() + .flatten() + } +} + +/// What LBA 0 holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mbr { + /// A GPT's protective MBR. + Protective, + /// An MBR's boot signature and records that are not one protective + /// record: an MBR partition table, or a hybrid one. + Other, + /// No boot signature at all. + Blank, +} + +/// Which copy of a table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Which { + Primary, + Backup, +} + +/// Why one copy is not a table this reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CopyError { + Gpt(GptError), + /// A valid header naming an array of another shape than the one this + /// writes. + Shape { entries: u32, entry_bytes: u32 }, +} + +impl From for CopyError { + fn from(e: GptError) -> Self { + Self::Gpt(e) + } +} + +/// Everything a disk holds of a table: LBA 0 and each copy, read alone. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Copies { + pub geometry: Geometry, + pub mbr: Mbr, + pub primary: Result, + pub backup: Result, +} + +impl Copies { + /// The table this disk carries, by §5.3.2's rule: the primary when it is + /// valid, else the backup. `None` for a disk with no valid copy, or no + /// protective MBR. + pub fn in_force(&self) -> Option<(Which, &Table)> { + if self.mbr != Mbr::Protective { + return None; + } + match (&self.primary, &self.backup) { + (Ok(t), _) => Some((Which::Primary, t)), + (Err(_), Ok(t)) => Some((Which::Backup, t)), + (Err(_), Err(_)) => None, + } + } + + /// Both copies valid and the same table. + pub fn agree(&self) -> bool { + matches!((&self.primary, &self.backup), (Ok(p), Ok(b)) if p == b) + } +} + +/// LBA 0 and both copies of `dev`'s table, each read and checked alone. +/// `None` only for a device [`Geometry::new`] refuses or whose LBA 0 does not +/// read. +pub fn read_copies(dev: &mut dyn Sectors) -> Option { + let geometry = Geometry::new(dev.lba_bytes(), dev.lba_count())?; + let mut block: Block = [0; 4096]; + let lba0 = geometry.lba.of_block(&mut block); + read(dev, 0, lba0).ok()?; + let mbr = if check_protective_mbr(lba0).is_ok() { + Mbr::Protective + } else if lba0.get(510..512) == Some(&[0x55, 0xAA]) { + Mbr::Other + } else { + Mbr::Blank + }; + let disk = Disk { + lba: geometry.lba, + lba_count: geometry.lba_count, + backup_header_lba: geometry.backup_header_lba(), + lba_count_slack: dev.lba_count_granularity().get().saturating_sub(1), + }; + let primary = read_copy(dev, &disk, 1); + let backup = read_copy(dev, &disk, geometry.backup_header_lba()); + Some(Copies { geometry, mbr, primary, backup }) +} + +fn read_copy(dev: &mut dyn Sectors, disk: &Disk, header_lba: u64) -> Result { + let mut block: Block = [0; 4096]; + let lba = disk.lba.of_block(&mut block); + read(dev, header_lba, lba)?; + let header = parse_header(lba, disk, header_lba)?; + if header.entry_count != ENTRIES_U32 || header.entry_bytes != ENTRY_BYTES_U32 { + return Err(CopyError::Shape { entries: header.entry_count, entry_bytes: header.entry_bytes }); + } + let mut array = [0u8; ARRAY_BYTES]; + for (at, chunk) in (header.entry_array_lba..header.entry_array_end).zip(array.chunks_mut(disk.lba.bytes_usize())) { + read(dev, at, chunk)?; + } + let mut crc = Crc32::new(); + crc.update(&array); + let computed = crc.finish(); + if computed != header.entry_array_crc { + return Err(GptError::EntryArrayCrc { stored: header.entry_array_crc, computed }.into()); + } + let mut slots = [Slot::UNUSED; ENTRIES]; + for (slot, raw) in slots.iter_mut().zip(array.as_chunks::().0) { + *slot = Slot::decode(raw).ok_or(GptError::EntrySize(header.entry_bytes))?; + } + Ok(Table { disk_guid: header.disk_guid, first_usable: header.first_usable_lba, last_usable: header.last_usable_lba, slots }) +} diff --git a/lib/gpt/src/guid.rs b/lib/gpt/src/guid.rs index 962f32b2265..1f40fb20faa 100644 --- a/lib/gpt/src/guid.rs +++ b/lib/gpt/src/guid.rs @@ -97,6 +97,16 @@ impl Guid { ]) } + /// A version 4 GUID out of sixteen random bytes, in the on-disk order: + /// the version is the high nibble of byte 7, the third field being + /// little-endian, and the variant the top two bits of byte 8 (RFC 9562 + /// §5.4). + pub const fn random_v4(mut bytes: [u8; 16]) -> Self { + bytes[7] = (bytes[7] & 0x0F) | 0x40; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + Self(bytes) + } + pub const fn is_zero(&self) -> bool { u128::from_ne_bytes(self.0) == 0 } diff --git a/lib/gpt/src/lib.rs b/lib/gpt/src/lib.rs index 90ba80cb907..74aa769277f 100644 --- a/lib/gpt/src/lib.rs +++ b/lib/gpt/src/lib.rs @@ -33,6 +33,7 @@ )] mod crc32; +pub mod edit; mod guid; use core::num::NonZeroU64; @@ -385,7 +386,7 @@ struct Disk { /// driver's own accepted range, which is not a coincidence: above 4096 the /// block no longer divides the kernel's 4 KiB block, and below 512 the GPT /// header does not fit in one. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum LbaSize { B512, B1024, @@ -416,6 +417,15 @@ impl LbaSize { } } + const fn bytes_usize(self) -> usize { + match self { + Self::B512 => 512, + Self::B1024 => 1024, + Self::B2048 => 2048, + Self::B4096 => 4096, + } + } + fn of_block(self, block: &mut Block) -> &mut [u8] { match self { Self::B512 => &mut block[..512], diff --git a/lib/gpt/tests/edit.rs b/lib/gpt/tests/edit.rs new file mode 100644 index 00000000000..550163d3395 --- /dev/null +++ b/lib/gpt/tests/edit.rs @@ -0,0 +1,382 @@ +//! The writer: what it writes reads back through both readers, refuses what +//! UEFI 2.10 §5.3 forbids, reports each copy's state, and survives a crash at +//! every point of its write order. + +mod table; + +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use table::{Image, Rng}; +use toyos_gpt::edit::{read_copies, CopyError, Geometry, Invalid, Mbr, Slot, Step, Table, Which}; +use toyos_gpt::{GptError, Guid}; + +const SEED: u64 = 0x6770_745F_6564_6974; + +fn image(lba_bytes: u32, lba_count: u64) -> Image { + Image { lba_bytes, lba_count, granularity: 1, bytes: vec![0; (lba_bytes as u64 * lba_count) as usize], fail_at: None } +} + +/// One step as a crash model holds it: a run of whole blocks, or a flush. +#[derive(Clone, Debug)] +enum Owned { + Write { lba: u64, bytes: Vec }, + Flush, +} + +fn owned(table: &Table, geometry: &Geometry, mbr: bool) -> Vec { + let encoded = table.encode(geometry).expect("a valid table"); + encoded + .steps(mbr) + .map(|s| match s { + Step::Write { lba, bytes } => Owned::Write { lba, bytes: bytes.to_vec() }, + Step::Flush => Owned::Flush, + }) + .collect() +} + +fn copy(img: &Image) -> Image { + Image { lba_bytes: img.lba_bytes, lba_count: img.lba_count, granularity: 1, bytes: img.bytes.clone(), fail_at: None } +} + +fn apply(img: &mut Image, lba: u64, bytes: &[u8]) { + let at = (lba * u64::from(img.lba_bytes)) as usize; + img.bytes[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn write(img: &mut Image, steps: &[Owned]) { + for s in steps { + if let Owned::Write { lba, bytes } = s { + apply(img, *lba, bytes); + } + } +} + +fn slot(rng: &mut Rng, kind: Guid, first: u64, last: u64, name: &str) -> Slot { + Slot { + type_guid: kind, + unique_guid: rng.guid(), + first, + last, + attributes: rng.next() & (1 << 63 | 1), + name: Slot::named(name).expect("a short name"), + } +} + +/// A table of a few partitions spread over `geometry`, at random indices. +fn some_table(rng: &mut Rng, geometry: &Geometry) -> Table { + let mut t = Table::empty(geometry, rng.guid()); + let (first, last) = geometry.usable(); + let span = (last - first + 1) / 8; + let kinds = [Guid::EFI_SYSTEM, Guid::TOYOS_DATA, Guid::TOYOS_ROOT, Guid::MICROSOFT_BASIC]; + for i in 0..rng.range(1, 6) { + let lo = first + i * span; + let index = loop { + let at = rng.below(128) as usize; + if !t.slots[at].is_used() { + break at; + } + }; + let (kind, len) = (rng.pick(&kinds), rng.below(span)); + t.slots[index] = slot(rng, kind, lo, lo + len, &format!("part {i}")); + } + t +} + +/// What `toyos_gpt::list`, the reader every disk driver serves from, finds: +/// each partition's index, type, unique GUID and range. +fn listed(img: &mut Image) -> Result, GptError> { + let mut out = [None; 128]; + toyos_gpt::list(img, &mut out)?; + Ok(out + .iter() + .flatten() + .map(|e| { + let p = e.expect("placed"); + (p.index(), (p.type_guid(), p.unique_guid(), p.first_lba(), p.last_lba())) + }) + .collect()) +} + +fn listing_of(t: &Table) -> BTreeMap { + t.used().map(|(i, s)| (i, (s.type_guid, s.unique_guid, s.first, s.last))).collect() +} + +/// Every block size the crate parses, on devices of whole 4 KiB blocks and +/// of not whole: both copies read back as the table, and `list` finds it. +#[test] +fn a_written_table_reads_back_from_both_copies() { + let mut rng = Rng::new(SEED); + for (lba_bytes, lba_count) in [(512, 262_144), (512, 7_507_812 / 32), (1024, 70_001), (2048, 40_000), (4096, 9_000)] { + let geometry = Geometry::new(lba_bytes, lba_count).expect("room"); + for _ in 0..20 { + let t = some_table(&mut rng, &geometry); + let mut img = image(lba_bytes, lba_count); + write(&mut img, &owned(&t, &geometry, true)); + let copies = read_copies(&mut img).expect("a geometry"); + assert_eq!(copies.mbr, Mbr::Protective); + assert_eq!(copies.primary.as_ref(), Ok(&t), "{lba_bytes}x{lba_count}"); + assert!(copies.agree()); + assert_eq!(listed(&mut img), Ok(listing_of(&t))); + let backup = geometry.backup_header_lba() as usize * lba_bytes as usize; + assert_eq!(&img.bytes[backup..backup + 8], b"EFI PART", "the backup header is on the last LBA"); + } + } +} + +/// The ROWS a boot image lays, on 1 MiB boundaries, one of them a length the +/// alignment does not round. +const ROWS: [(&str, Guid, u64); 6] = [ + ("ToyOS data", Guid::TOYOS_DATA, 3 << 20), + ("ESP", Guid::EFI_SYSTEM, 34 << 20), + ("ToyOS slots", Guid::TOYOS_SLOTS, 1 << 20), + ("ToyOS log", Guid::MICROSOFT_BASIC, 34 << 20), + ("ToyOS slot A", Guid::TOYOS_BOOT, (40 << 20) + 4096), + ("ToyOS root A", Guid::TOYOS_ROOT, 7 << 20), +]; + +/// **The differential oracle**: the `gpt` crate, a third party's writer +/// sharing no code with this one, lays the same partitions on the same disk +/// under the same GUIDs into the same bytes, on a disk the alignment rounds +/// and on one it does not. +#[test] +fn the_gpt_crate_writes_the_same_table() { + const LBA: u64 = 512; + for total in [128u64 << 20, (160 << 20) + 3 * 4096] { + let mut theirs = vec![0u8; total as usize]; + let mut cursor = Cursor::new(&mut theirs); + gpt::mbr::ProtectiveMBR::with_lb_size(u32::try_from(total / LBA - 1).unwrap_or(u32::MAX)) + .overwrite_lba0(&mut cursor) + .expect("the protective MBR"); + let mut disk = gpt::GptConfig::default() + .initialized(false) + .writable(true) + .logical_block_size(gpt::disk::LogicalBlockSize::Lb512) + .create_from_device(Box::new(cursor), None) + .expect("a table"); + disk.update_partitions(BTreeMap::new()).expect("an empty table"); + for (name, kind, len) in ROWS { + let kind = gpt::partition_types::Type { guid: kind.to_string().leak(), os: gpt::partition_types::OperatingSystem::None }; + disk.add_partition(name, len, kind, 0, Some(2048)).expect("a partition"); + } + let mut device = disk.write().expect("the table"); + let mut theirs = vec![0u8; total as usize]; + device.seek(SeekFrom::Start(0)).and_then(|_| device.read_exact(&mut theirs)).expect("read back"); + let guid = |at: usize| Guid(theirs[at..at + 16].try_into().unwrap()); + + let geometry = Geometry::new(LBA as u32, total / LBA).expect("room"); + let mut t = Table::empty(&geometry, guid(LBA as usize + 56)); + let mut at = 2048; + for (i, (name, kind, len)) in ROWS.iter().enumerate() { + let first = at; + at = (first + len / LBA).next_multiple_of(2048); + t.slots[i] = Slot { + type_guid: *kind, + unique_guid: guid(2 * LBA as usize + i * 128 + 16), + first, + last: first + len / LBA - 1, + attributes: 0, + name: Slot::named(name).unwrap(), + }; + } + let mut ours = image(LBA as u32, total / LBA); + write(&mut ours, &owned(&t, &geometry, true)); + let differing = (0..total as usize).filter(|&i| ours.bytes[i] != theirs[i]).count(); + assert_eq!(differing, 0, "{total}: {differing} bytes differ from the gpt crate's"); + } +} + +/// Each thing §5.3 forbids is refused by name, before a byte is encoded. +#[test] +fn a_table_uefi_forbids_is_refused() { + let mut rng = Rng::new(SEED ^ 1); + let g = Geometry::new(512, 100_000).unwrap(); + let (first, last) = g.usable(); + assert_eq!((first, last), (34, 100_000 - 34), "§5.3.1's minimum at 512-byte blocks"); + assert_eq!(Geometry::new(4096, 100).unwrap().usable(), (6, 100 - 6), "and at 4096"); + let base = |rng: &mut Rng| { + let mut t = Table::empty(&g, rng.guid()); + t.slots[0] = slot(rng, Guid::TOYOS_DATA, 2048, 4095, "a"); + t.slots[5] = slot(rng, Guid::TOYOS_ROOT, 4096, 8191, "b"); + t + }; + assert_eq!(base(&mut rng).check(&g), Ok(())); + let cases: [(&str, fn(&mut Table), Invalid); 8] = [ + ("zero disk", |t| t.disk_guid = Guid::ZERO, Invalid::DiskGuid), + ("usable over the primary", |t| t.first_usable = 33, Invalid::UsableRange { first: 33, last: 100_000 - 34 }), + ("usable over the backup", |t| t.last_usable += 1, Invalid::UsableRange { first: 34, last: 100_000 - 33 }), + ("reversed", |t| t.slots[5].last = 4000, Invalid::OutsideUsable { index: 5 }), + ("past usable", |t| t.slots[5].last = 100_000 - 33, Invalid::OutsideUsable { index: 5 }), + ("overlap", |t| t.slots[5].first = 4095, Invalid::Overlap { first: 0, second: 5 }), + ("zero unique", |t| t.slots[0].unique_guid = Guid::ZERO, Invalid::ZeroUnique { index: 0 }), + ("unused not zero", |t| t.slots[9].first = 1, Invalid::UnusedNotZero { index: 9 }), + ]; + for (what, bend, want) in cases { + let mut t = base(&mut rng); + bend(&mut t); + assert_eq!(t.check(&g), Err(want), "{what}"); + assert!(t.encode(&g).is_err(), "{what} encoded"); + } + let mut t = base(&mut rng); + t.slots[5].unique_guid = t.slots[0].unique_guid; + assert_eq!(t.check(&g), Err(Invalid::DuplicateUnique { first: 0, second: 5 })); + assert_eq!(Slot::named(&"x".repeat(36)).map(|n| n[35]), Some(u16::from(b'x'))); + assert_eq!(Slot::named(&"x".repeat(37)), None, "37 units"); +} + +/// What each copy holds is reported alone: a torn primary leaves the backup +/// in force, a torn backup leaves the primary in force and the two not in +/// agreement, two valid copies that differ do not agree, an MBR disk and a +/// blank one are named, and an array of another shape is not read as one. +#[test] +fn each_copy_is_read_and_reported_alone() { + let mut rng = Rng::new(SEED ^ 2); + let g = Geometry::new(512, 50_000).unwrap(); + let t = some_table(&mut rng, &g); + let mut whole = image(512, 50_000); + write(&mut whole, &owned(&t, &g, true)); + let header = |which: Which| match which { + Which::Primary => 512, + Which::Backup => g.backup_header_lba() as usize * 512, + }; + + for which in [Which::Primary, Which::Backup] { + let mut torn = copy(&whole); + torn.bytes[header(which) + 40] ^= 1; + let copies = read_copies(&mut torn).unwrap(); + let other = if which == Which::Primary { Which::Backup } else { Which::Primary }; + assert_eq!(copies.in_force().map(|(w, t)| (w, t.clone())), Some((other, t.clone())), "{which:?} torn"); + assert!(!copies.agree()); + let bad = if which == Which::Primary { &copies.primary } else { &copies.backup }; + assert!(matches!(bad, Err(CopyError::Gpt(GptError::HeaderCrc { .. }))), "{bad:?}"); + } + + // A second valid table in the backup's place alone. + let mut changed = t.clone(); + changed.slots.iter_mut().find(|s| s.is_used()).unwrap().name = Slot::named("renamed").unwrap(); + let mut differing = copy(&whole); + let steps = owned(&changed, &g, false); + write(&mut differing, &steps[..2]); + let copies = read_copies(&mut differing).unwrap(); + assert_eq!(copies.in_force().map(|(w, _)| w), Some(Which::Primary)); + assert!(copies.primary.is_ok() && copies.backup.is_ok() && !copies.agree()); + + let mut mbr = image(512, 50_000); + mbr.bytes[446 + 4] = 0x83; + mbr.bytes[510..512].copy_from_slice(&[0x55, 0xAA]); + let copies = read_copies(&mut mbr).unwrap(); + assert_eq!((copies.mbr, copies.in_force()), (Mbr::Other, None)); + assert_eq!(read_copies(&mut image(512, 50_000)).unwrap().mbr, Mbr::Blank); + assert!(read_copies(&mut image(512, 66)).is_none(), "no room for both copies and a block"); + + // The same table with 256-byte entries, resealed: valid, and not this shape. + let mut wide = copy(&whole); + let h = 512; + wide.bytes[h + 80..h + 84].copy_from_slice(&64u32.to_le_bytes()); + wide.bytes[h + 84..h + 88].copy_from_slice(&256u32.to_le_bytes()); + wide.bytes[h + 16..h + 20].fill(0); + let crc = toyos_gpt::crc32(&wide.bytes[h..h + 92]); + wide.bytes[h + 16..h + 20].copy_from_slice(&crc.to_le_bytes()); + let copies = read_copies(&mut wide).unwrap(); + assert_eq!(copies.primary, Err(CopyError::Shape { entries: 64, entry_bytes: 256 })); +} + +/// What a crash may leave of one write: nothing, all of it, or any subset of +/// its blocks. +#[derive(Clone, Copy, Debug)] +enum Landed { + None, + Whole, + Torn(u64), +} + +/// Show `each` every image a crash during `steps` over `old` may leave, and +/// answer how many. A flush is a barrier: everything before it is on the +/// medium. Between two flushes each write landed whole, not at all, or torn +/// into an arbitrary subset of its blocks; `torn` draws that many subsets +/// per write. +fn crashes(old: &Image, steps: &[Owned], rng: &mut Rng, torn: usize, each: &mut dyn FnMut(&str, &mut Image)) -> usize { + let epochs: Vec<&[Owned]> = steps.split(|s| matches!(s, Owned::Flush)).collect(); + let mut seen = 0; + let mut base = copy(old); + for (e, epoch) in epochs.iter().enumerate() { + let writes: Vec<(u64, &Vec)> = + epoch.iter().filter_map(|s| if let Owned::Write { lba, bytes } = s { Some((*lba, bytes)) } else { None }).collect(); + let choices: Vec> = writes + .iter() + .map(|_| { + let mut c = vec![Landed::None, Landed::Whole]; + c.extend((0..torn).map(|_| Landed::Torn(rng.next()))); + c + }) + .collect(); + let mut pick = vec![0usize; writes.len()]; + loop { + let mut img = copy(&base); + for ((lba, bytes), (c, k)) in writes.iter().zip(choices.iter().zip(&pick)) { + let blocks = bytes.chunks(old.lba_bytes as usize).enumerate(); + for (i, b) in blocks { + let lands = match c[*k] { + Landed::None => false, + Landed::Whole => true, + Landed::Torn(mask) => mask.rotate_left(i as u32) & 1 == 1, + }; + if lands { + apply(&mut img, lba + i as u64, b); + } + } + } + let at = format!("epoch {e} {:?}", pick.iter().zip(&choices).map(|(k, c)| c[*k]).collect::>()); + each(&at, &mut img); + seen += 1; + let Some(next) = (0..pick.len()).find(|&i| pick[i] + 1 < choices[i].len()) else { break }; + pick[next] += 1; + pick[..next].fill(0); + } + for (lba, bytes) in writes { + apply(&mut base, lba, bytes); + } + } + seen +} + +/// Whether a crash left an image read as wholly the old table or wholly the +/// new one by the reader drivers serve from, and §5.3.2's order held: the +/// primary is never newer than the backup. +fn judge(img: &mut Image, old: &Table, new: &Table) -> Result<(), String> { + let seen = listed(img).map_err(|e| format!("no table: {e:?}"))?; + if seen != listing_of(old) && seen != listing_of(new) { + return Err(format!("neither table: {seen:?}")); + } + let copies = read_copies(img).ok_or("no geometry")?; + if copies.primary.as_ref() == Ok(new) && copies.backup.as_ref() != Ok(new) { + return Err("the primary is new and the backup is not".into()); + } + Ok(()) +} + +/// The write order's whole claim: a crash anywhere — between any two writes, +/// or inside one, any subset of its blocks landed — leaves the disk reading +/// as the old table or the new one, never neither and never a mixture, and +/// the primary never newer than the backup. +#[test] +fn a_crash_anywhere_in_the_write_reads_as_the_old_table_or_the_new() { + let mut rng = Rng::new(SEED ^ 3); + for (lba_bytes, lba_count) in [(512u32, 40_000u64), (4096, 5_000)] { + let g = Geometry::new(lba_bytes, lba_count).unwrap(); + for _ in 0..4 { + let old = some_table(&mut rng, &g); + let mut new = some_table(&mut rng, &g); + new.disk_guid = old.disk_guid; + let mut disk = image(lba_bytes, lba_count); + write(&mut disk, &owned(&old, &g, true)); + let steps = owned(&new, &g, false); + let states = crashes(&disk, &steps, &mut rng, 6, &mut |at, img| { + if let Err(why) = judge(img, &old, &new) { + panic!("{lba_bytes}x{lba_count}, a crash at {at}: {why}"); + } + }); + assert!(states > 100, "{states} crash states"); + } + } +} diff --git a/system/fileserver/src/data.rs b/system/fileserver/src/data.rs index 15739097bba..a19b3eb3c8b 100644 --- a/system/fileserver/src/data.rs +++ b/system/fileserver/src/data.rs @@ -197,12 +197,7 @@ fn designated(cache: &Cache) -> bool { return false; } let [block0] = block0; - let magic = bcachefs::DESIGNATION_MAGIC; - let at = bcachefs::DESIGNATION_BLOCKS_OFFSET; - if block0[..magic.len()] != magic { - return false; - } - let stamped = u64::from_le_bytes(block0[at..at + 8].try_into().expect("eight bytes")); + let Some(stamped) = bcachefs::designated(&block0) else { return false }; if stamped != cache.blocks() { println!( "fileserver: a designation stamp at block 0 names {stamped} blocks, and this partition has {}; ignoring it", diff --git a/tests/common/hostdisk.rs b/tests/common/hostdisk.rs index 5523b9fb8d3..7e2386cc129 100644 --- a/tests/common/hostdisk.rs +++ b/tests/common/hostdisk.rs @@ -33,16 +33,14 @@ pub fn bytes() -> Vec { let mut entries = Vec::new(); for (i, (name, kind, len)) in PARTS.iter().enumerate() { let unique = Guid::from_fields(0x7057_0000 + i as u32, 0, 0x4000, [0x80, 0, 0, 0, 0, 0, 0, i as u8]); - entries.push(toyos_build::gptwrite::Entry { kind: *kind, unique, first_lba: at / LBA, last_lba: (at + len) / LBA - 1, name }); + entries.push(toyos_gpt::edit::Slot::new(*kind, unique, at / LBA, (at + len) / LBA - 1, name).expect("a short name")); for (sector, block) in disk[at as usize..(at + len) as usize].chunks_mut(LBA as usize).enumerate() { block.fill(((i as u8 + 1).wrapping_mul(0x25) ^ sector as u8) | 1); } at += len; } - let table = toyos_build::gptwrite::table(total / LBA, Guid::from_fields(0x7057_00ff, 0, 0x4000, [0x80; 8]), &entries); - disk[..table.primary.len()].copy_from_slice(&table.primary); - let backup = disk.len() - table.backup.len(); - disk[backup..].copy_from_slice(&table.backup); + let disk_guid = Guid::from_fields(0x7057_00ff, 0, 0x4000, [0x80; 8]); + toyos_build::image::write_gpt(&mut std::io::Cursor::new(&mut disk), total, disk_guid, &entries); disk } diff --git a/tests/metal/src/internal.rs b/tests/metal/src/internal.rs index d701148e5a2..6a993f42d0e 100644 --- a/tests/metal/src/internal.rs +++ b/tests/metal/src/internal.rs @@ -574,21 +574,12 @@ mod tests { fn image(dir: &Path, name: &str, parts: &[(Guid, Guid)], slots: [Option<(Guid, Guid)>; 2]) -> PathBuf { const ALIGN: u64 = 2048; const TOTAL: u64 = 32 << 20; - let entries: Vec> = (1..) + let entries: Vec = (1..) .zip(parts) - .map(|(i, (kind, unique))| toyos_build::gptwrite::Entry { - kind: *kind, - unique: *unique, - first_lba: i * ALIGN, - last_lba: i * ALIGN + 511, - name: "x", - }) + .map(|(i, (kind, unique))| toyos_gpt::edit::Slot::new(*kind, *unique, i * ALIGN, i * ALIGN + 511, "x").unwrap()) .collect(); - let table = toyos_build::gptwrite::table(TOTAL / u64::from(LBA), toyos_build::gptwrite::random_guid(), &entries); let mut out = vec![0u8; TOTAL as usize]; - out[..table.primary.len()].copy_from_slice(&table.primary); - let back = out.len() - table.backup.len(); - out[back..].copy_from_slice(&table.backup); + toyos_build::image::write_gpt(&mut std::io::Cursor::new(&mut out), TOTAL, toyos_build::image::random_guid(), &entries); let slot = |s: Option<(Guid, Guid)>| s.map(|(b, r)| toyos_update::slots::Slot { boot: b.0, root: r.0, version: 1 }); let slots = toyos_update::slots::Table { sequence: 1, marked: toyos_update::slots::Which::A, slots: slots.map(slot) }; let at = parts.iter().position(|(k, _)| *k == Guid::TOYOS_SLOTS).expect("a slot table") as u64 + 1; @@ -602,7 +593,7 @@ mod tests { #[test] fn an_image_is_admitted_piece_by_piece_by_its_slot_table() { let dir = toyos_tmpdir::TempDir::new("internal"); - let g = |_| toyos_build::gptwrite::random_guid(); + let g = |_| toyos_build::image::random_guid(); let [esp, slots, log, ba, ra, bb, rb, other]: [Guid; 8] = std::array::from_fn(g); let built = [ (Guid::EFI_SYSTEM, esp), (Guid::TOYOS_SLOTS, slots), (Guid::MICROSOFT_BASIC, log), diff --git a/tests/metal/src/lib.rs b/tests/metal/src/lib.rs index be53c64a004..2926fca80c8 100644 --- a/tests/metal/src/lib.rs +++ b/tests/metal/src/lib.rs @@ -1559,7 +1559,7 @@ impl Driver { } let own = |o: Owned| parts[Owned::ALL.iter().position(|x| *x == o).expect("listed")].clone(); let kept = |o: Owned| own(o).unique.expect("recognise refuses a partition with no GUID"); - let named = image.guids(toyos_build::gptwrite::random_guid); + let named = image.guids(toyos_build::image::random_guid); let mut every: Vec = vec![kept(Owned::Esp), kept(Owned::Slots)]; every.extend(named.iter().map(|(_, guid)| *guid)); let holders = self.ssh("listing every partition's GUID", internal::HOLDERS)?; @@ -3586,21 +3586,14 @@ mod tests { fn disk(parts: &[(&str, toyos_gpt::Guid)]) -> Vec { const TOTAL: u64 = 16 * 1024 * 1024; const ALIGN: u64 = 2048; - let entries: Vec> = (1..) + let entries: Vec = (1..) .zip(parts) - .map(|(i, (name, kind))| toyos_build::gptwrite::Entry { - kind: *kind, - unique: toyos_build::gptwrite::random_guid(), - first_lba: i * ALIGN, - last_lba: i * ALIGN + 511, - name, + .map(|(i, (name, kind))| { + toyos_gpt::edit::Slot::new(*kind, toyos_build::image::random_guid(), i * ALIGN, i * ALIGN + 511, name).unwrap() }) .collect(); - let table = toyos_build::gptwrite::table(TOTAL / u64::from(LBA), toyos_build::gptwrite::random_guid(), &entries); let mut out = vec![0u8; TOTAL as usize]; - out[..table.primary.len()].copy_from_slice(&table.primary); - let back = out.len() - table.backup.len(); - out[back..].copy_from_slice(&table.backup); + toyos_build::image::write_gpt(&mut std::io::Cursor::new(&mut out), TOTAL, toyos_build::image::random_guid(), &entries); out } From 34352aead50199e71745add7ab560b7ecf4013ea Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 09:44:05 +0100 Subject: [PATCH 02/14] drivers read their table to the disk's true last sector; the parser's count floor goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UEFI 2.10 §5.3.1 puts the backup header on the device's last LBA. nvme and usbd read their table through a 4 KiB-block view whose count they floored, so on a disk whose sector count is not whole blocks — one bench stick has 7507812 512-byte sectors — the backup was never where the parser looked, and `toyos_gpt::Sectors::lba_count_granularity` existed to concede the difference. Both now read every sector: whole blocks where the block is whole, and the tail one sector at a time (nvme's `read_sectors`, usbd's READ(10) of the sectors the last block has). The concession has no reader left and goes, with the floored-device test machinery. Co-Authored-By: Claude Opus 5.5 --- build/image.rs | 6 ------ lib/gpt/src/edit.rs | 11 ++--------- lib/gpt/src/lib.rs | 19 +++++-------------- lib/gpt/tests/edit.rs | 17 ++++++++++------- lib/gpt/tests/mutate.rs | 1 - lib/gpt/tests/oracle.rs | 1 - lib/gpt/tests/parse.rs | 17 ----------------- lib/gpt/tests/table/mod.rs | 14 ++------------ loader/src/rootimage.rs | 3 --- system/nvme/src/main.rs | 25 +++++++++++++------------ system/nvme/src/nvme.rs | 28 ++++++++++++++++++---------- system/usbd/xhci/src/msc.rs | 27 +++++++++++---------------- 12 files changed, 61 insertions(+), 108 deletions(-) diff --git a/build/image.rs b/build/image.rs index 18752106a37..ed39c589232 100644 --- a/build/image.rs +++ b/build/image.rs @@ -768,9 +768,6 @@ impl toyos_gpt::Sectors for FileSectors<'_> { self.0.metadata().map(|m| m.len()).unwrap_or(0) / u64::from(LBA) } - fn lba_count_granularity(&self) -> NonZeroU64 { - NonZeroU64::new(1).expect("one is not zero") - } fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { self.0.seek(SeekFrom::Start(lba * u64::from(LBA))).is_ok() && self.0.read_exact(buf).is_ok() @@ -881,9 +878,6 @@ impl toyos_gpt::Sectors for ImageSectors<'_> { self.0.len() as u64 / u64::from(LBA) } - fn lba_count_granularity(&self) -> NonZeroU64 { - NonZeroU64::new(1).expect("one is not zero") - } fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { let at = lba as usize * LBA as usize; diff --git a/lib/gpt/src/edit.rs b/lib/gpt/src/edit.rs index ea27460e36b..03b326e2569 100644 --- a/lib/gpt/src/edit.rs +++ b/lib/gpt/src/edit.rs @@ -5,9 +5,7 @@ //! [`ENTRIES`] entries of [`ENTRY_BYTES`]; a copy of another shape reads as //! [`CopyError::Shape`]. The writer lays the arrays where §5.3.1 puts them: //! the primary's at LBA 2, the backup's ending just before the device's last -//! LBA, which holds the backup header. The device's true last LBA: a -//! [`Sectors`] that floors its count reads a copy this writes at the wrong -//! place. +//! LBA, which holds the backup header. use crate::{check_protective_mbr, header_crc, parse_header, read, Block, Crc32, Disk, GptError, Guid, LbaSize, Sectors}; @@ -388,12 +386,7 @@ pub fn read_copies(dev: &mut dyn Sectors) -> Option { } else { Mbr::Blank }; - let disk = Disk { - lba: geometry.lba, - lba_count: geometry.lba_count, - backup_header_lba: geometry.backup_header_lba(), - lba_count_slack: dev.lba_count_granularity().get().saturating_sub(1), - }; + let disk = Disk { lba: geometry.lba, lba_count: geometry.lba_count, backup_header_lba: geometry.backup_header_lba() }; let primary = read_copy(dev, &disk, 1); let backup = read_copy(dev, &disk, geometry.backup_header_lba()); Some(Copies { geometry, mbr, primary, backup }) diff --git a/lib/gpt/src/lib.rs b/lib/gpt/src/lib.rs index 74aa769277f..0c5a0b6f374 100644 --- a/lib/gpt/src/lib.rs +++ b/lib/gpt/src/lib.rs @@ -73,10 +73,8 @@ const MBR_TYPE_PROTECTIVE: u8 = 0xEE; /// inside the parser: the parser must not have to know 4096-byte reads exist. pub trait Sectors { fn lba_bytes(&self) -> u32; + /// Every logical block of the device: the backup table is on the last. fn lba_count(&self) -> u64; - /// Granularity of `lba_count()` in logical blocks. A count floored by a - /// coarser reader can omit at most one less than this value. - fn lba_count_granularity(&self) -> NonZeroU64; /// Fill `buf` — exactly `lba_bytes()` long — with logical block `lba`. /// `false` means the read did not happen and its contents are unknown. fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool; @@ -374,7 +372,6 @@ struct Disk { lba_count: u64, /// `lba_count - 1`, where the backup header is: at least 2. backup_header_lba: u64, - lba_count_slack: u64, } /// A logical block size this crate parses, so one block is a fixed prefix of @@ -447,7 +444,6 @@ fn open_disk(dev: &mut dyn Sectors) -> Result { Some(last) if last >= 2 => last, _ => return Err(GptError::DeviceTooSmall(lba_count)), }; - let lba_count_slack = dev.lba_count_granularity().get().saturating_sub(1); let mut block: Block = [0; 4096]; let block = lba.of_block(&mut block); @@ -455,7 +451,7 @@ fn open_disk(dev: &mut dyn Sectors) -> Result { read(dev, 0, block)?; check_protective_mbr(block)?; - Ok(Disk { lba, lba_count, backup_header_lba, lba_count_slack }) + Ok(Disk { lba, lba_count, backup_header_lba }) } /// [`locate_type`]'s work against one header, primary or backup. @@ -586,7 +582,7 @@ impl StatedHeader { } fn parse_header(lba1: &[u8], disk: &Disk, header_lba: u64) -> Result { - let Disk { lba, lba_count, backup_header_lba, lba_count_slack } = *disk; + let Disk { lba, lba_count, .. } = *disk; let lba_bytes = lba.bytes(); let Some(stated) = StatedHeader::decode(lba1) else { return Err(GptError::NoHeader); @@ -657,13 +653,8 @@ fn parse_header(lba1: &[u8], disk: &Disk, header_lba: u64) -> Result= backup_array_lba { return Err(GptError::UsableRangeCoversBackup { last: last_usable_lba, backup_array_lba }); } diff --git a/lib/gpt/tests/edit.rs b/lib/gpt/tests/edit.rs index 550163d3395..a6228d94822 100644 --- a/lib/gpt/tests/edit.rs +++ b/lib/gpt/tests/edit.rs @@ -14,7 +14,7 @@ use toyos_gpt::{GptError, Guid}; const SEED: u64 = 0x6770_745F_6564_6974; fn image(lba_bytes: u32, lba_count: u64) -> Image { - Image { lba_bytes, lba_count, granularity: 1, bytes: vec![0; (lba_bytes as u64 * lba_count) as usize], fail_at: None } + Image { lba_bytes, lba_count, bytes: vec![0; (lba_bytes as u64 * lba_count) as usize], fail_at: None } } /// One step as a crash model holds it: a run of whole blocks, or a flush. @@ -36,7 +36,7 @@ fn owned(table: &Table, geometry: &Geometry, mbr: bool) -> Vec { } fn copy(img: &Image) -> Image { - Image { lba_bytes: img.lba_bytes, lba_count: img.lba_count, granularity: 1, bytes: img.bytes.clone(), fail_at: None } + Image { lba_bytes: img.lba_bytes, lba_count: img.lba_count, bytes: img.bytes.clone(), fail_at: None } } fn apply(img: &mut Image, lba: u64, bytes: &[u8]) { @@ -83,9 +83,11 @@ fn some_table(rng: &mut Rng, geometry: &Geometry) -> Table { t } -/// What `toyos_gpt::list`, the reader every disk driver serves from, finds: -/// each partition's index, type, unique GUID and range. -fn listed(img: &mut Image) -> Result, GptError> { +/// Each partition by index: its type, unique GUID, first and last block. +type Listing = BTreeMap; + +/// What `toyos_gpt::list`, the reader every disk driver serves from, finds. +fn listed(img: &mut Image) -> Result { let mut out = [None; 128]; toyos_gpt::list(img, &mut out)?; Ok(out @@ -98,7 +100,7 @@ fn listed(img: &mut Image) -> Result, GptE .collect()) } -fn listing_of(t: &Table) -> BTreeMap { +fn listing_of(t: &Table) -> Listing { t.used().map(|(i, s)| (i, (s.type_guid, s.unique_guid, s.first, s.last))).collect() } @@ -201,7 +203,8 @@ fn a_table_uefi_forbids_is_refused() { t }; assert_eq!(base(&mut rng).check(&g), Ok(())); - let cases: [(&str, fn(&mut Table), Invalid); 8] = [ + type Case = (&'static str, fn(&mut Table), Invalid); + let cases: [Case; 8] = [ ("zero disk", |t| t.disk_guid = Guid::ZERO, Invalid::DiskGuid), ("usable over the primary", |t| t.first_usable = 33, Invalid::UsableRange { first: 33, last: 100_000 - 34 }), ("usable over the backup", |t| t.last_usable += 1, Invalid::UsableRange { first: 34, last: 100_000 - 33 }), diff --git a/lib/gpt/tests/mutate.rs b/lib/gpt/tests/mutate.rs index e8f5baf106a..63b0a810f61 100644 --- a/lib/gpt/tests/mutate.rs +++ b/lib/gpt/tests/mutate.rs @@ -17,7 +17,6 @@ const SHAPE: Shape<'static> = Shape { types: &[Guid::EFI_SYSTEM, Guid::MICROSOFT_BASIC, Guid::TOYOS_DATA, Guid::TOYOS_ROOT], entry_sizes: &[128, 128, 128, 128, 256, 512], backup: true, - floored: true, }; /// A partition as the parser handed it back. diff --git a/lib/gpt/tests/oracle.rs b/lib/gpt/tests/oracle.rs index 26c56f9afcd..99385f44620 100644 --- a/lib/gpt/tests/oracle.rs +++ b/lib/gpt/tests/oracle.rs @@ -18,7 +18,6 @@ const SHAPE: Shape<'static> = Shape { // `gpt` reads 128-byte entries whatever the header says. entry_sizes: &[128], backup: false, - floored: false, }; const VALID: u64 = 2_000; diff --git a/lib/gpt/tests/parse.rs b/lib/gpt/tests/parse.rs index 9fe8565964a..05756208562 100644 --- a/lib/gpt/tests/parse.rs +++ b/lib/gpt/tests/parse.rs @@ -64,7 +64,6 @@ fn disk(edit: impl FnOnce(&mut Layout)) -> Image { }, backup: None, reported_lba_count: DISK_LBAS, - granularity: 1, mbr_type: 0xEE, mbr_signature: [0x55, 0xAA], }; @@ -513,22 +512,6 @@ fn a_usable_range_reaching_the_backup_gpt_is_refused() { ); } -/// The kernel's 4 KiB view floors a 512-byte disk's `lba_count` by up to 7 -/// LBAs while an honest table is laid out against the true end — a 2055-LBA -/// disk (2055 % 8 = 7) seen as 2048, its last_usable 2021 at the conceded -/// bound's edge, must parse. The unconceded bound refused every such disk. -#[test] -fn an_honest_table_on_a_floored_device_view_parses() { - let mut img = disk(|l| { - l.lba_count = 2055; - l.primary.last_usable = 2055 - 34; - mirrored(l); - }); - (img.lba_count, img.granularity) = (2048, 8); - let found = img.locate(guid(0xC3)).expect("an honest disk lost /boot"); - assert_eq!(found.partition().index(), 2); -} - /// UEFI gives every entry a `UniquePartitionGUID` that must be unique. Two /// entries claiming the searched-for GUID must refuse, never resolve /// first-wins — either one could be the partition the firmware meant. diff --git a/lib/gpt/tests/table/mod.rs b/lib/gpt/tests/table/mod.rs index 252caedf1af..92ee65ffb7c 100644 --- a/lib/gpt/tests/table/mod.rs +++ b/lib/gpt/tests/table/mod.rs @@ -98,9 +98,8 @@ pub struct Layout { pub lba_count: u64, pub primary: Table, pub backup: Option, - /// What the device answers for its block count, and the granularity it floors by. + /// What the device answers for its block count. pub reported_lba_count: u64, - pub granularity: u64, pub mbr_type: u8, pub mbr_signature: [u8; 2], } @@ -112,8 +111,6 @@ pub struct Shape<'a> { pub entry_sizes: &'a [u32], /// Some layouts carry no backup copy. pub backup: bool, - /// Some devices floor their block count, as the kernel's 4 KiB reader does. - pub floored: bool, } /// A table UEFI 2.11 §5.3 accepts, with up to eight disjoint partitions at random indices. @@ -164,14 +161,12 @@ pub fn valid(rng: &mut Rng, shape: &Shape<'_>) -> Layout { entries, }; let backup = (shape.backup && !rng.one_in(3)).then(|| primary.mirror(lba_bytes, lba_count)); - let granularity = if shape.floored && lba_bytes == 512 && rng.one_in(4) { 8 } else { 1 }; Layout { lba_bytes, lba_count, primary, backup, - reported_lba_count: lba_count / granularity * granularity, - granularity, + reported_lba_count: lba_count, mbr_type: 0xEE, mbr_signature: [0x55, 0xAA], } @@ -334,7 +329,6 @@ pub fn image(layout: &Layout) -> Image { Image { lba_bytes: layout.lba_bytes, lba_count: layout.reported_lba_count, - granularity: layout.granularity, bytes: disk, fail_at: None, } @@ -343,7 +337,6 @@ pub fn image(layout: &Layout) -> Image { pub struct Image { pub lba_bytes: u32, pub lba_count: u64, - pub granularity: u64, pub bytes: Vec, /// The one block whose read does not happen. pub fail_at: Option, @@ -356,9 +349,6 @@ impl Sectors for Image { fn lba_count(&self) -> u64 { self.lba_count } - fn lba_count_granularity(&self) -> core::num::NonZeroU64 { - core::num::NonZeroU64::new(self.granularity).expect("1 or 8") - } fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { if self.fail_at == Some(lba) { return false; diff --git a/loader/src/rootimage.rs b/loader/src/rootimage.rs index 0d3faaaae1e..e0dd8035a53 100644 --- a/loader/src/rootimage.rs +++ b/loader/src/rootimage.rs @@ -236,9 +236,6 @@ impl Sectors for Disk<'_> { self.lba_count } - fn lba_count_granularity(&self) -> NonZeroU64 { - NonZeroU64::MIN - } fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { let n = self.lba_bytes as usize; diff --git a/system/nvme/src/main.rs b/system/nvme/src/main.rs index 38d86ee4e18..9927769c4bb 100644 --- a/system/nvme/src/main.rs +++ b/system/nvme/src/main.rs @@ -47,8 +47,10 @@ const TOKEN_LINK: u64 = 1; const TOKEN_STOP: u64 = 2; const TOKEN_SESSION: u64 = 0x1_0000; -/// The controller as `toyos_gpt` reads it: its own sectors, served a 4 KiB -/// block at a time, and floored to whole blocks. +/// The controller as `toyos_gpt` reads it: every sector of the namespace, +/// a 4 KiB block at a time where the block is whole and one sector at a time +/// past the last whole block, where the backup table is on a namespace whose +/// count is not whole blocks. struct Sectors<'a> { ctrl: &'a mut Controller, block: Vec, @@ -59,20 +61,19 @@ impl toyos_gpt::Sectors for Sectors<'_> { self.ctrl.lba_bytes } fn lba_count(&self) -> u64 { - let per = BLOCK_BYTES as u64 / self.ctrl.lba_bytes as u64; - self.ctrl.sectors / per * per - } - fn lba_count_granularity(&self) -> core::num::NonZeroU64 { - core::num::NonZeroU64::new(BLOCK_BYTES as u64 / self.ctrl.lba_bytes as u64).expect("at least one") + self.ctrl.sectors } fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { - let lba_bytes = self.ctrl.lba_bytes as u64; - let at = lba * lba_bytes; - if !self.ctrl.read_block(at / BLOCK_BYTES as u64, &mut self.block) { + let per = BLOCK_BYTES as u64 / self.ctrl.lba_bytes as u64; + let first = lba / per * per; + if first + per > self.ctrl.sectors { + return self.ctrl.read_sectors(lba, buf); + } + if !self.ctrl.read_sectors(first, &mut self.block) { return false; } - let off = (at % BLOCK_BYTES as u64) as usize; - buf.copy_from_slice(&self.block[off..off + lba_bytes as usize]); + let off = ((lba - first) * self.ctrl.lba_bytes as u64) as usize; + buf.copy_from_slice(&self.block[off..off + self.ctrl.lba_bytes as usize]); true } } diff --git a/system/nvme/src/nvme.rs b/system/nvme/src/nvme.rs index f3ba02cb588..954e8d50c71 100644 --- a/system/nvme/src/nvme.rs +++ b/system/nvme/src/nvme.rs @@ -580,10 +580,16 @@ impl Controller { /// # Panics /// With no room: the caller asks [`Self::has_room`] first. pub fn submit_io(&mut self, write: bool, block: u64, blocks: u32, at: u64, owner: Owner) { - let queue = self.roomiest().expect("nvme: an I/O submission with no queue room"); let per = (toyos_blockring::BLOCK_BYTES as u32 / self.lba_bytes) as u64; - let sectors = blocks as u64 * per; - let pages = blocks as usize; + self.submit_sectors(write, block * per, blocks as u64 * per, at, owner); + } + + /// [`Self::submit_io`] in the namespace's own sectors: `sectors` of them + /// from `lba`, which a table at the device's end needs when its sector + /// count is not whole blocks. + fn submit_sectors(&mut self, write: bool, lba: u64, sectors: u64, at: u64, owner: Owner) { + let queue = self.roomiest().expect("nvme: an I/O submission with no queue room"); + let pages = (sectors * self.lba_bytes as u64).div_ceil(PAGE as u64) as usize; let (dma, dma_addr) = (self.dma, self.dma_addr); let list_base = OFF_PRP + queue * IO_DEPTH as usize * PAGE; let cmd = Command { @@ -595,8 +601,8 @@ impl Controller { 2 => at + PAGE as u64, _ => u64::MAX, }, - cdw10: (block * per) as u32, - cdw11: ((block * per) >> 32) as u32, + cdw10: lba as u32, + cdw11: (lba >> 32) as u32, cdw12: (sectors - 1) as u32, }; // Every page after the first, in this slot's list page (§4.1.1). @@ -619,12 +625,14 @@ impl Controller { self.peak = self.peak.max(self.busy()); } - /// Read one 4 KiB block synchronously into `out`, through this driver's - /// own scratch page: before any session exists, for the partition table. - pub fn read_block(&mut self, block: u64, out: &mut [u8]) -> bool { - assert_eq!(out.len(), toyos_blockring::BLOCK_BYTES); + /// Read `out.len()` bytes of whole sectors from `lba`, at most a page, + /// synchronously through this driver's own scratch page: while no + /// session's command is on the device, for the partition table. + pub fn read_sectors(&mut self, lba: u64, out: &mut [u8]) -> bool { + assert!(out.len() <= PAGE && out.len().is_multiple_of(self.lba_bytes as usize) && !out.is_empty()); assert_eq!(self.busy(), 0, "nvme: a waited read beside other commands"); - self.submit_io(false, block, 1, self.dma_addr + OFF_SCRATCH as u64, Owner::Driver); + let sectors = (out.len() / self.lba_bytes as usize) as u64; + self.submit_sectors(false, lba, sectors, self.dma_addr + OFF_SCRATCH as u64, Owner::Driver); let start = Instant::now(); let mut done = Vec::new(); let ok = loop { diff --git a/system/usbd/xhci/src/msc.rs b/system/usbd/xhci/src/msc.rs index 4c51710cd2f..02aa2ec36e2 100644 --- a/system/usbd/xhci/src/msc.rs +++ b/system/usbd/xhci/src/msc.rs @@ -19,7 +19,7 @@ use alloc::format; use alloc::string::String; use alloc::vec::Vec; use core::cell::{Cell, RefCell}; -use core::num::{NonZeroU16, NonZeroU64}; +use core::num::NonZeroU16; use toyos_device_memory::{DmaBuffers, Registers}; use toyos_scsi::{self as scsi, Cdb, Geometry, Reply, Sense, HOST_BLOCK, SENSE_BYTES}; @@ -441,13 +441,7 @@ impl<'d, 'u, R: Registers, D: DmaBuffers, C: Host, T> Msc<'d, 'u, R, D, C, T> { /// the read until it asks for none it has not been given. async fn table(&mut self, geometry: &Geometry) -> Result { let per = HOST_BLOCK / geometry.sector_bytes(); - let mut cache = Cache { - lba_bytes: geometry.sector_bytes(), - lbas: geometry.blocks() * u64::from(per), - per: NonZeroU64::new(u64::from(per)).expect("at least one sector a block"), - have: BTreeMap::new(), - missed: Vec::new(), - }; + let mut cache = Cache { lba_bytes: geometry.sector_bytes(), lbas: geometry.sectors(), have: BTreeMap::new(), missed: Vec::new() }; let mut reads = 0; loop { cache.missed.clear(); @@ -463,12 +457,17 @@ impl<'d, 'u, R: Registers, D: DmaBuffers, C: Host, T> Msc<'d, 'u, R, D, C, T> { } reads += 1; let at = self.addr(self.block); - let cdb = Cdb::transfer(false, (block * u64::from(per)) as u32, per as u16); - let read = self.command(&cdb, Data::In { at, len: HOST_BLOCK }).await?; - for s in 0..u64::from(per) { + // The last block of a disk whose count is not whole blocks is + // read as far as the disk goes: the backup table is there. + let start = block * u64::from(per); + let sectors = u64::from(per).min(geometry.sectors() - start) as u32; + let len = sectors * geometry.sector_bytes(); + let cdb = Cdb::transfer(false, start as u32, sectors as u16); + let read = self.command(&cdb, Data::In { at, len }).await?; + for s in 0..u64::from(sectors) { let first = (s as usize) * geometry.sector_bytes() as usize; let sector = match read { - Reply::Ok { delivered } if delivered == HOST_BLOCK => Some( + Reply::Ok { delivered } if delivered == len => Some( (0..geometry.sector_bytes() as usize).map(|i| self.dev.read8(self.block + first + i)).collect(), ), _ => None, @@ -762,7 +761,6 @@ fn status_of(sense: Sense) -> u32 { struct Cache { lba_bytes: u32, lbas: u64, - per: NonZeroU64, /// `None` for a sector whose read failed. have: BTreeMap>>, missed: Vec, @@ -775,9 +773,6 @@ impl toyos_gpt::Sectors for Cache { fn lba_count(&self) -> u64 { self.lbas } - fn lba_count_granularity(&self) -> NonZeroU64 { - self.per - } fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { match self.have.get(&lba) { Some(Some(sector)) => { From 100c6c37d9f8ddfaf97463b22d92e2a313aac43b Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 09:52:20 +0100 Subject: [PATCH 03/14] blockserve: a driver describes its disk and designates a partition itself, under a token A disk driver is the one process that holds its device and reads its table, so it is where a designation is judged and carried out: the identity checked is the identity written through, with no path between. - Each disk's table state has a 128-bit random token, drawn at bring-up and again after every change the driver makes to the disk (`wire::Token`, `toyos_blockserve::draw_token`). An authorization is bound to one; a re-plug, a driver restart or a change made since refuses it `Stale`, so one is used at most once. - `Sessions::describe` answers a disk's size, its token, LBA 0 and each copy of the table as `toyos_gpt::edit::read_copies` read them, and every entry of the copy in force with whether a session holds it (`MSG_DISK`, `MSG_SLOT`, `MSG_DESCRIBED`). - `Sessions::designate` refuses a stale token, a partition the running system is on (`active`, the service's word) and one a session holds, then holds the partition for the job's length and runs `toyos_blockserve::designate`: read the front 1 MiB, judge it, and only for a partition it does not recognise and a request that commits, write the stamp as one whole block made in memory, flush, read it back and compare. `toyos_blockserve::content` refuses ToyOS's own volume, an existing stamp and the signatures of the formats it names: a bug fence, never a licence, since recognising nothing proves nothing. - `Link` hands over describe and designate work beside sessions (`disk::MSG_DESCRIBE`, `disk::MSG_DESIGNATE`), and the block service's badge gains `Tables` and `Designate` (`wire::Badge`). Neither opens a session, and a designation writes nothing a client chose. nvme and usbd serve both with no code of their own beyond the hand-over: the job reaches the device through the same `Device` every session does. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + lib/bcachefs/src/lib.rs | 2 +- lib/bcachefs/src/superblock.rs | 6 + lib/blockhold/src/lib.rs | 11 +- lib/blockring/src/disk.rs | 50 +++++ lib/blockring/src/wire.rs | 284 ++++++++++++++++++++++++++++ lib/blockserve/Cargo.toml | 1 + lib/blockserve/src/content.rs | 112 ++++++++++++ lib/blockserve/src/designate.rs | 315 ++++++++++++++++++++++++++++++++ lib/blockserve/src/lib.rs | 11 ++ lib/blockserve/src/link.rs | 40 +++- lib/blockserve/src/region.rs | 6 + lib/blockserve/src/sessions.rs | 236 +++++++++++++++++++++++- lib/blockserve/src/table.rs | 55 +++++- system/nvme/src/main.rs | 29 ++- system/usbd/src/main.rs | 36 ++-- 16 files changed, 1138 insertions(+), 57 deletions(-) create mode 100644 lib/blockserve/src/content.rs create mode 100644 lib/blockserve/src/designate.rs diff --git a/Cargo.lock b/Cargo.lock index bc2e1363e63..e953494076f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3540,6 +3540,7 @@ dependencies = [ name = "toyos-blockserve" version = "0.1.0" dependencies = [ + "bcachefs", "toyos", "toyos-abi", "toyos-blockhold", diff --git a/lib/bcachefs/src/lib.rs b/lib/bcachefs/src/lib.rs index 778891ba61c..ec4e1141e63 100644 --- a/lib/bcachefs/src/lib.rs +++ b/lib/bcachefs/src/lib.rs @@ -27,7 +27,7 @@ pub use block_io::{BlockIO, BlockBuf, BlockNum, DeviceError, TransferError}; #[cfg(feature = "std")] pub use block_io::VecBlockIO; pub use fs::{file_entry_fits, Formatted, Mounted, ReadOnly, ReadWrite, Recorded, FsError, Extent}; -pub use superblock::{designated, designation, FsUuid, Superblock}; +pub use superblock::{carries_volume, designated, designation, FsUuid, Superblock}; /// Records the largest single allocation each test thread makes, so a test can /// assert what parsing a crafted block asks the allocator for. diff --git a/lib/bcachefs/src/superblock.rs b/lib/bcachefs/src/superblock.rs index 47d3592701d..80add52ea03 100644 --- a/lib/bcachefs/src/superblock.rs +++ b/lib/bcachefs/src/superblock.rs @@ -31,6 +31,12 @@ pub fn designation(blocks: u64) -> [u8; BLOCK_SIZE] { block } +/// Whether `block0` begins as a volume of this format's does, whatever state +/// the rest of the volume is in. +pub fn carries_volume(block0: &[u8]) -> bool { + block0.get(..MAGIC.len()) == Some(&MAGIC) +} + /// The block count a stamp at the front of `block0` names, or `None` where /// `block0` carries none. pub fn designated(block0: &[u8]) -> Option { diff --git a/lib/blockhold/src/lib.rs b/lib/blockhold/src/lib.rs index 3fa9e13e315..5a39f81d04a 100644 --- a/lib/blockhold/src/lib.rs +++ b/lib/blockhold/src/lib.rs @@ -142,11 +142,7 @@ impl Holds { /// On an empty span: the caller refuses one before asking. pub fn hold(&mut self, first: u64, end: u64, holder: H) -> Result<(), H> { assert!(first < end, "an empty span is refused before it is held"); - if let Some(held) = self - .spans - .iter() - .find_map(|span| span.holder.filter(|_| span.overlaps(first, end))) - { + if let Some(held) = self.holder(first, end) { return Err(held); } let mut account = Account::default(); @@ -170,6 +166,11 @@ impl Holds { Ok(()) } + /// Who holds a block of `first..end`, if anyone does. + pub fn holder(&self, first: u64, end: u64) -> Option { + self.spans.iter().find_map(|span| span.holder.filter(|_| span.overlaps(first, end))) + } + /// The hold beginning at `first` ends; an account owing a report stays /// with its blocks. /// diff --git a/lib/blockring/src/disk.rs b/lib/blockring/src/disk.rs index e6b0e2695d4..02c87e2c63d 100644 --- a/lib/blockring/src/disk.rs +++ b/lib/blockring/src/disk.rs @@ -51,6 +51,13 @@ pub const MSG_GONE: u32 = 4; /// Service to driver: serve this session, a [`Session`]. Handles, in order: /// the client's connection, and the region it sent with its open. pub const MSG_SESSION: u32 = 5; +/// Service to driver: describe this disk to the client, a [`disk`]-number +/// payload. Handles: the client's connection, which the driver answers on. +pub const MSG_DESCRIBE: u32 = 6; +/// Service to driver: carry out this designation for the client, a +/// [`Designation`]. Handles: the client's connection, which the driver +/// answers on. +pub const MSG_DESIGNATE: u32 = 7; pub const MAX_LABEL: usize = 64; /// The partitions one offer may name: a GPT's whole entry array at the @@ -184,6 +191,49 @@ impl Session { } } +/// A designation the service admitted, as [`MSG_DESIGNATE`] carries it: on +/// the driver's disk `disk`, the partition `unique`, if the disk's token is +/// still `token`; `active` for a partition the running system is on, which +/// the driver refuses; `commit` to write rather than judge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Designation { + pub disk: u32, + pub unique: [u8; GUID_BYTES], + pub token: crate::wire::Token, + pub active: bool, + pub commit: bool, +} + +impl Designation { + pub const BYTES: usize = 4 + 2 * GUID_BYTES + 2; + + pub fn encode(&self) -> [u8; Self::BYTES] { + let mut out = [0u8; Self::BYTES]; + out[..4].copy_from_slice(&self.disk.to_le_bytes()); + out[4..20].copy_from_slice(&self.unique); + out[20..36].copy_from_slice(&self.token); + out[36] = u8::from(self.active); + out[37] = u8::from(self.commit); + out + } + + pub fn decode(bytes: &[u8]) -> Option { + let b: &[u8; Self::BYTES] = bytes.try_into().ok()?; + let flag = |v: u8| match v { + 0 => Some(false), + 1 => Some(true), + _ => None, + }; + Some(Self { + disk: u32::from_le_bytes(b[..4].try_into().ok()?), + unique: b[4..20].try_into().ok()?, + token: b[20..36].try_into().ok()?, + active: flag(b[36])?, + commit: flag(b[37])?, + }) + } +} + /// A [`MSG_DRIVER`] payload: the driver's name for the service's lines, which /// never says who the driver is. pub fn label(bytes: &[u8]) -> Option { diff --git a/lib/blockring/src/wire.rs b/lib/blockring/src/wire.rs index e907af7777f..e5affbdef84 100644 --- a/lib/blockring/src/wire.rs +++ b/lib/blockring/src/wire.rs @@ -18,6 +18,10 @@ //! server's ends and its answer together: nothing else makes the ends, and an //! answer is otherwise only read off the wire ([`Opened::decode`]). +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + use toyos::transport::Word; use crate::layout::{self, ServerRings, RING_WORDS}; @@ -35,6 +39,23 @@ pub const MSG_REFUSED: u32 = 3; pub const MSG_LIST: u32 = 4; /// The answer to [`MSG_LIST`]: one [`Listed`] after another. pub const MSG_LISTED: u32 = 5; +/// What one disk's table holds, for a [`Badge::Tables`] connection: the +/// payload is the service's number for the disk, four bytes. Its driver +/// answers [`MSG_DISK`], a [`MSG_SLOT`] for each used entry and +/// [`MSG_DESCRIBED`]; or [`MSG_REFUSED`]. +pub const MSG_DESCRIBE: u32 = 6; +/// A [`DiskHead`]. +pub const MSG_DISK: u32 = 7; +/// A [`SlotLine`]. +pub const MSG_SLOT: u32 = 8; +/// The description is whole. No payload. +pub const MSG_DESCRIBED: u32 = 9; +/// Designate the partition a [`Badge::Designate`] names: payload one byte, +/// 1 to write the stamp and 0 to judge only. Answered [`MSG_DESIGNATED`] or +/// [`MSG_REFUSED`]. +pub const MSG_DESIGNATE: u32 = 10; +/// A [`Designated`]. +pub const MSG_DESIGNATED: u32 = 11; /// The bytes of a GUID payload. pub const GUID_BYTES: usize = 16; @@ -235,6 +256,14 @@ pub enum Refusal { /// minter of this protocol stamps, reaches none, and its listing is /// refused the same. NotGranted, + /// The token an authorization was minted against is not the disk's: the + /// disk was brought up again or changed since. + Stale, + /// The partition backs the running system, which no authorization + /// changes beneath it. + Active, + /// The device did not do what the operation needed of it. + Device, } impl Refusal { @@ -247,6 +276,9 @@ impl Refusal { Self::Exhausted => 5, Self::ClaimRefused => 6, Self::NotGranted => 7, + Self::Stale => 8, + Self::Active => 9, + Self::Device => 10, } } @@ -259,6 +291,9 @@ impl Refusal { 5 => Some(Self::Exhausted), 6 => Some(Self::ClaimRefused), 7 => Some(Self::NotGranted), + 8 => Some(Self::Stale), + 9 => Some(Self::Active), + 10 => Some(Self::Device), _ => None, } } @@ -272,6 +307,255 @@ impl Refusal { } } +/// A disk's table state, drawn by its driver at the disk's bring-up and +/// again after every change it makes to the disk: what an authorization is +/// bound to. +pub type Token = [u8; GUID_BYTES]; + +/// What a connector to the block service reaches: the badge the supervisor +/// minted it with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Badge { + /// Sessions on the partitions a [`Grant`] admits. + Partitions(Grant), + /// Every disk's table, read: a listing of every partition and each disk's + /// description. No session and no change. + Tables, + /// One designation of the partition `unique` while its disk's token is + /// `token`, and nothing else. + Designate { unique: [u8; GUID_BYTES], token: Token }, +} + +impl Badge { + pub fn encode(&self) -> Vec { + match self { + Self::Partitions(grant) => grant.encode().to_vec(), + Self::Tables => vec![3], + Self::Designate { unique, token } => [&[4][..], unique, token].concat(), + } + } + + /// `None` for bytes no minter of this protocol stamps. + pub fn decode(bytes: &[u8]) -> Option { + match bytes.first()? { + 1 | 2 => Grant::decode(bytes).map(Self::Partitions), + 3 => (bytes.len() == 1).then_some(Self::Tables), + 4 if bytes.len() == 1 + 2 * GUID_BYTES => Some(Self::Designate { + unique: bytes[1..1 + GUID_BYTES].try_into().ok()?, + token: bytes[1 + GUID_BYTES..].try_into().ok()?, + }), + _ => None, + } + } + + /// Whether a listing through it names the partition `unique` of type `kind`. + pub fn lists(&self, unique: [u8; GUID_BYTES], kind: [u8; GUID_BYTES]) -> bool { + match self { + Self::Partitions(grant) => grant.admits(unique, kind), + Self::Tables => true, + Self::Designate { .. } => false, + } + } +} + +/// What LBA 0 of a disk holds, as [`DiskHead`] says it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lba0 { + Protective, + /// An MBR that is not a GPT's protective one. + Mbr, + /// No boot signature. + Blank, + /// It would not read. + Unread, +} + +/// The longest copy state a [`DiskHead`] carries. +pub const MAX_STATE: usize = 200; + +/// One disk as its driver describes it: its token, its size, and what each +/// copy of its table is — `valid` or why not. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiskHead { + pub token: Token, + pub lba_bytes: u32, + pub sectors: u64, + pub lba0: Lba0, + /// Both copies valid and the same table. + pub agree: bool, + pub primary: String, + pub backup: String, +} + +impl DiskHead { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(34 + self.primary.len() + self.backup.len() + 4); + out.extend_from_slice(&self.token); + out.extend_from_slice(&self.lba_bytes.to_le_bytes()); + out.extend_from_slice(&self.sectors.to_le_bytes()); + out.push(match self.lba0 { + Lba0::Protective => 0, + Lba0::Mbr => 1, + Lba0::Blank => 2, + Lba0::Unread => 3, + }); + out.push(u8::from(self.agree)); + for state in [&self.primary, &self.backup] { + let state = &state.as_bytes()[..floor_char(state, MAX_STATE)]; + out.extend_from_slice(&(state.len() as u16).to_le_bytes()); + out.extend_from_slice(state); + } + out + } + + pub fn decode(bytes: &[u8]) -> Option { + let (token, rest) = bytes.split_first_chunk::()?; + let (lba_bytes, rest) = rest.split_first_chunk::<4>()?; + let (sectors, rest) = rest.split_first_chunk::<8>()?; + let (&[lba0, agree], rest) = rest.split_first_chunk::<2>()?; + let lba0 = match lba0 { + 0 => Lba0::Protective, + 1 => Lba0::Mbr, + 2 => Lba0::Blank, + 3 => Lba0::Unread, + _ => return None, + }; + let agree = match agree { + 0 => false, + 1 => true, + _ => return None, + }; + let (primary, rest) = text(rest)?; + let (backup, rest) = text(rest)?; + rest.is_empty().then(|| Self { + token: *token, + lba_bytes: u32::from_le_bytes(*lba_bytes), + sectors: u64::from_le_bytes(*sectors), + lba0, + agree, + primary, + backup, + }) + } +} + +/// The longest prefix of `s` within `max` bytes that ends on a character. +fn floor_char(s: &str, max: usize) -> usize { + (0..=s.len().min(max)).rev().find(|&i| s.is_char_boundary(i)).unwrap_or(0) +} + +/// A length-prefixed UTF-8 string of at most [`MAX_STATE`] bytes, and what follows it. +fn text(bytes: &[u8]) -> Option<(String, &[u8])> { + let (len, rest) = bytes.split_first_chunk::<2>()?; + let len = u16::from_le_bytes(*len) as usize; + if len > MAX_STATE { + return None; + } + let (s, rest) = rest.split_at_checked(len)?; + Some((core::str::from_utf8(s).ok()?.into(), rest)) +} + +/// One used entry of the table in force, as [`MSG_SLOT`] carries it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotLine { + pub index: u32, + pub kind: [u8; GUID_BYTES], + pub unique: [u8; GUID_BYTES], + pub first: u64, + pub last: u64, + pub attributes: u64, + pub name: [u16; 36], + /// A session or the driver's own work holds it now. + pub held: bool, + /// The driver serves sessions on it. + pub served: bool, +} + +impl SlotLine { + pub const BYTES: usize = 4 + 2 * GUID_BYTES + 3 * 8 + 72 + 2; + + pub fn encode(&self) -> [u8; Self::BYTES] { + let mut out = [0u8; Self::BYTES]; + out[..4].copy_from_slice(&self.index.to_le_bytes()); + out[4..20].copy_from_slice(&self.kind); + out[20..36].copy_from_slice(&self.unique); + out[36..44].copy_from_slice(&self.first.to_le_bytes()); + out[44..52].copy_from_slice(&self.last.to_le_bytes()); + out[52..60].copy_from_slice(&self.attributes.to_le_bytes()); + for (pair, unit) in out[60..132].as_chunks_mut::<2>().0.iter_mut().zip(self.name) { + *pair = unit.to_le_bytes(); + } + out[132] = u8::from(self.held); + out[133] = u8::from(self.served); + out + } + + pub fn decode(bytes: &[u8]) -> Option { + let b: &[u8; Self::BYTES] = bytes.try_into().ok()?; + let flag = |v: u8| match v { + 0 => Some(false), + 1 => Some(true), + _ => None, + }; + let mut name = [0u16; 36]; + for (unit, pair) in name.iter_mut().zip(b[60..132].as_chunks::<2>().0) { + *unit = u16::from_le_bytes(*pair); + } + Some(Self { + index: u32::from_le_bytes(b[..4].try_into().ok()?), + kind: b[4..20].try_into().ok()?, + unique: b[20..36].try_into().ok()?, + first: u64::from_le_bytes(b[36..44].try_into().ok()?), + last: u64::from_le_bytes(b[44..52].try_into().ok()?), + attributes: u64::from_le_bytes(b[52..60].try_into().ok()?), + name, + held: flag(b[132])?, + served: flag(b[133])?, + }) + } +} + +/// What a designation came to, once it was admitted. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Designated { + /// The stamp is on the medium and read back. + Written, + /// A judge-only request: the stamp would be written. + WouldWrite, + /// A stamp naming this partition's size is there already; nothing written. + Already, + /// The partition holds a volume of ToyOS's own; nothing written. + Volume, + /// The partition carries another format's signature, named; nothing written. + Foreign(String), +} + +impl Designated { + pub fn encode(&self) -> Vec { + let (word, text): (u32, &str) = match self { + Self::Written => (1, ""), + Self::WouldWrite => (2, ""), + Self::Already => (3, ""), + Self::Volume => (4, ""), + Self::Foreign(name) => (5, &name[..floor_char(name, MAX_STATE)]), + }; + [&word.to_le_bytes()[..], text.as_bytes()].concat() + } + + pub fn decode(bytes: &[u8]) -> Option { + let (word, text) = bytes.split_first_chunk::<4>()?; + let empty = text.is_empty(); + match u32::from_le_bytes(*word) { + 1 if empty => Some(Self::Written), + 2 if empty => Some(Self::WouldWrite), + 3 if empty => Some(Self::Already), + 4 if empty => Some(Self::Volume), + 5 if text.len() <= MAX_STATE => Some(Self::Foreign(core::str::from_utf8(text).ok()?.into())), + _ => None, + } + } +} + /// A GUID payload, exactly [`GUID_BYTES`] long. pub fn guid(bytes: &[u8]) -> Option<[u8; GUID_BYTES]> { bytes.try_into().ok() diff --git a/lib/blockserve/Cargo.toml b/lib/blockserve/Cargo.toml index f05172ef34a..37a30901706 100644 --- a/lib/blockserve/Cargo.toml +++ b/lib/blockserve/Cargo.toml @@ -11,3 +11,4 @@ toyos = { path = "../../sdk/toyos" } toyos-blockring = { path = "../blockring" } toyos-blockhold = { path = "../blockhold" } toyos-gpt = { path = "../gpt" } +bcachefs = { path = "../bcachefs" } diff --git a/lib/blockserve/src/content.rs b/lib/blockserve/src/content.rs new file mode 100644 index 00000000000..b32094356aa --- /dev/null +++ b/lib/blockserve/src/content.rs @@ -0,0 +1,112 @@ +//! What the front of a partition says it holds, judged before a designation +//! writes there. +//! +//! **A bug fence, not a licence**: a partition is designated because its +//! authorization says so, and a signature recognised here refuses it, since +//! a volume somebody can name is the likeliest mistake. Finding none proves +//! nothing about what the partition holds. Each offset is the format's own, +//! from the document named beside it. + +/// How much of a partition [`judge`] reads: every signature below lies in it. +pub const READ_BYTES: usize = 1 << 20; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Content { + /// A volume of ToyOS's own format. + Volume, + /// A designation stamp naming this many blocks. + Designated(u64), + /// Another format's signature, by name. + Foreign(&'static str), + /// Nothing recognised. + Unrecognised, +} + +/// `bytes` at `at` in `front`. +fn has(front: &[u8], at: usize, bytes: &[u8]) -> bool { + front.get(at..at + bytes.len()) == Some(bytes) +} + +/// Every format recognised, by name: where its signature is, and what it is. +const SIGNATURES: &[(&str, usize, &[u8])] = &[ + // The ext4 Disk Layout: the superblock at 1024, `s_magic` 0xEF53 at its byte 0x38. + ("an ext2/3/4 file system", 1024 + 0x38, &[0x53, 0xEF]), + // XFS Algorithms & Data Structures, the superblock: `sb_magicnum` "XFSB" at 0. + ("an XFS file system", 0, b"XFSB"), + // Btrfs on-disk format: the superblock at 64 KiB, its magic at 0x40. + ("a btrfs file system", 0x1_0000 + 0x40, b"_BHRfS_M"), + // NTFS boot sector: the OEM ID at 3. + ("an NTFS file system", 3, b"NTFS "), + // exFAT File System Specification §3.1.2: FileSystemName at 3. + ("an exFAT file system", 3, b"EXFAT "), + // Microsoft FAT Specification: BS_FilSysType at 54 (FAT12/16) and 82 (FAT32). + ("a FAT file system", 54, b"FAT1"), + ("a FAT32 file system", 82, b"FAT32 "), + // LUKS1 On-Disk Format Specification and LUKS2's: the magic at 0. + ("a LUKS encrypted volume", 0, b"LUKS\xBA\xBE"), + // Linux swap: "SWAPSPACE2" ending a 4 KiB page. + ("a Linux swap area", 4096 - 10, b"SWAPSPACE2"), + // LVM2's physical volume label: "LABELONE" opening the second sector. + ("an LVM2 physical volume", 512, b"LABELONE"), + // md RAID's version 1.1 and 1.2 superblocks: magic 0xa92b4efc at 0 and 4 KiB. + ("an md RAID member", 0, &[0xFC, 0x4E, 0x2B, 0xA9]), + ("an md RAID member", 4096, &[0xFC, 0x4E, 0x2B, 0xA9]), + // ECMA-119: the first volume descriptor at 32 KiB, its identifier "CD001" at 1. + ("an ISO 9660 image", 0x8000 + 1, b"CD001"), + // Apple File System Reference: the container superblock's `nx_magic` "NXSB" at 32. + ("an APFS container", 32, b"NXSB"), + // Technical Note TN1150: the HFS Plus volume header at 1024, signature "H+" or "HX". + ("an HFS Plus volume", 1024, b"H+"), + ("an HFSX volume", 1024, b"HX"), + // F2FS: the superblock at 1024, magic 0xF2F52010. + ("an F2FS file system", 1024, &[0x10, 0x20, 0xF5, 0xF2]), + // UEFI 2.10 §5.3.2: a GPT header, "EFI PART", at LBA 1 of 512-byte blocks. + ("a GPT partition table", 512, b"EFI PART"), + // An MBR or boot sector's signature, 0x55AA at 510. + ("a boot sector", 510, &[0x55, 0xAA]), +]; + +/// What the front of a partition of `blocks` blocks holds: `front` is its +/// first [`READ_BYTES`], or all of it on a smaller one. +pub fn judge(front: &[u8], blocks: u64) -> Content { + if bcachefs::carries_volume(front) { + return Content::Volume; + } + if let Some(stamped) = bcachefs::designated(front) { + return if stamped == blocks { Content::Designated(stamped) } else { Content::Foreign("a stamp for another size") }; + } + match SIGNATURES.iter().find(|(_, at, bytes)| has(front, *at, bytes)) { + Some((name, _, _)) => Content::Foreign(name), + None => Content::Unrecognised, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each signature is recognised where its format puts it, a ToyOS volume + /// and a stamp are told apart from them, and a stamp for another size is + /// not taken for this partition's. + #[test] + fn each_format_is_recognised_where_it_puts_its_signature() { + let blank = vec![0u8; READ_BYTES]; + assert_eq!(judge(&blank, 100), Content::Unrecognised); + for (name, at, bytes) in SIGNATURES { + let mut front = blank.clone(); + front[*at..*at + bytes.len()].copy_from_slice(bytes); + assert_eq!(judge(&front, 100), Content::Foreign(name), "{name}"); + let mut moved = blank.clone(); + moved[*at + 1..*at + 1 + bytes.len()].copy_from_slice(bytes); + assert_ne!(judge(&moved, 100), Content::Foreign(name), "{name} one byte off"); + } + let mut stamped = blank.clone(); + stamped[..4096].copy_from_slice(&bcachefs::designation(100)); + assert_eq!(judge(&stamped, 100), Content::Designated(100)); + assert_eq!(judge(&stamped, 99), Content::Foreign("a stamp for another size")); + let mut ours = blank.clone(); + ours[..4].copy_from_slice(b"BCFS"); + assert_eq!(judge(&ours, 100), Content::Volume); + assert_eq!(judge(&blank[..4096], 1), Content::Unrecognised, "a partition shorter than the read"); + } +} diff --git a/lib/blockserve/src/designate.rs b/lib/blockserve/src/designate.rs new file mode 100644 index 00000000000..9e82ca6d1d4 --- /dev/null +++ b/lib/blockserve/src/designate.rs @@ -0,0 +1,315 @@ +//! A designation's steps, apart from the device and memory they run on: read +//! the front of the partition, judge it ([`crate::content`]), and only for a +//! partition it does not recognise and a request that writes, write the stamp +//! as one whole block built in memory, flush, read it back and compare. +//! +//! The caller issues each [`Ask`] on its device and reports each command's +//! answer; one command is on the device at a time. + +use toyos_blockring::wire::{Designated, Refusal}; +use toyos_blockring::{BLOCK_BYTES, MAX_REQUEST_BLOCKS}; + +use crate::content::{self, Content}; + +/// Where in the job's memory each piece is: the front read, the stamp, and +/// the stamp read back, after a first block the job does not use. +pub const FRONT: usize = BLOCK_BYTES; +pub const STAMP: usize = FRONT + content::READ_BYTES; +pub const BACK: usize = STAMP + BLOCK_BYTES; +/// The memory a job needs: a session's region holds it. +pub const BYTES: usize = BACK + BLOCK_BYTES; +const _: () = assert!(BYTES <= toyos_blockring::SESSION_BYTES); + +/// The job's memory, which the device moves blocks into and out of. +pub trait Memory { + fn copy_out(&self, at: usize, out: &mut [u8]); + fn copy_in(&mut self, at: usize, data: &[u8]); +} + +/// What the job asks of its caller next. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ask { + /// Nothing until a command is answered, or until the device has room. + Wait, + /// Read `count` blocks from the partition's block `block` into memory at `at`. + Read { block: u64, count: u32, at: usize }, + /// Write the block in memory at `at` to the partition's block `block`. + Write { block: u64, at: usize }, + /// Flush the device's volatile cache; a device with none is answered at once. + Flush, + Done(Result), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Read { next: u64, end: u64 }, + Write, + Flush, + Back, + Compare, +} + +pub struct Designation { + blocks: u64, + commit: bool, + phase: Phase, + /// A command is on the device. + issued: bool, + /// A command failed, or the device was reset beneath one. + failed: bool, +} + +impl Designation { + /// A designation of a partition of `blocks` blocks: one that writes when + /// `commit`, and judges only otherwise. + pub fn new(blocks: u64, commit: bool) -> Self { + let end = blocks.min((content::READ_BYTES / BLOCK_BYTES) as u64); + Self { blocks, commit, phase: Phase::Read { next: 0, end }, issued: false, failed: false } + } + + /// The next step, given whether the device has `room` for a command. + pub fn next(&mut self, memory: &mut dyn Memory, room: bool) -> Ask { + if self.issued { + return Ask::Wait; + } + if self.failed { + return Ask::Done(Err(Refusal::Device)); + } + let needs_device = match self.phase { + Phase::Read { next, end } => next < end, + Phase::Compare => false, + Phase::Write | Phase::Flush | Phase::Back => true, + }; + if needs_device && !room { + return Ask::Wait; + } + match self.phase { + Phase::Read { next, end } if next < end => { + let count = (end - next).min(u64::from(MAX_REQUEST_BLOCKS)); + self.phase = Phase::Read { next: next + count, end }; + self.issue(Ask::Read { block: next, count: count as u32, at: FRONT + next as usize * BLOCK_BYTES }) + } + Phase::Read { end, .. } => { + let mut front = vec![0u8; end as usize * BLOCK_BYTES]; + memory.copy_out(FRONT, &mut front); + match content::judge(&front, self.blocks) { + Content::Volume => Ask::Done(Ok(Designated::Volume)), + Content::Designated(_) => Ask::Done(Ok(Designated::Already)), + Content::Foreign(name) => Ask::Done(Ok(Designated::Foreign(name.into()))), + Content::Unrecognised if !self.commit => Ask::Done(Ok(Designated::WouldWrite)), + Content::Unrecognised => { + memory.copy_in(STAMP, &bcachefs::designation(self.blocks)); + self.phase = Phase::Write; + self.next(memory, room) + } + } + } + Phase::Write => { + self.phase = Phase::Flush; + self.issue(Ask::Write { block: 0, at: STAMP }) + } + Phase::Flush => { + self.phase = Phase::Back; + self.issue(Ask::Flush) + } + Phase::Back => { + self.phase = Phase::Compare; + self.issue(Ask::Read { block: 0, count: 1, at: BACK }) + } + Phase::Compare => { + let (mut back, mut stamp) = ([0u8; BLOCK_BYTES], [0u8; BLOCK_BYTES]); + memory.copy_out(BACK, &mut back); + memory.copy_out(STAMP, &mut stamp); + Ask::Done(if back == stamp { Ok(Designated::Written) } else { Err(Refusal::Device) }) + } + } + } + + fn issue(&mut self, ask: Ask) -> Ask { + self.issued = true; + ask + } + + /// The command on the device was answered. + pub fn answered(&mut self, ok: bool) { + assert!(self.issued, "an answer for a designation with nothing on the device"); + self.issued = false; + self.failed |= !ok; + } + + /// The device was reset beneath the command on it, which is never answered. + pub fn reset(&mut self) { + if self.issued { + self.issued = false; + self.failed = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl Memory for Vec { + fn copy_out(&self, at: usize, out: &mut [u8]) { + out.copy_from_slice(&self[at..at + out.len()]); + } + fn copy_in(&mut self, at: usize, data: &[u8]) { + self[at..at + data.len()].copy_from_slice(data); + } + } + + /// A partition of whole blocks, a device with a volatile cache that holds + /// writes until a flush, and every command the job issued. + struct Disk { + blocks: Vec<[u8; BLOCK_BYTES]>, + cached: Vec<(u64, [u8; BLOCK_BYTES])>, + cache: bool, + /// The command number that fails, counted from 0. + fails: Option, + /// A device that answers a write it did not keep. + drops_writes: bool, + commands: Vec, + } + + impl Disk { + fn new(blocks: usize, fill: impl Fn(usize) -> u8) -> Self { + let blocks = (0..blocks).map(|b| [fill(b); BLOCK_BYTES]).collect(); + Self { blocks, cached: Vec::new(), cache: true, fails: None, drops_writes: false, commands: Vec::new() } + } + + /// Run `job` to its end, answering each command as the device would. + fn run(&mut self, job: &mut Designation, memory: &mut Vec) -> Result { + loop { + let ask = job.next(memory, true); + let n = self.commands.len(); + self.commands.push(ask.clone()); + let ok = self.fails != Some(n); + match ask { + Ask::Done(done) => return done, + Ask::Wait => panic!("a job with nothing on the device waits"), + Ask::Read { block, count, at } => { + for i in 0..count as usize { + let b = block as usize + i; + let data = self.cached.iter().rev().find(|(c, _)| *c as usize == b).map_or(self.blocks[b], |(_, d)| *d); + memory.copy_in(at + i * BLOCK_BYTES, &data); + } + } + Ask::Write { block, at } => { + let mut data = [0u8; BLOCK_BYTES]; + memory.copy_out(at, &mut data); + if !self.drops_writes { + if self.cache { + self.cached.push((block, data)); + } else { + self.blocks[block as usize] = data; + } + } + } + Ask::Flush => { + for (b, d) in self.cached.drain(..) { + self.blocks[b as usize] = d; + } + } + } + job.answered(ok); + } + } + } + + fn memory() -> Vec { + vec![0; BYTES] + } + + /// An unrecognised partition is stamped on the medium, flushed before it + /// is read back, and the rest of it is untouched; the front it judged is + /// the first 1 MiB, in requests no larger than the ring allows. + #[test] + fn an_unrecognised_partition_is_stamped_flushed_and_read_back() { + let mut disk = Disk::new(1000, |b| (b % 251) as u8 | 1); + let mut job = Designation::new(1000, true); + assert_eq!(disk.run(&mut job, &mut memory()), Ok(Designated::Written)); + assert_eq!(disk.blocks[0], bcachefs::designation(1000), "the stamp is on the medium, not in the cache"); + assert!(disk.blocks[1..].iter().enumerate().all(|(i, b)| b[0] == ((i + 1) % 251) as u8 | 1)); + let reads: u64 = disk + .commands + .iter() + .filter_map(|c| match c { + Ask::Read { count, at, .. } if *at >= FRONT && *at < STAMP => Some(u64::from(*count)), + _ => None, + }) + .sum(); + assert_eq!(reads, 256); + assert!(disk.commands.iter().all(|c| !matches!(c, Ask::Read { count, .. } if *count > MAX_REQUEST_BLOCKS))); + let order: Vec<&str> = disk + .commands + .iter() + .filter_map(|c| match c { + Ask::Write { .. } => Some("write"), + Ask::Flush => Some("flush"), + Ask::Read { at, .. } if *at == BACK => Some("back"), + _ => None, + }) + .collect(); + assert_eq!(order, ["write", "flush", "back"]); + } + + /// What the judge recognises is answered and nothing is written; a judge-only + /// request writes nothing either. + #[test] + fn a_recognised_or_judged_partition_is_never_written() { + let mut ours = Disk::new(300, |_| 0); + ours.blocks[0][..4].copy_from_slice(b"BCFS"); + let mut stamped = Disk::new(300, |_| 0); + stamped.blocks[0] = bcachefs::designation(300); + let mut ext4 = Disk::new(300, |_| 0); + ext4.blocks[0][1080..1082].copy_from_slice(&[0x53, 0xEF]); + let cases = [ + (ours, true, Designated::Volume), + (stamped, true, Designated::Already), + (ext4, true, Designated::Foreign("an ext2/3/4 file system".into())), + (Disk::new(300, |_| 7), false, Designated::WouldWrite), + ]; + for (mut disk, commit, want) in cases { + let before = disk.blocks.clone(); + let mut job = Designation::new(300, commit); + assert_eq!(disk.run(&mut job, &mut memory()), Ok(want.clone())); + assert!(disk.blocks == before && disk.commands.iter().all(|c| !matches!(c, Ask::Write { .. } | Ask::Flush)), "{want:?}"); + } + } + + /// A failed command, a device that drops the write it answered, and a + /// reset beneath a command each end the job refused `Device`, never + /// `Written`. + #[test] + fn a_failed_or_unkept_write_is_never_answered_written() { + let mut job = Designation::new(300, true); + let mut probe = Disk::new(300, |_| 3); + probe.run(&mut job, &mut memory()).unwrap(); + let write = probe.commands.iter().position(|c| matches!(c, Ask::Write { .. })).unwrap(); + for fails in [0, write, write + 1, write + 2] { + let mut disk = Disk { fails: Some(fails), ..Disk::new(300, |_| 3) }; + assert_eq!(disk.run(&mut Designation::new(300, true), &mut memory()), Err(Refusal::Device), "command {fails} failed"); + } + let mut dropping = Disk { drops_writes: true, ..Disk::new(300, |_| 3) }; + assert_eq!(dropping.run(&mut Designation::new(300, true), &mut memory()), Err(Refusal::Device)); + + let mut job = Designation::new(300, true); + let mut mem = memory(); + assert!(matches!(job.next(&mut mem, true), Ask::Read { .. })); + job.reset(); + assert_eq!(job.next(&mut mem, true), Ask::Done(Err(Refusal::Device))); + } + + /// No room on the device is a wait, never a skipped step; a device with no + /// cache answers the flush at once and the stamp still reads back. + #[test] + fn a_full_device_waits_and_a_cacheless_one_still_reads_back() { + let mut job = Designation::new(10, true); + let mut mem = memory(); + assert_eq!(job.next(&mut mem, false), Ask::Wait); + let mut disk = Disk { cache: false, ..Disk::new(10, |_| 9) }; + assert_eq!(disk.run(&mut job, &mut mem), Ok(Designated::Written)); + assert_eq!(disk.blocks[0], bcachefs::designation(10)); + } +} diff --git a/lib/blockserve/src/lib.rs b/lib/blockserve/src/lib.rs index 401ee903359..d48b45de188 100644 --- a/lib/blockserve/src/lib.rs +++ b/lib/blockserve/src/lib.rs @@ -7,6 +7,8 @@ //! Every disk driver links this, whatever its controller: what differs is the //! [`sessions::Device`] beneath. +pub mod content; +pub mod designate; pub mod link; pub mod region; pub mod sessions; @@ -15,6 +17,15 @@ pub mod table; pub use sessions::{Device, Owner, Sessions}; pub use table::Partition; +/// A fresh table token ([`toyos_blockring::wire::Token`]) from the kernel's +/// random source. A refused draw ends the driver: a token anyone can predict +/// binds nothing. +pub fn draw_token() -> toyos_blockring::wire::Token { + let mut token = [0u8; 16]; + toyos_abi::syscall::random(&mut token).unwrap_or_else(|why| panic!("blockserve: no random token for a disk: {why:?}")); + token +} + /// A partition GUID as the lines say it. pub fn guid_text(bytes: [u8; 16]) -> String { toyos_gpt::Guid(bytes).to_string() diff --git a/lib/blockserve/src/link.rs b/lib/blockserve/src/link.rs index 4f091143fa7..a6a5731cd1f 100644 --- a/lib/blockserve/src/link.rs +++ b/lib/blockserve/src/link.rs @@ -17,12 +17,14 @@ use toyos::ipc::{Connection, FrameRx, RxStep}; use toyos::poller::{Poller, READABLE}; use toyos_blockring::disk::{self, Offer, Part, Settled}; -/// A session the service handed over: what to open, the client's connection -/// and the region it sent. -pub struct Handed { - pub session: disk::Session, - pub conn: Connection, - pub region: toyos::RawHandle, +/// What the service handed over, each with the client's connection the +/// driver answers on. +pub enum Handed { + /// A session to open, over the region the client sent. + Session { session: disk::Session, conn: Connection, region: toyos::RawHandle }, + /// A description of one of this driver's disks. + Describe { disk: u32, conn: Connection }, + Designate { order: disk::Designation, conn: Connection }, } /// Fresh connections in a row that may refuse what the driver says before it @@ -33,7 +35,7 @@ pub struct Link { label: String, /// `None` once the service's port has closed for good. conn: Option, - rx: FrameRx<{ disk::Session::BYTES }>, + rx: FrameRx<{ disk::Designation::BYTES }>, offers: BTreeMap>, settled: Option, } @@ -134,7 +136,7 @@ impl Link { } } - /// Every session the service handed over since the last call. Anything + /// Everything the service handed over since the last call. Anything /// else it sends is the service breaking the protocol, which ends the /// connection. pub fn take(&mut self) -> Vec { @@ -151,7 +153,7 @@ impl Link { match (session, handles) { (Some(session), Some([conn, region])) => { let conn = Connection::received(conn); - handed.push(Handed { session, conn, region }); + handed.push(Handed::Session { session, conn, region }); continue; } (_, Some(handles)) => { @@ -161,6 +163,26 @@ impl Link { (_, None) => "a session without its two handles", } } + RxStep::Frame { msg_type: msg_type @ (disk::MSG_DESCRIBE | disk::MSG_DESIGNATE), payload_len, .. } => { + let payload = self.rx.payload(payload_len).to_vec(); + match self.rx.take_handles::<1>() { + Some([conn]) => { + let conn = Connection::received(conn); + let asked = match msg_type { + disk::MSG_DESCRIBE => disk::disk(&payload).map(|disk| Handed::Describe { disk, conn }), + _ => disk::Designation::decode(&payload).map(|order| Handed::Designate { order, conn }), + }; + match asked { + Some(asked) => { + handed.push(asked); + continue; + } + None => "a description or designation frame this protocol does not make", + } + } + None => "a description or designation without the client's connection", + } + } RxStep::Frame { .. } => "a frame this protocol does not make", }; self.ended(why); diff --git a/lib/blockserve/src/region.rs b/lib/blockserve/src/region.rs index 1200b36e160..8ffd6cc93f0 100644 --- a/lib/blockserve/src/region.rs +++ b/lib/blockserve/src/region.rs @@ -51,6 +51,12 @@ impl Region { whole.sub(run.span().offset, run.span().len) } + /// The whole region, for the driver's own work on a region it made. + pub fn window(&self) -> Window { + // SAFETY: as `arena`'s. + unsafe { Window::new(self.memory.as_ptr(), SESSION_BYTES) } + } + /// A second handle to the region, for a send. pub fn share(&self) -> Result { self.memory.share() diff --git a/lib/blockserve/src/sessions.rs b/lib/blockserve/src/sessions.rs index 77f20259f1b..db786e22a8b 100644 --- a/lib/blockserve/src/sessions.rs +++ b/lib/blockserve/src/sessions.rs @@ -12,6 +12,12 @@ //! **A server never blocks on a client.** The open is answered with one //! `try_send` on the client's own connection, and a doorbell is one //! non-blocking write whose refusal is a full pipe — a doorbell already rung. +//! +//! **A designation is the driver's own work on one partition** ([`Job`]): it +//! holds the partition as a session would for as long as it runs, so nothing +//! opens it meanwhile, and it reaches the device through the same [`Device`] +//! as every session. What it writes is the stamp alone, which it makes +//! itself; no client's bytes reach the disk through it. use std::collections::BTreeMap; @@ -23,10 +29,13 @@ use toyos_blockring::disk::Part; use toyos_blockring::entry::{Completion, Op}; use toyos_blockring::layout::{ServerRings, DEPTH}; use toyos_blockring::server::{ServerSession, Taken}; -use toyos_blockring::wire::{self, Opened, Refusal}; +use toyos_blockring::disk::Designation; +use toyos_blockring::wire::{self, Designated, DiskHead, Lba0, Opened, Refusal, SlotLine, Token}; use toyos_blockring::Status; +use crate::designate::{self, Ask}; use crate::region::Region; +use crate::table::{self, Read}; use crate::Partition; /// Sessions one disk holds at once. Each costs its region's mapping in the @@ -72,21 +81,63 @@ struct Served { posted: bool, } +/// The session id the driver's own work answers under: no session is ever +/// numbered so. +const JOB: u64 = u64::MAX; + +/// A designation in progress, on a partition it holds. +struct Job { + conn: Connection, + order: Designation, + steps: designate::Designation, + region: Region, + device_addr: u64, + first: u64, +} + +impl designate::Memory for Region { + fn copy_out(&self, at: usize, out: &mut [u8]) { + self.window().copy_out(at, out); + } + fn copy_in(&mut self, at: usize, data: &[u8]) { + self.window().copy_in(at, data); + } +} + /// Every session on one disk, and the partitions its table names. pub struct Sessions { /// Who says this disk's lines. name: String, parts: Vec, + read: Read, + /// The disk's table state ([`wire::Token`]), and where a fresh one comes from. + token: Token, + draw: fn() -> Token, holds: Holds, /// The device's loss count: every reset may have dropped its cache. losses: u64, sessions: BTreeMap, next_id: u64, + job: Option, } impl Sessions { - pub fn new(name: String, parts: Vec) -> Self { - Self { name, parts, holds: Holds::new(), losses: 0, sessions: BTreeMap::new(), next_id: 0 } + /// The disk `read` describes, under a token `draw` draws now and after + /// every change this driver makes to the disk. + pub fn new(name: String, read: Read, draw: fn() -> Token) -> Self { + let parts = table::say(&name, &read); + Self { + name, + parts, + read, + token: draw(), + draw, + holds: Holds::new(), + losses: 0, + sessions: BTreeMap::new(), + next_id: 0, + job: None, + } } /// What the block service is offered of this disk. @@ -191,6 +242,13 @@ impl Sessions { /// Hand one device answer to the session it is for. pub fn answered(&mut self, owner: Owner, ok: bool, status: u32) { + if owner.session == JOB { + let job = self.job.as_mut().unwrap_or_else(|| panic!("{}: an answer for a designation that ended", self.name)); + if !ok { + println!("{}: the designation's command failed with status {status:#x}", self.name); + } + return job.steps.answered(ok); + } let s = self.sessions.get_mut(&owner.session).unwrap_or_else(|| { panic!("{}: an answer for session {}, which ended with requests on the device", self.name, owner.session) }); @@ -259,6 +317,7 @@ impl Sessions { } s.rings.0.release(s.region.words()); } + self.work(dev); } /// Publish what each session was answered, and ring its doorbell. @@ -319,6 +378,9 @@ impl Sessions { /// dropped the device's cache. Answers how many were the sessions'. pub fn reset(&mut self) -> usize { self.losses += 1; + if let Some(job) = &mut self.job { + job.steps.reset(); + } let mut answered = 0; for s in self.sessions.values_mut() { for c in s.state.abort_all() { @@ -333,6 +395,7 @@ impl Sessions { /// client told, and every session ended. pub fn end(&mut self, dev: &mut impl Device) { self.reset(); + self.work(dev); for s in self.sessions.values_mut() { s.closing = true; } @@ -343,6 +406,136 @@ impl Sessions { pub fn losses(&self) -> u64 { self.losses } + + /// Describe the disk to `conn`: its head, then each used entry of the + /// table in force, then the end. One `try_send` each; a client that will + /// not take them is let go. + pub fn describe(&self, conn: Connection) { + let copies = self.read.copies.as_deref(); + let head = DiskHead { + token: self.token, + lba_bytes: self.read.lba_bytes, + sectors: self.read.sectors, + lba0: match copies.map(|c| c.mbr) { + None => Lba0::Unread, + Some(toyos_gpt::edit::Mbr::Protective) => Lba0::Protective, + Some(toyos_gpt::edit::Mbr::Other) => Lba0::Mbr, + Some(toyos_gpt::edit::Mbr::Blank) => Lba0::Blank, + }, + agree: copies.is_some_and(|c| c.agree()), + primary: copies.map_or_else(|| "unread".into(), |c| table::state(&c.primary)), + backup: copies.map_or_else(|| "unread".into(), |c| table::state(&c.backup)), + }; + if conn.try_send_bytes(wire::MSG_DISK, &head.encode()).is_err() { + return; + } + if let Some((_, in_force)) = copies.and_then(|c| c.in_force()) { + for (index, slot) in in_force.used() { + let span = self.parts.iter().find(|p| p.unique == slot.unique_guid.0).and_then(|p| p.span.as_ref().ok()); + let line = SlotLine { + index, + kind: slot.type_guid.0, + unique: slot.unique_guid.0, + first: slot.first, + last: slot.last, + attributes: slot.attributes, + name: slot.name, + held: span.is_some_and(|&(first, blocks)| self.holds.holder(first, first + blocks).is_some()), + served: span.is_some(), + }; + if conn.try_send_bytes(wire::MSG_SLOT, &line.encode()).is_err() { + return; + } + } + } + let _ = conn.try_send_bytes(wire::MSG_DESCRIBED, &[]); + } + + /// The span `order` designates, held for it; or its refusal, judged + /// against this disk's own state in this turn: its token, the running + /// system's partitions, its table and its holds. + fn admit(&mut self, order: &Designation, dev: &mut impl Device) -> Result<(u64, u64), Refusal> { + if self.job.is_some() { + return Err(Refusal::Exhausted); + } + if order.token != self.token { + return Err(Refusal::Stale); + } + if order.active { + return Err(Refusal::Active); + } + self.place(order.unique, dev) + } + + /// Begin the designation the service admitted, answering `conn` when it + /// ends; or refuse it now ([`Self::admit`]). + pub fn designate(&mut self, dev: &mut impl Device, conn: Connection, order: Designation) { + let what = crate::guid_text(order.unique); + let name = self.name.clone(); + let refuse = |conn: &Connection, why: Refusal| { + println!("{name}: a designation of {what} refused: {why:?}"); + let _ = conn.try_send_bytes(wire::MSG_REFUSED, &why.encode()); + }; + let (first, blocks) = match self.admit(&order, dev) { + Ok(span) => span, + Err(why) => return refuse(&conn, why), + }; + let region = Region::create().and_then(|region| dev.dma_map(region.handle()).map(|at| (region, at))); + let (region, device_addr) = match region { + Ok(mapped) => mapped, + Err(why) => { + self.holds.release(first); + println!("{}: no region for a designation: {why:?}", self.name); + return refuse(&conn, Refusal::Exhausted); + } + }; + println!("{}: designating {what} ({blocks} blocks){}", self.name, if order.commit { "" } else { ", judging only" }); + let steps = designate::Designation::new(blocks, order.commit); + self.job = Some(Job { conn, order, steps, region, device_addr, first }); + self.work(dev); + } + + /// Move the designation on as far as the device lets it. + fn work(&mut self, dev: &mut impl Device) { + while let Some(job) = &mut self.job { + let owner = |write| Owner { session: JOB, tag: 0, write }; + match job.steps.next(&mut job.region, dev.has_room()) { + Ask::Wait => return, + Ask::Read { block, count, at } => dev.submit_io(false, job.first + block, count, job.device_addr + at as u64, owner(false)), + Ask::Write { block, at } => dev.submit_io(true, job.first + block, 1, job.device_addr + at as u64, owner(true)), + Ask::Flush => { + if !dev.submit_flush(owner(false)) { + job.steps.answered(true); + } + } + Ask::Done(outcome) => return self.finish(dev, outcome), + } + } + } + + /// End the designation: its region out of the controller's domain, its + /// partition free, and its client answered. + fn finish(&mut self, dev: &mut impl Device, outcome: Result) { + let job = self.job.take().expect("a designation"); + if let Err(why) = dev.dma_unmap(job.device_addr) { + panic!("{}: the kernel would not take a designation's region back: {why:?}", self.name); + } + self.holds.release(job.first); + let what = crate::guid_text(job.order.unique); + if outcome == Ok(Designated::Written) { + self.token = (self.draw)(); + } + match outcome { + Ok(done) => { + println!("{}: designation of {what}: {done:?}", self.name); + let _ = job.conn.try_send_bytes(wire::MSG_DESIGNATED, &done.encode()); + } + Err(why) => { + println!("{}: designation of {what} failed: {why:?}", self.name); + let _ = job.conn.try_send_bytes(wire::MSG_REFUSED, &why.encode()); + } + } + } } #[cfg(test)] @@ -372,13 +565,36 @@ mod tests { } fn disk() -> Sessions { - Sessions::new( - "test".into(), - vec![ - Partition { unique: [1; 16], kind: [9; 16], span: Ok((0, 100)) }, - Partition { unique: [2; 16], kind: [9; 16], span: Err("not whole blocks".into()) }, - ], - ) + let parts = vec![ + Partition { unique: [1; 16], kind: [9; 16], span: Ok((0, 100)) }, + Partition { unique: [2; 16], kind: [9; 16], span: Err("not whole blocks".into()) }, + ]; + Sessions::new("test".into(), Read { lba_bytes: 512, sectors: 1 << 20, copies: None, parts: Ok(parts) }, || [5; 16]) + } + + fn order(unique: [u8; 16], token: Token, active: bool) -> Designation { + Designation { disk: 0, unique, token, active, commit: true } + } + + /// A designation is judged against the disk's own state before it takes + /// anything: a token from another state of the disk, a partition the + /// running system is on, one a session holds, one the table does not + /// name and one it will not serve are each refused, and none of them + /// holds the partition. + #[test] + fn a_designation_is_refused_on_the_disks_own_state_before_it_holds_anything() { + let mut disk = disk(); + let refusals = [ + (order([1; 16], [6; 16], false), Refusal::Stale), + (order([1; 16], [5; 16], true), Refusal::Active), + (order([3; 16], [5; 16], false), Refusal::NotFound), + (order([2; 16], [5; 16], false), Refusal::Unusable), + ]; + for (order, want) in refusals { + assert_eq!(disk.admit(&order, &mut Nothing), Err(want), "{order:?}"); + } + assert_eq!(disk.place([1; 16], &mut Nothing), Ok((0, 100)), "nothing refused held it"); + assert_eq!(disk.admit(&order([1; 16], [5; 16], false), &mut Nothing), Err(Refusal::Held)); } /// What only the disk knows refuses an open the service admitted: a diff --git a/lib/blockserve/src/table.rs b/lib/blockserve/src/table.rs index 661e471d6b4..f67775cff46 100644 --- a/lib/blockserve/src/table.rs +++ b/lib/blockserve/src/table.rs @@ -1,11 +1,14 @@ -//! Every partition a disk's table names, read once at the disk's bring-up. +//! Every partition a disk's table names, read once at the disk's bring-up, +//! with what the disk holds of the table: its size and each copy. //! //! Every partition lies outside every other and the table outside all of them, //! so no session can change it: the table a driver read is the one it serves -//! for the disk's life. A partition whose range is not whole 4 KiB blocks, or -//! that its own table refuses, is offered and refused by name. +//! until the driver itself changes the disk. A partition whose range is not +//! whole 4 KiB blocks, or that its own table refuses, is offered and refused +//! by name. use toyos_blockring::BLOCK_BYTES; +use toyos_gpt::edit::Copies; /// One partition of the table, in blocks of the disk. #[derive(Clone, Debug, PartialEq, Eq)] @@ -16,10 +19,28 @@ pub struct Partition { pub span: Result<(u64, u64), String>, } -/// Every partition the table on `sectors` names, each checked past -/// `toyos_gpt::locate`'s range and overlap checks and held to whole blocks; -/// or why the disk carries no table this reads. Pure: [`say`] says it. -pub fn read(sectors: &mut dyn toyos_gpt::Sectors) -> Result, String> { +/// What a driver read of one disk at its bring-up. +pub struct Read { + pub lba_bytes: u32, + pub sectors: u64, + /// LBA 0 and each copy of the table, each read alone; `None` for a disk + /// with no room for a table or whose LBA 0 would not read. + pub copies: Option>, + /// Every partition the table names, or why the disk carries no table + /// this reads. + pub parts: Result, String>, +} + +/// What the table on `sectors` says: every partition it names, each checked +/// past `toyos_gpt::locate`'s range and overlap checks and held to whole +/// blocks, and both copies as they stand. Pure: [`say`] says it. +pub fn read(sectors: &mut dyn toyos_gpt::Sectors) -> Read { + let (lba_bytes, count) = (sectors.lba_bytes(), sectors.lba_count()); + let copies = toyos_gpt::edit::read_copies(sectors).map(Box::new); + Read { lba_bytes, sectors: count, copies, parts: partitions(sectors) } +} + +fn partitions(sectors: &mut dyn toyos_gpt::Sectors) -> Result, String> { let per = BLOCK_BYTES as u64 / u64::from(sectors.lba_bytes()); let mut found = [None; 128]; if let Err(why) = toyos_gpt::list(sectors, &mut found) { @@ -54,9 +75,9 @@ pub fn read(sectors: &mut dyn toyos_gpt::Sectors) -> Result, Stri /// Say what [`read`] found, as `name`, and answer the partitions: none for a /// disk with no table. -pub fn say(name: &str, read: Result, String>) -> Vec { - let parts = match read { - Ok(parts) => parts, +pub fn say(name: &str, read: &Read) -> Vec { + let parts = match &read.parts { + Ok(parts) => parts.clone(), Err(why) => { println!("{name}: the disk carries no partition table this driver reads: {why}"); return Vec::new(); @@ -69,5 +90,19 @@ pub fn say(name: &str, read: Result, String>) -> Vec { Err(why) => println!("{name}: partition {guid} of type {kind} is not served: {why}"), } } + if let Some(copies) = &read.copies + && copies.in_force().is_some() + && !copies.agree() + { + println!("{name}: the table's two copies do not agree: primary {:?}, backup {:?}", state(&copies.primary), state(&copies.backup)); + } parts } + +/// One copy as a line says it. +pub fn state(copy: &Result) -> String { + match copy { + Ok(_) => "valid".into(), + Err(why) => format!("{why:?}"), + } +} diff --git a/system/nvme/src/main.rs b/system/nvme/src/main.rs index 9927769c4bb..94e78365fab 100644 --- a/system/nvme/src/main.rs +++ b/system/nvme/src/main.rs @@ -22,7 +22,7 @@ use toyos::PciDev; use toyos_abi::syscall::{SyscallError, DEV_PREFIX}; use toyos_blockring::disk::Settled; use toyos_blockring::BLOCK_BYTES; -use toyos_blockserve::link::Link; +use toyos_blockserve::link::{Handed, Link}; use toyos_blockserve::sessions::MAX_SESSIONS; use toyos_blockserve::{Device, Sessions}; @@ -134,8 +134,7 @@ fn main() { ctrl.sectors ); let read = toyos_blockserve::table::read(&mut Sectors { ctrl: &mut ctrl, block: vec![0u8; BLOCK_BYTES] }); - let parts = toyos_blockserve::table::say(&name, read); - let mut disk = Sessions::new(name.clone(), parts); + let mut disk = Sessions::new(name.clone(), read, toyos_blockserve::draw_token); link.offer(DISK, disk.offered()); link.settle(Settled::Offered); serve(&name, &mut ctrl, &mut disk, &mut link, stop.as_ref()) @@ -156,8 +155,9 @@ fn idle(link: &mut Link, stop: Option<&toyos::Pipe>) -> ! { std::process::exit(0); } for handed in link.take() { - toyos_abi::syscall::close(handed.region); - drop(handed.conn); + if let Handed::Session { region, .. } = handed { + toyos_abi::syscall::close(region); + } } } } @@ -200,12 +200,23 @@ fn serve(name: &str, ctrl: &mut Controller, disk: &mut Sessions, link: &mut Link } for handed in link.take() { - if handed.session.disk != DISK { - println!("{name}: the block service handed a session on disk {}, which this driver never offered", handed.session.disk); - toyos_abi::syscall::close(handed.region); + let on = match &handed { + Handed::Session { session, .. } => session.disk, + Handed::Describe { disk, .. } => *disk, + Handed::Designate { order, .. } => order.disk, + }; + if on != DISK { + println!("{name}: the block service handed work on disk {on}, which this driver never offered"); + if let Handed::Session { region, .. } = handed { + toyos_abi::syscall::close(region); + } continue; } - disk.open(ctrl, handed.conn, handed.region, handed.session.unique, handed.session.writes); + match handed { + Handed::Session { session, conn, region } => disk.open(ctrl, conn, region, session.unique, session.writes), + Handed::Describe { conn, .. } => disk.describe(conn), + Handed::Designate { order, conn } => disk.designate(ctrl, conn, order), + } } for id in disk.ids() { if ready.contains(&(TOKEN_SESSION + id)) { diff --git a/system/usbd/src/main.rs b/system/usbd/src/main.rs index d4e3f7ff104..cac392855b6 100644 --- a/system/usbd/src/main.rs +++ b/system/usbd/src/main.rs @@ -29,9 +29,9 @@ use toyos::poller::{Poller, READABLE, WRITABLE}; use toyos::{AsHandle, PciDev}; use toyos_abi::syscall::{SyscallError, DEV_PREFIX}; use toyos_blockring::disk::Settled; -use toyos_blockserve::link::Link; +use toyos_blockserve::link::{Handed, Link}; use toyos_blockserve::sessions::{Owner as Request, MAX_SESSIONS}; -use toyos_blockserve::{table, Device, Partition, Sessions}; +use toyos_blockserve::{table, Device, Sessions}; use toyos_input::wire::{self, Record, MAX_PAYLOAD}; use toyos_inspect::{Snapshot, MSG_INSPECT, MSG_SNAPSHOT}; use toyos_pci_claim::{Bar, Grant}; @@ -70,7 +70,7 @@ impl Host for Process { } } -type Driver = Usb, String>>; +type Driver = Usb; /// The owner of input, over the `input` port. #[derive(Default)] @@ -223,7 +223,9 @@ fn idle(mut link: Option, stop: Option) -> ! { std::process::exit(0); } for handed in link.iter_mut().flat_map(Link::take) { - toyos_abi::syscall::close(handed.region); + if let Handed::Session { region, .. } = handed { + toyos_abi::syscall::close(region); + } } } } @@ -268,7 +270,7 @@ fn serve( match event { Event::Arrived { disk, name: disk_name, table } => { let label = format!("{name} {disk_name}"); - let sessions = Sessions::new(label.clone(), table::say(&label, table)); + let sessions = Sessions::new(label.clone(), table, toyos_blockserve::draw_token); if let Some(link) = &mut link { link.offer(disk, sessions.offered()); } @@ -299,15 +301,23 @@ fn serve( } } for handed in link.iter_mut().flat_map(Link::take) { - match disks.get_mut(&handed.session.disk) { - Some(sessions) => { - let mut port = Disk { usb, disk: handed.session.disk, claim: dev }; - sessions.open(&mut port, handed.conn, handed.region, handed.session.unique, handed.session.writes); - } - None => { - println!("{name}: the block service handed a session on disk {}, which is not served", handed.session.disk); - toyos_abi::syscall::close(handed.region); + let on = match &handed { + Handed::Session { session, .. } => session.disk, + Handed::Describe { disk, .. } => *disk, + Handed::Designate { order, .. } => order.disk, + }; + let Some(sessions) = disks.get_mut(&on) else { + println!("{name}: the block service handed work on disk {on}, which is not served"); + if let Handed::Session { region, .. } = handed { + toyos_abi::syscall::close(region); } + continue; + }; + let mut port = Disk { usb, disk: on, claim: dev }; + match handed { + Handed::Session { session, conn, region } => sessions.open(&mut port, conn, region, session.unique, session.writes), + Handed::Describe { conn, .. } => sessions.describe(conn), + Handed::Designate { order, conn } => sessions.designate(&mut port, conn, order), } } let submitted = usb.disks.submitted(); From 145ace688ee22ec3b5a2d297a4fdbfd632e8e7bc Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 09:55:35 +0100 Subject: [PATCH 04/14] diskserver routes a disk's description and a designation to the driver that holds the disk A connection's badge decides what it reaches (`wire::Badge`): a partitions grant lists and opens what it admits, as before; a tables badge lists every partition and has its driver describe a disk; a designation badge does the one designation it was minted for, on the one disk carrying its partition, and nothing else. Neither of the new two opens a session. The supervisor names the partitions the running system is on (`--active`), and the driver refuses to designate one of them. The client's side is `diskserver::tables`: `describe` and `designate`. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + system/diskserver/Cargo.toml | 2 + system/diskserver/src/lib.rs | 4 +- system/diskserver/src/main.rs | 218 ++++++++++++++++++++++++------- system/diskserver/src/session.rs | 4 +- system/diskserver/src/tables.rs | 57 ++++++++ 6 files changed, 237 insertions(+), 49 deletions(-) create mode 100644 system/diskserver/src/tables.rs diff --git a/Cargo.lock b/Cargo.lock index e953494076f..a43fc71e235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ dependencies = [ "toyos-blockdev", "toyos-blockring", "toyos-blockserve", + "toyos-gpt", ] [[package]] diff --git a/system/diskserver/Cargo.toml b/system/diskserver/Cargo.toml index 1bcd64f4b7e..8e7d38a1aa5 100644 --- a/system/diskserver/Cargo.toml +++ b/system/diskserver/Cargo.toml @@ -23,6 +23,8 @@ toyos-blockring = { path = "../../lib/blockring" } toyos-blockserve = { path = "../../lib/blockserve" } # The device a served partition is. toyos-blockdev = { path = "../../lib/blockdev" } +# The `--active` partitions, named as GUID text. +toyos-gpt = { path = "../../lib/gpt" } [package.metadata.toyos.host] exempt.owns = "the block service between ToyOS disk drivers and their clients" diff --git a/system/diskserver/src/lib.rs b/system/diskserver/src/lib.rs index 99d18360bba..de343129040 100644 --- a/system/diskserver/src/lib.rs +++ b/system/diskserver/src/lib.rs @@ -1,9 +1,11 @@ //! What a program that opens a partition through the block service links: //! the session itself ([`session`]), and the partition as a block device over -//! it ([`disk`]). The service is `src/main.rs` beside this. +//! it ([`disk`]); and a disk's description and a designation ([`tables`]). +//! The service is `src/main.rs` beside this. pub mod disk; pub mod session; +pub mod tables; pub use session::{list, Answer, Error, Session, Unsent, Waited}; pub use toyos_blockring::client::{Outcome, Ticket}; diff --git a/system/diskserver/src/main.rs b/system/diskserver/src/main.rs index fbe3b654769..4b1926d737b 100644 --- a/system/diskserver/src/main.rs +++ b/system/diskserver/src/main.rs @@ -9,13 +9,16 @@ //! region it sent — and the driver answers it and serves the session, so a //! block never passes through here. //! -//! **A connection reaches what its grant says** ([`wire::Grant`]): the badge -//! the supervisor minted its connector with, which the kernel stamps on it and -//! answers this acceptor alone. A listing names the partitions the grant -//! admits, an open of any other is refused `NotGranted` whether or not a disk -//! carries it, a connection with no grant reaches nothing, and a session whose -//! grant does not write is handed over as one, whose driver answers every -//! write `ReadOnly`. +//! **A connection reaches what its badge says** ([`wire::Badge`]), minted by +//! the supervisor, which the kernel stamps on it and answers this acceptor +//! alone. A partitions grant lists and opens what it admits, and an open of +//! any other is refused `NotGranted` whether or not a disk carries it; a +//! session whose grant does not write is handed over as one, whose driver +//! answers every write `ReadOnly`. A tables badge lists every partition and +//! has a disk described by its driver, and opens nothing. A designation badge +//! does one thing: the driver of the one disk carrying its partition carries +//! out that designation, told whether the running system is on it +//! (`--active`). A connection with no badge reaches nothing. //! //! **A driver is who the supervisor says it is**: the badge it minted the //! driver's `disk` connector with. It starts the disk drivers before this @@ -44,7 +47,7 @@ use toyos::poller::{Poller, READABLE}; use toyos::{AsHandle, OwnedHandle}; use toyos_abi::syscall::{SyscallError, MAX_BADGE}; use toyos_blockring::disk::{self, Offer, Part, Settled}; -use toyos_blockring::wire::{self, Grant, Listed, Refusal}; +use toyos_blockring::wire::{self, Badge, Grant, Listed, Refusal}; use toyos_blockring::PORT; use toyos_blockserve::guid_text; @@ -67,6 +70,9 @@ const MAX_DRIVERS: usize = 16; /// on. const DRIVER: &str = "--driver"; const UNDRIVEN: &str = "--undriven"; +/// Argv: a partition the running system is on, by unique GUID, which no +/// designation changes. +const ACTIVE: &str = "--active"; const TOKEN_ACCEPT: u64 = 0; const TOKEN_DRIVERS: u64 = 1; @@ -92,16 +98,18 @@ enum Fate { /// A disk driver as the supervisor named it: the badge it minted the /// driver's `disk` connector with, which the kernel stamps on the connection /// and the driver cannot choose. What a driver calls itself is for the lines. -type Badge = u32; +type DriverBadge = u32; /// Everything the drivers said, and what the supervisor said of them. #[derive(Default)] struct Known { /// The disk drivers the supervisor started before this service. - expected: Vec, + expected: Vec, undriven: Vec, - fates: BTreeMap, + fates: BTreeMap, offered: Vec, + /// The partitions the running system is on. + active: Vec<[u8; 16]>, } impl Known { @@ -116,7 +124,7 @@ impl Known { /// it settles again (`true`, so the bound counts from now). One that never /// settled is unusable at once: its end is its last word, and every /// question waiting on it is answered now rather than at the bound. - fn ended(&mut self, badge: Badge) -> bool { + fn ended(&mut self, badge: DriverBadge) -> bool { match self.fates.insert(badge, Fate::Ended) { Some(_) => true, None => { @@ -128,7 +136,7 @@ impl Known { /// The drivers whose controllers' partitions cannot be said absent: those /// expected and never heard, and those unusable or ended. - fn unsettled(&self) -> Vec { + fn unsettled(&self) -> Vec { let silent = self.expected.iter().filter(|d| !self.fates.contains_key(d)).copied(); silent.chain(self.fates.iter().filter(|(_, f)| **f != Fate::Offered).map(|(d, _)| *d)).collect() } @@ -144,18 +152,20 @@ impl Known { } } - /// What a listing through `grant` answers, or `None` while it waits: - /// every partition offered that it admits. **The grant is asked first**, + /// What a listing through `badge` answers, or `None` while it waits: + /// every partition offered that it lists. **The badge is asked first**, /// so a connection with none learns nothing. - fn listing(&self, grant: Option, expired: bool) -> Option, Refusal>> { - let Some(grant) = grant else { return Some(Err(Refusal::NotGranted)) }; + fn listing(&self, badge: Option, expired: bool) -> Option, Refusal>> { + let Some(badge) = badge.filter(|b| !matches!(b, Badge::Designate { .. })) else { + return Some(Err(Refusal::NotGranted)); + }; if !self.settled() && !expired { return None; } let listed: Vec = self .offered .iter() - .filter(|o| grant.admits(o.part.unique, o.part.kind)) + .filter(|o| badge.lists(o.part.unique, o.part.kind)) .map(|o| Listed { disk: o.disk, unique: o.part.unique, kind: o.part.kind }) .collect(); Some(match self.absent() { @@ -193,6 +203,37 @@ impl Known { } } +impl Known { + /// Where a description of disk `disk` through `badge` is answered, or its + /// refusal, or `None` while it waits: only a [`Badge::Tables`] reaches + /// one. + fn describe(&self, disk: u32, badge: Option, expired: bool) -> Option> { + if badge != Some(Badge::Tables) { + return Some(Err(Refusal::NotGranted)); + } + if !self.settled() && !expired { + return None; + } + Some(if self.offered.iter().any(|o| o.disk == disk) { Ok(disk) } else { Err(self.absent()) }) + } + + /// Where the designation `badge` names is carried out, and whether the + /// running system is on it; or its refusal, or `None` while it waits. A + /// partition two disks carry is refused on both, as an open of it is. + fn designation(&self, badge: Option, expired: bool) -> Option> { + let Some(Badge::Designate { unique, .. }) = badge else { return Some(Err(Refusal::NotGranted)) }; + if !self.settled() && !expired { + return None; + } + let carrying: Vec<&Offered> = self.offered.iter().filter(|o| o.part.unique == unique && unique != [0; 16]).collect(); + Some(match carrying[..] { + [] => Err(self.absent()), + [one] if one.part.blocks.is_some() => Ok((*one, self.active.contains(&unique))), + _ => Err(Refusal::Unusable), + }) + } +} + /// A connection that has not asked anything yet. struct Pending { conn: Connection, @@ -204,18 +245,20 @@ struct Pending { enum Ask { List, Open { guid: [u8; 16], region: OwnedHandle }, + Describe { disk: u32 }, + Designate { commit: bool }, } /// A client whose question waits for the drivers. struct Waiting { conn: Connection, - grant: Option, + badge: Option, ask: Ask, } /// One disk driver's connection. struct Driver { - badge: Badge, + badge: DriverBadge, conn: Connection, rx: ipc::FrameRx<{ 4 + disk::MAX_PARTS * Part::BYTES }>, label: Option, @@ -244,6 +287,10 @@ fn main() { let value = args.get(at + 1).unwrap_or_else(|| panic!("diskserver: {} takes a value", args[at])); match args[at].as_str() { DRIVER => known.expected.push(value.parse().unwrap_or_else(|_| panic!("diskserver: {DRIVER} {value}"))), + ACTIVE => { + let guid = toyos_gpt::Guid::parse(value).unwrap_or_else(|| panic!("diskserver: {ACTIVE} {value}")); + known.active.push(guid.0); + } UNDRIVEN => { println!("diskserver: {value} is on this machine and no driver of it started; what it carries is refused"); known.undriven.push(value.clone()); @@ -457,11 +504,20 @@ impl Service { } } - /// One client's first frame: a listing, or an open. + /// One client's first frame: a listing, an open, a description or a + /// designation. fn asked(&mut self, clients: &toyos::port::Acceptor, mut p: Pending, msg_type: u32, payload_len: usize) { - let grant = grant(clients, &p.conn); - if msg_type == wire::MSG_LIST && payload_len == 0 { - return self.waiting.push(Waiting { conn: p.conn, grant, ask: Ask::List }); + let badge = badge(clients, &p.conn); + let payload = p.rx.payload(payload_len); + let ask = match (msg_type, payload) { + (wire::MSG_LIST, []) => Some(Ask::List), + (wire::MSG_DESCRIBE, _) => disk::disk(payload).map(|disk| Ask::Describe { disk }), + (wire::MSG_DESIGNATE, [0]) => Some(Ask::Designate { commit: false }), + (wire::MSG_DESIGNATE, [1]) => Some(Ask::Designate { commit: true }), + _ => None, + }; + if let Some(ask) = ask { + return self.waiting.push(Waiting { conn: p.conn, badge, ask }); } let guid = wire::guid(p.rx.payload(payload_len)); let handles = p.rx.take_handles::<1>(); @@ -474,7 +530,7 @@ impl Service { // SAFETY: the handle arrived with this frame and nothing else in this // process names it. let region = unsafe { OwnedHandle::from_raw(region) }; - self.waiting.push(Waiting { conn: p.conn, grant, ask: Ask::Open { guid, region } }); + self.waiting.push(Waiting { conn: p.conn, badge, ask: Ask::Open { guid, region } }); } /// Answer every waiting question the drivers have now answered, or whose @@ -484,14 +540,14 @@ impl Service { for w in waiting { let expired = now >= self.awaited_since + DRIVER_BOUND; match w.ask { - Ask::List => match self.known.listing(w.grant, expired) { + Ask::List => match self.known.listing(w.badge, expired) { None => self.waiting.push(w), Some(answer) => { self.waited(expired, "a listing"); list(&w.conn, answer); } }, - Ask::Open { guid, region } => match self.known.open(guid, w.grant, expired) { + Ask::Open { guid, region } => match self.known.open(guid, partitions(w.badge), expired) { None => self.waiting.push(Waiting { ask: Ask::Open { guid, region }, ..w }), Some(Err(why)) => { self.waited(expired, "an open"); @@ -499,10 +555,28 @@ impl Service { refuse(&w.conn, why); } Some(Ok(offered)) => { - let writes = w.grant.expect("admitted through a grant").writes; + let writes = partitions(w.badge).expect("admitted through a grant").writes; self.hand_over(offered, guid, writes, w.conn, region); } }, + Ask::Describe { disk } => match self.known.describe(disk, w.badge, expired) { + None => self.waiting.push(Waiting { ask: Ask::Describe { disk }, ..w }), + Some(Err(why)) => refuse(&w.conn, why), + Some(Ok(ours)) => self.hand_work(ours, disk::MSG_DESCRIBE, |theirs| theirs.to_le_bytes().to_vec(), w.conn), + }, + Ask::Designate { commit } => match self.known.designation(w.badge, expired) { + None => self.waiting.push(Waiting { ask: Ask::Designate { commit }, ..w }), + Some(Err(why)) => { + println!("diskserver: a designation refused: {why:?}"); + refuse(&w.conn, why); + } + Some(Ok((offered, active))) => { + let Some(Badge::Designate { unique, token }) = w.badge else { unreachable!("admitted through its badge") }; + let order = |disk| disk::Designation { disk, unique, token, active, commit }.encode().to_vec(); + println!("diskserver: designation of {} handed to disk {}'s driver", guid_text(unique), offered.disk); + self.hand_work(offered.disk, disk::MSG_DESIGNATE, order, w.conn); + } + }, } } } @@ -536,6 +610,30 @@ impl Service { } } +impl Service { + /// Hand `conn` to the driver of disk `ours`, with the frame `payload` + /// makes of that driver's own number for the disk: the driver answers it. + fn hand_work(&mut self, ours: u32, msg_type: u32, payload: impl Fn(u32) -> Vec, conn: Connection) { + let Some((&id, d)) = self.drivers.iter().find(|(_, d)| d.disks.values().any(|&o| o == ours)) else { + unreachable!("diskserver: disk {ours} is offered and no driver has it") + }; + let theirs = *d.disks.iter().find(|(_, o)| **o == ours).expect("just found").0; + match d.conn.try_send_bytes_with(msg_type, &payload(theirs), [conn.into_owned()]) { + Ok(()) => {} + Err(ipc::TrySendError::Full) => self.drop_driver(id, "it would not take a client's work"), + Err(why) => println!("diskserver: the kernel would not move a client to disk driver {}: {why:?}", d.badge), + } + } +} + +/// The partitions grant a badge carries, if it is one. +fn partitions(badge: Option) -> Option { + match badge { + Some(Badge::Partitions(grant)) => Some(grant), + _ => None, + } +} + fn refuse(conn: &Connection, why: Refusal) { let _ = conn.try_send_bytes(wire::MSG_REFUSED, &why.encode()); } @@ -556,7 +654,7 @@ fn list(conn: &Connection, answer: Result, Refusal>) { /// Who the driver on `conn`, accepted from `acceptor`, is: the badge the /// supervisor minted its connector with. -fn driver_badge(acceptor: &toyos::port::Acceptor, conn: &Connection) -> Option { +fn driver_badge(acceptor: &toyos::port::Acceptor, conn: &Connection) -> Option { let mut badge = [0u8; MAX_BADGE]; match acceptor.badge(conn, &mut badge) { Ok(bytes) => disk::identity(bytes), @@ -565,12 +663,12 @@ fn driver_badge(acceptor: &toyos::port::Acceptor, conn: &Connection) -> Option Option { +fn badge(acceptor: &toyos::port::Acceptor, conn: &Connection) -> Option { let mut badge = [0u8; MAX_BADGE]; match acceptor.badge(conn, &mut badge) { - Ok(bytes) => Grant::decode(bytes), + Ok(bytes) => Badge::decode(bytes), Err(SyscallError::NotFound) => None, Err(why) => panic!("diskserver: its own acceptor would not say a connection's badge: {why:?}"), } @@ -590,8 +688,8 @@ mod tests { const DATA_KIND: [u8; 16] = [0x30; 16]; const ROOT_KIND: [u8; 16] = [0x40; 16]; /// Two drivers, by the badges the supervisor minted them. - const STICK: Badge = 7; - const NVME: Badge = 9; + const STICK: DriverBadge = 7; + const NVME: DriverBadge = 9; fn unique(guid: [u8; 16]) -> Option { Some(Grant { scope: Scope::Unique(guid), writes: true }) @@ -605,7 +703,7 @@ mod tests { Part { unique, kind, blocks: Some(100) } } - fn known(expected: &[Badge]) -> Known { + fn known(expected: &[DriverBadge]) -> Known { Known { expected: expected.to_vec(), ..Known::default() } } @@ -647,9 +745,9 @@ mod tests { let uniques = |answer: Option, Refusal>>| { answer.expect("answered").map(|all| all.iter().map(|l| (l.disk, l.unique)).collect::>()) }; - assert_eq!(uniques(known.listing(unique(BOOT), false)), Ok(vec![(0, BOOT)])); - assert_eq!(uniques(known.listing(data(), false)), Ok(vec![(1, DATA)])); - assert_eq!(uniques(known.listing(unique([9; 16]), false)), Ok(vec![])); + assert_eq!(uniques(known.listing(unique(BOOT).map(Badge::Partitions), false)), Ok(vec![(0, BOOT)])); + assert_eq!(uniques(known.listing(data().map(Badge::Partitions), false)), Ok(vec![(1, DATA)])); + assert_eq!(uniques(known.listing(unique([9; 16]).map(Badge::Partitions), false)), Ok(vec![])); assert_eq!(known.listing(None, false), Some(Err(Refusal::NotGranted))); } @@ -662,19 +760,19 @@ mod tests { let mut known = known(&[STICK, NVME]); known.fates.insert(NVME, Fate::Offered); known.offered.push(Offered { disk: 0, part: part(DATA, DATA_KIND) }); - assert_eq!(known.listing(data(), false), None, "the stick's driver has not settled"); + assert_eq!(known.listing(data().map(Badge::Partitions), false), None, "the stick's driver has not settled"); assert_eq!(known.open(LOG, unique(LOG), false), None); assert_eq!(known.open(DATA, data(), false), None, "offered, and another disk may carry it too"); assert_eq!(known.open(DATA, None, false), Some(Err(Refusal::NotGranted)), "no grant waits for nothing"); assert_eq!(known.open(LOG, unique(LOG), true), Some(Err(Refusal::Unusable)), "the bound passed, a driver silent"); assert!(known.open(DATA, data(), true).is_some_and(|r| r.is_ok())); - assert!(known.listing(data(), true).is_some_and(|r| r.is_ok_and(|l| l.len() == 1))); + assert!(known.listing(data().map(Badge::Partitions), true).is_some_and(|r| r.is_ok_and(|l| l.len() == 1))); let log = Some(Grant { scope: Scope::Kind(LOG_KIND), writes: true }); - assert_eq!(known.listing(log, true), Some(Err(Refusal::Unusable)), "nothing listed, a driver silent"); + assert_eq!(known.listing(log.map(Badge::Partitions), true), Some(Err(Refusal::Unusable)), "nothing listed, a driver silent"); known.fates.insert(STICK, Fate::Offered); assert_eq!(known.open(LOG, unique(LOG), false), Some(Err(Refusal::NotFound)), "every driver said"); - assert_eq!(known.listing(log, false), Some(Ok(vec![]))); + assert_eq!(known.listing(log.map(Badge::Partitions), false), Some(Ok(vec![]))); } /// A GUID the first driver offers is not routed while another may offer @@ -702,7 +800,7 @@ mod tests { known.offered.retain(|o| o.disk != 1); assert_eq!(known.open(DATA, data(), false), None, "a type grant waits for the offer"); assert_eq!(known.open(DATA, unique(DATA), false), None); - assert_eq!(known.listing(data(), false), None); + assert_eq!(known.listing(data().map(Badge::Partitions), false), None); known.fates.insert(NVME, Fate::Offered); known.offered.push(Offered { disk: 2, part: part(DATA, DATA_KIND) }); assert_eq!(known.open(DATA, data(), false), Some(Ok(Offered { disk: 2, part: part(DATA, DATA_KIND) }))); @@ -746,13 +844,13 @@ mod tests { fn an_unusable_or_undriven_controller_is_refused_and_an_absent_one_lists_nothing() { let mut unusable = known(&[NVME]); unusable.fates.insert(NVME, Fate::Unusable); - assert_eq!(unusable.listing(data(), false), Some(Err(Refusal::Unusable))); + assert_eq!(unusable.listing(data().map(Badge::Partitions), false), Some(Err(Refusal::Unusable))); assert_eq!(unusable.open(DATA, unique(DATA), false), Some(Err(Refusal::Unusable))); let undriven = Known { undriven: vec!["pci:01:08:02 at PCI 00:02.0".into()], ..Known::default() }; - assert_eq!(undriven.listing(data(), false), Some(Err(Refusal::ClaimRefused))); + assert_eq!(undriven.listing(data().map(Badge::Partitions), false), Some(Err(Refusal::ClaimRefused))); assert_eq!(undriven.open(DATA, unique(DATA), false), Some(Err(Refusal::ClaimRefused))); let absent = Known::default(); - assert_eq!(absent.listing(data(), false), Some(Ok(vec![]))); + assert_eq!(absent.listing(data().map(Badge::Partitions), false), Some(Ok(vec![]))); assert_eq!(absent.open(DATA, unique(DATA), false), Some(Err(Refusal::NotFound))); } @@ -766,4 +864,32 @@ mod tests { assert_eq!(known.open(LOG, unique(LOG), false), Some(Err(Refusal::Unusable))); assert_eq!(known.open([7; 16], unique([7; 16]), false), Some(Err(Refusal::Unusable))); } + + /// A description reaches a disk only through a tables badge, and names + /// one the service has; a designation reaches only the partition its own + /// badge names, on the one disk that carries it, said active when the + /// running system is on it, and nothing a partitions grant or a tables + /// badge carries designates anything. + #[test] + fn only_its_own_badge_describes_or_designates() { + let mut known = two_disks(); + known.active.push(LOG); + assert_eq!(known.describe(1, Some(Badge::Tables), false), Some(Ok(1))); + assert_eq!(known.describe(7, Some(Badge::Tables), false), Some(Err(Refusal::NotFound))); + for other in [None, data().map(Badge::Partitions), Some(Badge::Designate { unique: DATA, token: [0; 16] })] { + assert_eq!(known.describe(1, other, false), Some(Err(Refusal::NotGranted)), "{other:?}"); + } + let designate = |unique| Some(Badge::Designate { unique, token: [8; 16] }); + let data_disk = Offered { disk: 1, part: part(DATA, DATA_KIND) }; + assert_eq!(known.designation(designate(DATA), false), Some(Ok((data_disk, false)))); + assert!(matches!(known.designation(designate(LOG), false), Some(Ok((_, true)))), "the running system's log"); + assert_eq!(known.designation(designate([9; 16]), false), Some(Err(Refusal::NotFound))); + for other in [None, data().map(Badge::Partitions), Some(Badge::Tables)] { + assert_eq!(known.designation(other, false), Some(Err(Refusal::NotGranted)), "{other:?}"); + } + assert_eq!(known.listing(designate(DATA), false), Some(Err(Refusal::NotGranted)), "a designation lists nothing"); + assert_eq!(known.listing(Some(Badge::Tables), false).map(|r| r.map(|l| l.len())), Some(Ok(4))); + known.offered.push(Offered { disk: 0, part: part(DATA, DATA_KIND) }); + assert_eq!(known.designation(designate(DATA), false), Some(Err(Refusal::Unusable)), "on two disks"); + } } diff --git a/system/diskserver/src/session.rs b/system/diskserver/src/session.rs index edc6123da30..587715fd4a2 100644 --- a/system/diskserver/src/session.rs +++ b/system/diskserver/src/session.rs @@ -376,7 +376,7 @@ fn whole_blocks(len: usize) -> u32 { blocks } -fn connect(names: &Namespace, service: &str) -> Result { +pub(crate) fn connect(names: &Namespace, service: &str) -> Result { names.open(service).map_err(|e| match e { SyscallError::NotFound => Error::NotHeld, e => Error::Kernel(e), @@ -385,7 +385,7 @@ fn connect(names: &Namespace, service: &str) -> Result { /// The header of the service's answer on `conn`, once it is there, within /// [`wire::ANSWER_BOUND_NS`]. -fn answer(conn: &Connection) -> Result { +pub(crate) fn answer(conn: &Connection) -> Result { let poller = Poller::new(1); poller.watch(conn, READABLE, 0); let mut ready = false; diff --git a/system/diskserver/src/tables.rs b/system/diskserver/src/tables.rs new file mode 100644 index 00000000000..c2f284ca567 --- /dev/null +++ b/system/diskserver/src/tables.rs @@ -0,0 +1,57 @@ +//! A client's side of a disk's description and of a designation: what +//! `disks` and the supervisor ask the block service through a tables or a +//! designation badge (`toyos_blockring::wire::Badge`). + +use toyos::namespace::Namespace; +use toyos_blockring::wire::{self, Designated, DiskHead, Refusal, SlotLine}; + +use crate::session::{answer, connect}; +use crate::Error; + +/// The longest frame either answer carries. +const FRAME: usize = 4 + 2 * (2 + wire::MAX_STATE) + 64; + +/// One frame's type and payload on `conn`, within the answer bound. +fn frame(conn: &toyos::ipc::Connection) -> Result<(u32, Vec), Error> { + let header = answer(conn)?; + let mut payload = vec![0u8; (header.len() as usize).min(FRAME)]; + let len = conn.recv_bytes(&header, &mut payload).map_err(|_| Error::Ended)?; + payload.truncate(len); + Ok((header.msg_type, payload)) +} + +fn refused(payload: &[u8]) -> Error { + Refusal::decode(payload).map_or(Error::Protocol, Error::Refused) +} + +/// What disk `disk` (the service's number, from a listing) holds: its head +/// and every used entry of its table in force. +pub fn describe(names: &Namespace, service: &str, disk: u32) -> Result<(DiskHead, Vec), Error> { + let conn = connect(names, service)?; + conn.send_bytes(wire::MSG_DESCRIBE, &disk.to_le_bytes()).map_err(|_| Error::Ended)?; + let head = match frame(&conn)? { + (wire::MSG_DISK, payload) => DiskHead::decode(&payload).ok_or(Error::Protocol)?, + (wire::MSG_REFUSED, payload) => return Err(refused(&payload)), + _ => return Err(Error::Protocol), + }; + let mut slots = Vec::new(); + loop { + match frame(&conn)? { + (wire::MSG_SLOT, payload) => slots.push(SlotLine::decode(&payload).ok_or(Error::Protocol)?), + (wire::MSG_DESCRIBED, payload) if payload.is_empty() => return Ok((head, slots)), + _ => return Err(Error::Protocol), + } + } +} + +/// Carry out the designation the connector `service` names was minted for: +/// write the stamp when `commit`, judge only otherwise. +pub fn designate(names: &Namespace, service: &str, commit: bool) -> Result { + let conn = connect(names, service)?; + conn.send_bytes(wire::MSG_DESIGNATE, &[u8::from(commit)]).map_err(|_| Error::Ended)?; + match frame(&conn)? { + (wire::MSG_DESIGNATED, payload) => Designated::decode(&payload).ok_or(Error::Protocol), + (wire::MSG_REFUSED, payload) => Err(refused(&payload)), + _ => Err(Error::Protocol), + } +} From e3f24b0966a9cf17315dc2010f43ce7873fb3a1f Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:05:15 +0100 Subject: [PATCH 05/14] disks: the tables listed and DATA designated under the authorizations the signed image grants `/system/bin/disks list` describes every disk the block service serves: its size, LBA 0, each copy of its table and every partition of the copy in force. `disks designate data [--commit]` has the machine's one DATA partition designated by the driver of its disk. Neither is authority of the program's own: each needs the authorization its row was minted (`toyos_manifest::authorize`), which the supervisor mints at every start of the row as a connector to the block service carrying that one badge. The build lets an authorization that changes a disk go only to a row no login can start, and no image this repository ships carries one: today only a signed test image's job designates. Interactive use waits for the administrative authorization every system change will share, and nothing disk-specific stands in for it (`issues/toyos-manages-its-own-partitions.md`). The designation is resolved when it is asked for, not when the row starts: the block service finds the one DATA partition among every disk it serves, refusing none, two, or a GUID two disks carry, and the driver of that disk judges it against its own table, its holds and the running system's partitions, which the supervisor names (`--active`), in the turn it begins. An image's preauthorization names a role, not a state a person reviewed, so there is nothing for a token to bind and none is carried; a plan-bound authorization brings one with the table edits it permits. A foreign DATA partition is served absent rather than from memory, which `issues/a-foreign-data-partition-is-answered-with-memory.md` asked for: `hostdisk::left_alone` reads the absent line and that the in-memory line is never said. The scratch disk `--adopt-scratch` writes is recorded as not served (`issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md`). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 + Cargo.toml | 1 + build/build.rs | 42 ++++ images/system.toml | 9 +- ...-data-partition-is-answered-with-memory.md | 23 -- ...-disk-adopt-scratch-makes-is-not-served.md | 21 ++ issues/toyos-manages-its-own-partitions.md | 47 ++++ lib/blockring/src/disk.rs | 19 +- lib/blockring/src/wire.rs | 50 ++-- lib/blockserve/src/lib.rs | 9 - lib/blockserve/src/sessions.rs | 45 ++-- lib/manifest/src/authorize.rs | 44 ++++ lib/manifest/src/lib.rs | 16 ++ system/disks/Cargo.toml | 16 ++ system/disks/src/main.rs | 217 ++++++++++++++++++ system/diskserver/src/main.rs | 98 +++++--- system/fileserver/src/main.rs | 5 +- system/nvme/src/main.rs | 2 +- system/supervisor/src/main.rs | 47 +++- system/usbd/src/main.rs | 2 +- tests/common/hostdisk.rs | 5 +- 21 files changed, 584 insertions(+), 145 deletions(-) delete mode 100644 issues/a-foreign-data-partition-is-answered-with-memory.md create mode 100644 issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md create mode 100644 issues/toyos-manages-its-own-partitions.md create mode 100644 lib/manifest/src/authorize.rs create mode 100644 system/disks/Cargo.toml create mode 100644 system/disks/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index a43fc71e235..5fdda3642ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,17 @@ dependencies = [ "ctutils", ] +[[package]] +name = "disks" +version = "0.1.0" +dependencies = [ + "diskserver", + "toyos", + "toyos-blockring", + "toyos-gpt", + "toyos-manifest", +] + [[package]] name = "diskserver" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 135aa2ffdd6..bb84daf99c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ members = [ "system/host", "system/inspect", "system/logkeeper", + "system/disks", "system/logout", "system/netstack", "system/netstack/dhcp", diff --git a/build/build.rs b/build/build.rs index 2a8d7a7ed1f..301a7a48a17 100644 --- a/build/build.rs +++ b/build/build.rs @@ -193,6 +193,8 @@ struct ProgramConfig { syscap: Vec, /// The idle slot (`toyos_manifest::Program::slots`). slots: bool, + /// Disk authorizations minted at its start (`toyos_manifest::authorize`). + authorize: Vec, /// A system service: the supervisor starts it with `HOME` at its own `/state/` /// and makes that directory, where every other row gets the session's. service: bool, @@ -717,6 +719,18 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { if program.slots && config.boot.start.contains(name) { return Err(format!("`{name}` asks for `slots` and is in `[boot] start`, and the slots are granted to a launch alone")); } + // A row any login could start would hand every login what it holds: + // an authorization that changes a disk is the image's to grant, to a + // row only the boot starts. + for word in &program.authorize { + let Some(authorization) = toyos_manifest::authorize::Authorization::parse(word) else { + return Err(format!("`{name}` asks to be authorized for `{word}`, which is no disk authorization")); + }; + let starters: Vec<&String> = config.programs.iter().filter(|(_, p)| p.starts.contains(name)).map(|(n, _)| n).collect(); + if authorization.changes() && !starters.is_empty() { + return Err(format!("`{name}` is authorized for `{word}`, which changes a disk, and {starters:?} may start it")); + } + } } for (port, holders) in HELD_ALONE { if config.apps.receives.iter().any(|r| r == port) { @@ -844,6 +858,7 @@ fn render_manifest(config: &SystemConfig) -> Vec { drives: cfg.drives.clone(), syscap: cfg.syscap.clone(), slots: cfg.slots, + authorize: cfg.authorize.clone(), service: cfg.service, state: cfg.state, roles: cfg.roles.clone(), @@ -2967,6 +2982,33 @@ mod tests { ); } + /// No image this repository ships authorizes a change to a disk: that is + /// a test image's, for the one boot that makes it. + #[test] + fn no_shipped_boot_config_authorizes_a_disk_change() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + for boot in [Boot::shipped(root), Boot::diag(root), Boot::console(root)] { + let config = parse_config(&boot.config); + for (name, program) in &config.programs { + let changes = program.authorize.iter().filter_map(|w| toyos_manifest::authorize::Authorization::parse(w)).any(|a| a.changes()); + assert!(!changes, "{} authorizes `{name}` to change a disk: {:?}", boot.config.display(), program.authorize); + } + } + } + + /// An authorization that changes a disk goes to a row only the boot + /// starts, never one a login can launch; reading the tables may go to + /// either; and a word that names nothing is refused. + #[test] + fn a_disk_changing_authorization_goes_to_no_row_a_login_can_start() { + let gate = |text: &str| held_by_their_holders_alone(&toml::from_str(text).unwrap()); + assert!(gate("[programs.runner]\nauthorize = [\"designate:data\"]\n").is_ok()); + assert!(gate("[programs.disks]\nauthorize = [\"tables\"]\n[programs.shell]\nstarts = [\"disks\"]\n").is_ok()); + let launched = gate("[programs.disks]\nauthorize = [\"designate:data\"]\n[programs.shell]\nstarts = [\"disks\"]\n"); + assert!(launched.unwrap_err().contains("may start it")); + assert!(gate("[programs.disks]\nauthorize = [\"format:everything\"]\n").unwrap_err().contains("no disk authorization")); + } + /// No image this repository ships starts sshserver. /// /// It listens on every interface and authenticates against a file that is diff --git a/images/system.toml b/images/system.toml index 1135b3ad838..30b0a4fb44a 100644 --- a/images/system.toml +++ b/images/system.toml @@ -168,6 +168,13 @@ login = true [programs.logout] receives = ["session-admin"] +# The machine's disks and their tables, read: `launch disks`. Nothing here +# lets it change a disk; an authorization that does (`designate:data`) is the +# build's to refuse any row a login can start (`build/build.rs`), until the +# administrative authorization every system change will share exists. +[programs.disks] +authorize = ["tables"] + # `swap` is the authority to replace a running service's binary; the build # refuses it to any other program (`build/build.rs`). `ssh swap netstack # < netstack` runs it for a login. @@ -225,7 +232,7 @@ starts = ["shell"] [programs.shell] receives = ["surface"] -starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "sshserver", "swap", "toyfetch", "update", "grants", "logout", "/apps"] +starts = ["toybox", "shell", "terminal", "doom", "snake", "calc", "editor", "paint", "files", "host", "inspect", "pkg", "sshserver", "swap", "toyfetch", "update", "grants", "logout", "disks", "/apps"] [programs.doom] receives = ["compositor", "soundserver"] diff --git a/issues/a-foreign-data-partition-is-answered-with-memory.md b/issues/a-foreign-data-partition-is-answered-with-memory.md deleted file mode 100644 index c176d6bf72b..00000000000 --- a/issues/a-foreign-data-partition-is-answered-with-memory.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -status: open -kind: defect -opened: 2026-09-28 ---- - -# A foreign DATA partition is answered with memory - -`system/fileserver/src/main.rs`'s `data_on` answers `Probed::Foreign` — a -TOYOS-DATA partition that holds no volume of ours and no designation stamp — -with `ram()`: `/apps`, `/config`, `/home` and `/state` are served from memory, -and everything written under them is lost at the next reboot. That is a disk -that is there and cannot be used, answered as though the machine had none, -which is the harm `fileserver::absent::Absent` exists to refuse; a refused NVMe -claim and two DATA partitions are refused by name for the same reason. - -`foreign_disk_untouched`, the guest test that booted this arm, asserted the -partition is not written and the `no volume of ours` line, and nothing about -what stands in for it; it was cut in `520c0d129`. - -**Exit condition:** a foreign DATA partition serves DATA absent by name, and -a test of `data_on`'s `Probed::Foreign` arm reads the absent line and that the -in-memory line is never said. diff --git a/issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md b/issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md new file mode 100644 index 00000000000..af9c8e256ee --- /dev/null +++ b/issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md @@ -0,0 +1,21 @@ +--- +status: open +kind: defect +opened: 2026-10-28 +--- + +# The scratch disk `--adopt-scratch` makes is not served + +`toyos-metal --adopt-scratch` (`tests/metal/src/lib.rs`, `Job::Scratch`) +writes a table whose one partition runs from LBA 2048 to the last usable +sector (`sgdisk --new=1:0:0`), so on a stick whose sector count is not whole +4 KiB blocks the partition is not whole blocks either, and usbd offers it +and refuses every session on it by name: a bench boot's log read +`partition … of type 51CC09F9-7442-435B-BC03-0FD8718B0FB5 is not served: LBA +2048+60060419 is not whole 4 KiB blocks of 512`. A destructive test given +that stick has nothing to write. + +**Exit condition:** a scratch disk is initialized from inside ToyOS with one +partition on whole 4 KiB blocks (`issues/toyos-manages-its-own-partitions.md`, +stage 3), a bench boot serves a session on it, and `--adopt-scratch` is +deleted. diff --git a/issues/toyos-manages-its-own-partitions.md b/issues/toyos-manages-its-own-partitions.md new file mode 100644 index 00000000000..66679d7de49 --- /dev/null +++ b/issues/toyos-manages-its-own-partitions.md @@ -0,0 +1,47 @@ +--- +status: open +kind: track +opened: 2026-10-28 +--- + +# ToyOS manages its own partitions + +The disk driver that drives a disk owns its table and makes every change to +it (`lib/blockserve`); diskserver routes a request to it under the badge its +connector was minted with (`toyos_blockring::wire::Badge`); `toyos_gpt::edit` +is the one GPT writer, laid in UEFI 2.10 §5.3.2's order; `/system/bin/disks` +is the command. An authorization is one action on one target, and no badge +that changes a disk opens a session or writes a byte a client chose. + +Built: `disks list` (`authorize = ["tables"]`) and the designation of the +machine's one DATA partition (`authorize = ["designate:data"]`), which the +build grants only a row no login can start (`build/build.rs`), so today only +a signed test image's job holds it. + +## Stages, in order + +1. **Interactive authorization.** `disks` changes nothing for a person at a + shell or over ssh: no row a login can start may hold an authorization that + changes a disk, and none is built to fill the gap. Its exit is the + administrative authorization every system change shares (the planning + architect, 2026-10-28) — one userland path, not a disk-specific trust + system — through which `disks` asks for one authorization of one action + on one target, and the supervisor mints it. Its product choice (how the + owner approves a system change) is the owner's. +2. **Table edits.** `Edit` (one disk's table replaced by an exact intended + table: create, delete, retype, rename, and restoring a copy, which §5.3.2 + requires be reported) and `Init` (a fresh table on one disk). Each + authorization names the plan it permits and is bound to the disk's state + when it was authorized — a token the driver draws at bring-up and after + every change it makes, compared in the turn it writes. The driver refuses + a change to a held partition or one the running system is on + (`--active`), writes sector-granular at the disk's true last LBA, flushes + between the two copies, and reads both back. Unrelated partitions are + untouched by construction; another system's are reclaimed only when the + plan names them. +3. **Scratch disks.** `Init` with one aligned partition of + `Guid::TOYOS_SCRATCH` replaces `toyos-metal --adopt-scratch` + (`issues/the-scratch-disk-adopt-scratch-makes-is-not-served.md`). +4. **The installer** (`issues/storage-is-layers-and-a-role-is-a-filesystem.md`, + stage 3) lays its layout through stage 2's library and authorization, and + `toyos-metal --install-internal`'s `sgdisk` and `partx` go with it. diff --git a/lib/blockring/src/disk.rs b/lib/blockring/src/disk.rs index 02c87e2c63d..1cc086826bc 100644 --- a/lib/blockring/src/disk.rs +++ b/lib/blockring/src/disk.rs @@ -192,28 +192,26 @@ impl Session { } /// A designation the service admitted, as [`MSG_DESIGNATE`] carries it: on -/// the driver's disk `disk`, the partition `unique`, if the disk's token is -/// still `token`; `active` for a partition the running system is on, which -/// the driver refuses; `commit` to write rather than judge. +/// the driver's disk `disk`, the partition `unique`; `active` for a partition +/// the running system is on, which the driver refuses; `commit` to write +/// rather than judge. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Designation { pub disk: u32, pub unique: [u8; GUID_BYTES], - pub token: crate::wire::Token, pub active: bool, pub commit: bool, } impl Designation { - pub const BYTES: usize = 4 + 2 * GUID_BYTES + 2; + pub const BYTES: usize = 4 + GUID_BYTES + 2; pub fn encode(&self) -> [u8; Self::BYTES] { let mut out = [0u8; Self::BYTES]; out[..4].copy_from_slice(&self.disk.to_le_bytes()); out[4..20].copy_from_slice(&self.unique); - out[20..36].copy_from_slice(&self.token); - out[36] = u8::from(self.active); - out[37] = u8::from(self.commit); + out[20] = u8::from(self.active); + out[21] = u8::from(self.commit); out } @@ -227,9 +225,8 @@ impl Designation { Some(Self { disk: u32::from_le_bytes(b[..4].try_into().ok()?), unique: b[4..20].try_into().ok()?, - token: b[20..36].try_into().ok()?, - active: flag(b[36])?, - commit: flag(b[37])?, + active: flag(b[20])?, + commit: flag(b[21])?, }) } } diff --git a/lib/blockring/src/wire.rs b/lib/blockring/src/wire.rs index e5affbdef84..6768acb6dd2 100644 --- a/lib/blockring/src/wire.rs +++ b/lib/blockring/src/wire.rs @@ -50,8 +50,8 @@ pub const MSG_DISK: u32 = 7; pub const MSG_SLOT: u32 = 8; /// The description is whole. No payload. pub const MSG_DESCRIBED: u32 = 9; -/// Designate the partition a [`Badge::Designate`] names: payload one byte, -/// 1 to write the stamp and 0 to judge only. Answered [`MSG_DESIGNATED`] or +/// Designate the machine's DATA partition, for a [`Badge::DesignateData`] +/// connection: payload one byte, 1 to write the stamp and 0 to judge only. Answered [`MSG_DESIGNATED`] or /// [`MSG_REFUSED`]. pub const MSG_DESIGNATE: u32 = 10; /// A [`Designated`]. @@ -256,9 +256,6 @@ pub enum Refusal { /// minter of this protocol stamps, reaches none, and its listing is /// refused the same. NotGranted, - /// The token an authorization was minted against is not the disk's: the - /// disk was brought up again or changed since. - Stale, /// The partition backs the running system, which no authorization /// changes beneath it. Active, @@ -276,9 +273,8 @@ impl Refusal { Self::Exhausted => 5, Self::ClaimRefused => 6, Self::NotGranted => 7, - Self::Stale => 8, - Self::Active => 9, - Self::Device => 10, + Self::Active => 8, + Self::Device => 9, } } @@ -291,9 +287,8 @@ impl Refusal { 5 => Some(Self::Exhausted), 6 => Some(Self::ClaimRefused), 7 => Some(Self::NotGranted), - 8 => Some(Self::Stale), - 9 => Some(Self::Active), - 10 => Some(Self::Device), + 8 => Some(Self::Active), + 9 => Some(Self::Device), _ => None, } } @@ -307,11 +302,6 @@ impl Refusal { } } -/// A disk's table state, drawn by its driver at the disk's bring-up and -/// again after every change it makes to the disk: what an authorization is -/// bound to. -pub type Token = [u8; GUID_BYTES]; - /// What a connector to the block service reaches: the badge the supervisor /// minted it with. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -321,9 +311,10 @@ pub enum Badge { /// Every disk's table, read: a listing of every partition and each disk's /// description. No session and no change. Tables, - /// One designation of the partition `unique` while its disk's token is - /// `token`, and nothing else. - Designate { unique: [u8; GUID_BYTES], token: Token }, + /// Designations of the machine's DATA partition, resolved when one is + /// asked for: the one partition of `TOYOS_DATA`'s type the service + /// serves. Nothing else. + DesignateData, } impl Badge { @@ -331,7 +322,7 @@ impl Badge { match self { Self::Partitions(grant) => grant.encode().to_vec(), Self::Tables => vec![3], - Self::Designate { unique, token } => [&[4][..], unique, token].concat(), + Self::DesignateData => vec![4], } } @@ -340,10 +331,7 @@ impl Badge { match bytes.first()? { 1 | 2 => Grant::decode(bytes).map(Self::Partitions), 3 => (bytes.len() == 1).then_some(Self::Tables), - 4 if bytes.len() == 1 + 2 * GUID_BYTES => Some(Self::Designate { - unique: bytes[1..1 + GUID_BYTES].try_into().ok()?, - token: bytes[1 + GUID_BYTES..].try_into().ok()?, - }), + 4 => (bytes.len() == 1).then_some(Self::DesignateData), _ => None, } } @@ -353,7 +341,7 @@ impl Badge { match self { Self::Partitions(grant) => grant.admits(unique, kind), Self::Tables => true, - Self::Designate { .. } => false, + Self::DesignateData => false, } } } @@ -373,11 +361,10 @@ pub enum Lba0 { /// The longest copy state a [`DiskHead`] carries. pub const MAX_STATE: usize = 200; -/// One disk as its driver describes it: its token, its size, and what each -/// copy of its table is — `valid` or why not. +/// One disk as its driver describes it: its size, and what each copy of its +/// table is — `valid` or why not. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DiskHead { - pub token: Token, pub lba_bytes: u32, pub sectors: u64, pub lba0: Lba0, @@ -389,8 +376,7 @@ pub struct DiskHead { impl DiskHead { pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(34 + self.primary.len() + self.backup.len() + 4); - out.extend_from_slice(&self.token); + let mut out = Vec::with_capacity(18 + self.primary.len() + self.backup.len() + 4); out.extend_from_slice(&self.lba_bytes.to_le_bytes()); out.extend_from_slice(&self.sectors.to_le_bytes()); out.push(match self.lba0 { @@ -409,8 +395,7 @@ impl DiskHead { } pub fn decode(bytes: &[u8]) -> Option { - let (token, rest) = bytes.split_first_chunk::()?; - let (lba_bytes, rest) = rest.split_first_chunk::<4>()?; + let (lba_bytes, rest) = bytes.split_first_chunk::<4>()?; let (sectors, rest) = rest.split_first_chunk::<8>()?; let (&[lba0, agree], rest) = rest.split_first_chunk::<2>()?; let lba0 = match lba0 { @@ -428,7 +413,6 @@ impl DiskHead { let (primary, rest) = text(rest)?; let (backup, rest) = text(rest)?; rest.is_empty().then(|| Self { - token: *token, lba_bytes: u32::from_le_bytes(*lba_bytes), sectors: u64::from_le_bytes(*sectors), lba0, diff --git a/lib/blockserve/src/lib.rs b/lib/blockserve/src/lib.rs index d48b45de188..78477be870a 100644 --- a/lib/blockserve/src/lib.rs +++ b/lib/blockserve/src/lib.rs @@ -17,15 +17,6 @@ pub mod table; pub use sessions::{Device, Owner, Sessions}; pub use table::Partition; -/// A fresh table token ([`toyos_blockring::wire::Token`]) from the kernel's -/// random source. A refused draw ends the driver: a token anyone can predict -/// binds nothing. -pub fn draw_token() -> toyos_blockring::wire::Token { - let mut token = [0u8; 16]; - toyos_abi::syscall::random(&mut token).unwrap_or_else(|why| panic!("blockserve: no random token for a disk: {why:?}")); - token -} - /// A partition GUID as the lines say it. pub fn guid_text(bytes: [u8; 16]) -> String { toyos_gpt::Guid(bytes).to_string() diff --git a/lib/blockserve/src/sessions.rs b/lib/blockserve/src/sessions.rs index db786e22a8b..fc6419dfeac 100644 --- a/lib/blockserve/src/sessions.rs +++ b/lib/blockserve/src/sessions.rs @@ -30,7 +30,7 @@ use toyos_blockring::entry::{Completion, Op}; use toyos_blockring::layout::{ServerRings, DEPTH}; use toyos_blockring::server::{ServerSession, Taken}; use toyos_blockring::disk::Designation; -use toyos_blockring::wire::{self, Designated, DiskHead, Lba0, Opened, Refusal, SlotLine, Token}; +use toyos_blockring::wire::{self, Designated, DiskHead, Lba0, Opened, Refusal, SlotLine}; use toyos_blockring::Status; use crate::designate::{self, Ask}; @@ -110,9 +110,6 @@ pub struct Sessions { name: String, parts: Vec, read: Read, - /// The disk's table state ([`wire::Token`]), and where a fresh one comes from. - token: Token, - draw: fn() -> Token, holds: Holds, /// The device's loss count: every reset may have dropped its cache. losses: u64, @@ -122,16 +119,13 @@ pub struct Sessions { } impl Sessions { - /// The disk `read` describes, under a token `draw` draws now and after - /// every change this driver makes to the disk. - pub fn new(name: String, read: Read, draw: fn() -> Token) -> Self { + /// The disk `read` describes. + pub fn new(name: String, read: Read) -> Self { let parts = table::say(&name, &read); Self { name, parts, read, - token: draw(), - draw, holds: Holds::new(), losses: 0, sessions: BTreeMap::new(), @@ -413,7 +407,6 @@ impl Sessions { pub fn describe(&self, conn: Connection) { let copies = self.read.copies.as_deref(); let head = DiskHead { - token: self.token, lba_bytes: self.read.lba_bytes, sectors: self.read.sectors, lba0: match copies.map(|c| c.mbr) { @@ -452,15 +445,12 @@ impl Sessions { } /// The span `order` designates, held for it; or its refusal, judged - /// against this disk's own state in this turn: its token, the running - /// system's partitions, its table and its holds. + /// against this disk's own state in this turn: the running system's + /// partitions, its table and its holds. fn admit(&mut self, order: &Designation, dev: &mut impl Device) -> Result<(u64, u64), Refusal> { if self.job.is_some() { return Err(Refusal::Exhausted); } - if order.token != self.token { - return Err(Refusal::Stale); - } if order.active { return Err(Refusal::Active); } @@ -522,9 +512,6 @@ impl Sessions { } self.holds.release(job.first); let what = crate::guid_text(job.order.unique); - if outcome == Ok(Designated::Written) { - self.token = (self.draw)(); - } match outcome { Ok(done) => { println!("{}: designation of {what}: {done:?}", self.name); @@ -569,32 +556,30 @@ mod tests { Partition { unique: [1; 16], kind: [9; 16], span: Ok((0, 100)) }, Partition { unique: [2; 16], kind: [9; 16], span: Err("not whole blocks".into()) }, ]; - Sessions::new("test".into(), Read { lba_bytes: 512, sectors: 1 << 20, copies: None, parts: Ok(parts) }, || [5; 16]) + Sessions::new("test".into(), Read { lba_bytes: 512, sectors: 1 << 20, copies: None, parts: Ok(parts) }) } - fn order(unique: [u8; 16], token: Token, active: bool) -> Designation { - Designation { disk: 0, unique, token, active, commit: true } + fn order(unique: [u8; 16], active: bool) -> Designation { + Designation { disk: 0, unique, active, commit: true } } /// A designation is judged against the disk's own state before it takes - /// anything: a token from another state of the disk, a partition the - /// running system is on, one a session holds, one the table does not - /// name and one it will not serve are each refused, and none of them - /// holds the partition. + /// anything: a partition the running system is on, one a session holds, + /// one the table does not name and one it will not serve are each + /// refused, and none of them holds the partition. #[test] fn a_designation_is_refused_on_the_disks_own_state_before_it_holds_anything() { let mut disk = disk(); let refusals = [ - (order([1; 16], [6; 16], false), Refusal::Stale), - (order([1; 16], [5; 16], true), Refusal::Active), - (order([3; 16], [5; 16], false), Refusal::NotFound), - (order([2; 16], [5; 16], false), Refusal::Unusable), + (order([1; 16], true), Refusal::Active), + (order([3; 16], false), Refusal::NotFound), + (order([2; 16], false), Refusal::Unusable), ]; for (order, want) in refusals { assert_eq!(disk.admit(&order, &mut Nothing), Err(want), "{order:?}"); } assert_eq!(disk.place([1; 16], &mut Nothing), Ok((0, 100)), "nothing refused held it"); - assert_eq!(disk.admit(&order([1; 16], [5; 16], false), &mut Nothing), Err(Refusal::Held)); + assert_eq!(disk.admit(&order([1; 16], false), &mut Nothing), Err(Refusal::Held)); } /// What only the disk knows refuses an open the service admitted: a diff --git a/lib/manifest/src/authorize.rs b/lib/manifest/src/authorize.rs new file mode 100644 index 00000000000..0d9ebc31539 --- /dev/null +++ b/lib/manifest/src/authorize.rs @@ -0,0 +1,44 @@ +//! What a row may do to the machine's disks beyond its own partitions, which +//! only the signed image grants: `authorize = [...]` in its row. The +//! supervisor mints each at the row's start, scoped to one action and target +//! in the state it finds then, and puts it in the row's namespace under +//! [`Authorization::name`]. No login, and no `[apps]` row, ever holds one. + +/// One authorization a row may declare. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Authorization { + /// Every disk's table, read: a listing and each disk's description. + Tables, + /// One designation of the machine's one DATA partition, bound to its + /// disk's state when the row starts. + DesignateData, +} + +impl Authorization { + pub const ALL: [Self; 2] = [Self::Tables, Self::DesignateData]; + + /// How a row spells it. + pub const fn word(self) -> &'static str { + match self { + Self::Tables => "tables", + Self::DesignateData => "designate:data", + } + } + + pub fn parse(word: &str) -> Option { + Self::ALL.into_iter().find(|a| a.word() == word) + } + + /// The name it has in the row's namespace. + pub const fn name(self) -> &'static str { + match self { + Self::Tables => "block-tables", + Self::DesignateData => "block-designate-data", + } + } + + /// Whether it changes a disk, rather than reading one. + pub const fn changes(self) -> bool { + matches!(self, Self::DesignateData) + } +} diff --git a/lib/manifest/src/lib.rs b/lib/manifest/src/lib.rs index d2ee73e9742..0f6d356e399 100644 --- a/lib/manifest/src/lib.rs +++ b/lib/manifest/src/lib.rs @@ -23,6 +23,7 @@ //! drive one process of it per PCI function the matcher names //! syscap a right on the SysCap dup the supervisor endows //! slots the idle slot's partitions and the slot table +//! authorize a disk authorization the supervisor mints at its start ([`authorize`]) //! service a system service: its `HOME` is `/state/`, not the session's //! state it keeps machine state of its own in `/state/` //! role a file server for ``: one process of it per role @@ -44,6 +45,7 @@ //! package is granted, [`consent`] how the person at the screen is asked, //! [`clipboard`] who watches the clipboard and [`display`] who sets the display. +pub mod authorize; pub mod clipboard; pub mod consent; pub mod device; @@ -316,6 +318,10 @@ pub struct Program { /// runs is never among them. One program holds it — `build/build.rs` gates /// which. pub slots: bool, + /// Disk authorizations the supervisor mints at its start, by + /// [`authorize::Authorization::word`]: the signed image's word alone, and + /// `build/build.rs` gates which rows. + pub authorize: Vec, /// A system service: its `HOME` is its own [`STATE`] directory rather /// than the session user's home, so what it keeps is machine state and no /// user's. It keeps state ([`Self::state_dir`]) whether or not @@ -486,6 +492,8 @@ pub enum RenderError { ServesWithoutService(String), /// A `roles` entry naming no file-server role. NoSuchRole { program: String, role: String }, + /// An `authorize` entry naming no [`authorize::Authorization`]. + NoSuchAuthorization { program: String, word: String }, /// A `devices` entry that names no device, or a `drives` entry that is /// no PCI matcher. NoSuchDevice { program: String, entry: String }, @@ -545,6 +553,12 @@ pub fn render(manifest: &Manifest) -> Result, RenderError> { if program.slots { out.push_str("slots\n"); } + for word in &program.authorize { + if authorize::Authorization::parse(word).is_none() { + return Err(RenderError::NoSuchAuthorization { program: program.name.clone(), word: word.clone() }); + } + out.push_str(&format!("authorize {word}\n")); + } if program.service { out.push_str("service\n"); } @@ -726,6 +740,7 @@ pub fn parse(text: &str) -> Manifest { "drive" => program.drives.push(rest.to_string()), "syscap" => program.syscap.push(rest.to_string()), "slots" if rest.is_empty() => program.slots = true, + "authorize" if authorize::Authorization::parse(rest).is_some() => program.authorize.push(rest.to_string()), "service" => program.service = true, "state" if rest.is_empty() => program.state = true, "role" => program.roles.push(rest.to_string()), @@ -773,6 +788,7 @@ mod tests { source: Source::Path("/system/bin/update".into()), slots: true, state: true, + authorize: vec!["tables".into(), "designate:data".into()], ..Program::default() }, Program { diff --git a/system/disks/Cargo.toml b/system/disks/Cargo.toml new file mode 100644 index 00000000000..97529e1cd65 --- /dev/null +++ b/system/disks/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "disks" +description = "disks [list | designate data [--commit]]: the machine's disks and their tables, and the designation of its DATA partition, through the block service under the authorizations its row is minted." +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" + +[dependencies] +toyos = { path = "../../sdk/toyos" } +toyos-blockring = { path = "../../lib/blockring" } +toyos-gpt = { path = "../../lib/gpt" } +toyos-manifest = { path = "../../lib/manifest" } +diskserver = { path = "../diskserver" } + +[package.metadata.toyos.host] +exempt.manages = "ToyOS's disks, through its block service's tables and designation badges" diff --git a/system/disks/src/main.rs b/system/disks/src/main.rs new file mode 100644 index 00000000000..a6d456a0a5d --- /dev/null +++ b/system/disks/src/main.rs @@ -0,0 +1,217 @@ +//! `/system/bin/disks`: the machine's disks, through the block service. +//! +//! - `disks` or `disks list`: every disk the block service serves, its size, +//! each copy of its table, and every partition of the copy in force. +//! - `disks designate data`: judge the machine's one DATA partition for +//! designation and write nothing; `--commit` writes the stamp, which the +//! DATA file server formats at its next start. +//! +//! **It holds no authority of its own.** Each command needs the +//! authorization its row is minted under its name +//! (`toyos_manifest::authorize`): `tables` to list, `designate:data` to +//! designate. A run without one is refused, whoever runs it and from where; +//! a login grants none. Exit status 0 where it was done, 1 where it was +//! refused or failed, 2 for a command line it does not take. + +use toyos_blockring::wire::{Designated, DiskHead, Lba0, SlotLine}; +use toyos_gpt::Guid; +use toyos_manifest::authorize::Authorization; + +const USAGE: &str = "usage: disks [list | designate data [--commit]]"; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let code = match args[..] { + [] | ["list"] => list(), + ["designate", "data"] => designate(false), + ["designate", "data", "--commit"] => designate(true), + _ => { + eprintln!("{USAGE}"); + 2 + } + }; + std::process::exit(code); +} + +/// The refusal of a run whose row was not minted `authorization`. +fn unauthorized(authorization: Authorization) -> i32 { + refuse(&format!( + "disks: this run holds no `{}` authorization, which only the signed image grants a row; nothing was done", + authorization.word() + )) +} + +fn list() -> i32 { + let Some(names) = toyos::endow::namespace() else { return unauthorized(Authorization::Tables) }; + let service = Authorization::Tables.name(); + let listed = match diskserver::list(names, service) { + Ok(listed) => listed, + Err(diskserver::Error::NotHeld) => return unauthorized(Authorization::Tables), + Err(why) => return refuse(&format!("disks: the block service would not list its partitions: {why:?}")), + }; + let mut disks: Vec = listed.iter().map(|l| l.disk).collect(); + disks.sort_unstable(); + disks.dedup(); + if disks.is_empty() { + println!("no disk is served"); + } + let mut code = 0; + for disk in disks { + match diskserver::tables::describe(names, service, disk) { + Ok((head, slots)) => print!("{}", describe(disk, &head, &slots)), + Err(why) => { + println!("disk {disk}: not described: {why:?}"); + code = 1; + } + } + } + code +} + +fn designate(commit: bool) -> i32 { + let Some(names) = toyos::endow::namespace() else { return unauthorized(Authorization::DesignateData) }; + match diskserver::tables::designate(names, Authorization::DesignateData.name(), commit) { + Err(diskserver::Error::NotHeld) => unauthorized(Authorization::DesignateData), + Ok(done) => { + println!("{}", designated(&done)); + i32::from(!matches!(done, Designated::Written | Designated::WouldWrite | Designated::Already)) + } + Err(why) => refuse(&format!("disks: the DATA partition was not designated: {why:?}")), + } +} + +fn refuse(why: &str) -> i32 { + eprintln!("{why}"); + 1 +} + +/// What a designation came to, as a line. +fn designated(done: &Designated) -> String { + match done { + Designated::Written => "the DATA partition is designated: the stamp is on the disk and read back; DATA is formatted at its next start".into(), + Designated::WouldWrite => "the DATA partition holds nothing recognised and would be designated; nothing was written (--commit writes)".into(), + Designated::Already => "the DATA partition is designated already; nothing was written".into(), + Designated::Volume => "the DATA partition holds a ToyOS volume; nothing was written".into(), + Designated::Foreign(what) => format!("the DATA partition carries {what}; nothing was written"), + } +} + +/// The name a partition type has here, or its GUID. +fn kind(guid: [u8; 16]) -> String { + let named = [ + (Guid::EFI_SYSTEM, "EFI system"), + (Guid::MICROSOFT_BASIC, "basic data"), + (Guid::TOYOS_DATA, "ToyOS DATA"), + (Guid::TOYOS_ROOT, "ToyOS ROOT"), + (Guid::TOYOS_BOOT, "ToyOS slot"), + (Guid::TOYOS_SLOTS, "ToyOS slot table"), + (Guid::TOYOS_SCRATCH, "ToyOS scratch"), + ]; + named.iter().find(|(g, _)| g.0 == guid).map_or_else(|| Guid(guid).to_string(), |(_, name)| (*name).to_string()) +} + +/// `bytes` in the largest binary unit that leaves a whole number above one. +fn size(bytes: u64) -> String { + let units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut at = 0; + let mut v = bytes; + while v >= 1024 * 10 && at + 1 < units.len() { + v /= 1024; + at += 1; + } + format!("{v} {}", units[at]) +} + +/// One disk as `disks list` prints it. +fn describe(disk: u32, head: &DiskHead, slots: &[SlotLine]) -> String { + let bytes = head.sectors * u64::from(head.lba_bytes); + let lba0 = match head.lba0 { + Lba0::Protective => "a GPT", + Lba0::Mbr => "an MBR that is not a GPT's", + Lba0::Blank => "no partition table", + Lba0::Unread => "a first sector that would not read", + }; + let copies = if head.agree { + "both copies agree".to_string() + } else { + format!("primary {}, backup {}", head.primary, head.backup) + }; + let mut out = format!( + "disk {disk}: {} ({} sectors of {} bytes), {lba0}, {copies}\n", + size(bytes), + head.sectors, + head.lba_bytes + ); + for s in slots { + let name = String::from_utf16_lossy(s.name.split(|u| *u == 0).next().unwrap_or_default()); + let sectors = s.last.saturating_sub(s.first).saturating_add(1); + let state = match (s.served, s.held) { + (true, true) => "in use", + (true, false) => "free", + (false, _) => "not served", + }; + out.push_str(&format!( + " {:>3} {:<16} {:>10} {} {:?} {state}\n", + s.index + 1, + kind(s.kind), + size(sectors * u64::from(head.lba_bytes)), + Guid(s.unique), + name, + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A disk prints its size, its table's state and each partition by its + /// one-based number, type, size, unique GUID, name and use; copies that + /// disagree say what each is. + #[test] + fn a_disk_is_described_one_line_a_partition() { + let mut name = [0u16; 36]; + for (u, c) in name.iter_mut().zip("ToyOS data".encode_utf16()) { + *u = c; + } + let head = DiskHead { + lba_bytes: 512, + sectors: 500_118_192, + lba0: Lba0::Protective, + agree: true, + primary: "valid".into(), + backup: "valid".into(), + }; + let data = SlotLine { + index: 3, + kind: Guid::TOYOS_DATA.0, + unique: [0x11; 16], + first: 2048, + last: 2048 + (170u64 << 21) - 1, + attributes: 0, + name, + held: false, + served: true, + }; + let text = describe(1, &head, &[data]); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[0], "disk 1: 238 GiB (500118192 sectors of 512 bytes), a GPT, both copies agree"); + assert_eq!( + lines[1].split_whitespace().collect::>(), + ["4", "ToyOS", "DATA", "170", "GiB", "11111111-1111-1111-1111-111111111111", "\"ToyOS", "data\"", "free"] + ); + assert_eq!(lines.len(), 2); + let torn = DiskHead { agree: false, backup: "Gpt(HeaderCrc { stored: 1, computed: 2 })".into(), ..head }; + assert!(describe(0, &torn, &[]).contains("primary valid, backup Gpt(HeaderCrc"), "{}", describe(0, &torn, &[])); + assert_eq!(size(4096), "4096 B"); + assert_eq!(size(10 << 30), "10 GiB"); + } + + #[test] + fn only_a_done_or_judged_designation_succeeds() { + assert!(designated(&Designated::Written).contains("read back")); + assert!(designated(&Designated::Foreign("an NTFS file system".into())).contains("carries an NTFS file system")); + } +} diff --git a/system/diskserver/src/main.rs b/system/diskserver/src/main.rs index 4b1926d737b..7500dad4302 100644 --- a/system/diskserver/src/main.rs +++ b/system/diskserver/src/main.rs @@ -16,9 +16,9 @@ //! session whose grant does not write is handed over as one, whose driver //! answers every write `ReadOnly`. A tables badge lists every partition and //! has a disk described by its driver, and opens nothing. A designation badge -//! does one thing: the driver of the one disk carrying its partition carries -//! out that designation, told whether the running system is on it -//! (`--active`). A connection with no badge reaches nothing. +//! does one thing: the machine's one DATA partition, resolved when it is +//! asked, is designated by the driver of its disk, told whether the running +//! system is on it (`--active`). A connection with no badge reaches nothing. //! //! **A driver is who the supervisor says it is**: the badge it minted the //! driver's `disk` connector with. It starts the disk drivers before this @@ -71,8 +71,10 @@ const MAX_DRIVERS: usize = 16; const DRIVER: &str = "--driver"; const UNDRIVEN: &str = "--undriven"; /// Argv: a partition the running system is on, by unique GUID, which no -/// designation changes. +/// designation changes; or that which they are is unknown, and every +/// partition is taken for one. const ACTIVE: &str = "--active"; +const ACTIVE_UNKNOWN: &str = "--active-unknown"; const TOKEN_ACCEPT: u64 = 0; const TOKEN_DRIVERS: u64 = 1; @@ -108,8 +110,9 @@ struct Known { undriven: Vec, fates: BTreeMap, offered: Vec, - /// The partitions the running system is on. - active: Vec<[u8; 16]>, + /// The partitions the running system is on, or `None` where the + /// supervisor could not say. + active: Option>, } impl Known { @@ -156,7 +159,7 @@ impl Known { /// every partition offered that it lists. **The badge is asked first**, /// so a connection with none learns nothing. fn listing(&self, badge: Option, expired: bool) -> Option, Refusal>> { - let Some(badge) = badge.filter(|b| !matches!(b, Badge::Designate { .. })) else { + let Some(badge) = badge.filter(|b| *b != Badge::DesignateData) else { return Some(Err(Refusal::NotGranted)); }; if !self.settled() && !expired { @@ -217,18 +220,26 @@ impl Known { Some(if self.offered.iter().any(|o| o.disk == disk) { Ok(disk) } else { Err(self.absent()) }) } - /// Where the designation `badge` names is carried out, and whether the - /// running system is on it; or its refusal, or `None` while it waits. A - /// partition two disks carry is refused on both, as an open of it is. + /// Where the designation `badge` asks for is carried out — the one DATA + /// partition offered, resolved now — and whether the running system is on + /// it; or its refusal, or `None` while it waits. Two DATA partitions are + /// never guessed between, and a GUID two disks carry is refused on both, + /// as an open of it is. fn designation(&self, badge: Option, expired: bool) -> Option> { - let Some(Badge::Designate { unique, .. }) = badge else { return Some(Err(Refusal::NotGranted)) }; + if badge != Some(Badge::DesignateData) { + return Some(Err(Refusal::NotGranted)); + } if !self.settled() && !expired { return None; } - let carrying: Vec<&Offered> = self.offered.iter().filter(|o| o.part.unique == unique && unique != [0; 16]).collect(); - Some(match carrying[..] { - [] => Err(self.absent()), - [one] if one.part.blocks.is_some() => Ok((*one, self.active.contains(&unique))), + let data: Vec<&Offered> = self.offered.iter().filter(|o| o.part.kind == toyos_gpt::Guid::TOYOS_DATA.0).collect(); + let Some(one) = data.first().filter(|_| data.len() == 1) else { + return Some(Err(if data.is_empty() { self.absent() } else { Refusal::Unusable })); + }; + let unique = one.part.unique; + let carrying = self.offered.iter().filter(|o| o.part.unique == unique).count(); + Some(match (carrying, one.part.blocks) { + (1, Some(_)) => Ok((**one, self.active.as_ref().is_none_or(|a| a.contains(&unique)))), _ => Err(Refusal::Unusable), }) } @@ -283,13 +294,22 @@ fn main() { let args: Vec = std::env::args().collect(); let mut known = Known::default(); let mut at = 1; + known.active = Some(Vec::new()); while at < args.len() { + if args[at] == ACTIVE_UNKNOWN { + println!("diskserver: which partitions the running system is on is unknown; every designation is refused"); + known.active = None; + at += 1; + continue; + } let value = args.get(at + 1).unwrap_or_else(|| panic!("diskserver: {} takes a value", args[at])); match args[at].as_str() { DRIVER => known.expected.push(value.parse().unwrap_or_else(|_| panic!("diskserver: {DRIVER} {value}"))), ACTIVE => { let guid = toyos_gpt::Guid::parse(value).unwrap_or_else(|| panic!("diskserver: {ACTIVE} {value}")); - known.active.push(guid.0); + if let Some(active) = &mut known.active { + active.push(guid.0); + } } UNDRIVEN => { println!("diskserver: {value} is on this machine and no driver of it started; what it carries is refused"); @@ -571,8 +591,8 @@ impl Service { refuse(&w.conn, why); } Some(Ok((offered, active))) => { - let Some(Badge::Designate { unique, token }) = w.badge else { unreachable!("admitted through its badge") }; - let order = |disk| disk::Designation { disk, unique, token, active, commit }.encode().to_vec(); + let unique = offered.part.unique; + let order = |disk| disk::Designation { disk, unique, active, commit }.encode().to_vec(); println!("diskserver: designation of {} handed to disk {}'s driver", guid_text(unique), offered.disk); self.hand_work(offered.disk, disk::MSG_DESIGNATE, order, w.conn); } @@ -685,7 +705,7 @@ mod tests { const ROOT: [u8; 16] = [4; 16]; const LOG_KIND: [u8; 16] = [0x10; 16]; const BOOT_KIND: [u8; 16] = [0x20; 16]; - const DATA_KIND: [u8; 16] = [0x30; 16]; + const DATA_KIND: [u8; 16] = toyos_gpt::Guid::TOYOS_DATA.0; const ROOT_KIND: [u8; 16] = [0x40; 16]; /// Two drivers, by the badges the supervisor minted them. const STICK: DriverBadge = 7; @@ -866,30 +886,44 @@ mod tests { } /// A description reaches a disk only through a tables badge, and names - /// one the service has; a designation reaches only the partition its own - /// badge names, on the one disk that carries it, said active when the - /// running system is on it, and nothing a partitions grant or a tables - /// badge carries designates anything. + /// one the service has. A designation reaches only the machine's one DATA + /// partition, resolved when it is asked, said active when the running + /// system is on it, and is refused when two DATA partitions or two disks + /// carrying its GUID leave it no one partition; nothing a partitions grant + /// or a tables badge carries designates anything. #[test] fn only_its_own_badge_describes_or_designates() { let mut known = two_disks(); - known.active.push(LOG); + known.active = Some(vec![LOG]); assert_eq!(known.describe(1, Some(Badge::Tables), false), Some(Ok(1))); assert_eq!(known.describe(7, Some(Badge::Tables), false), Some(Err(Refusal::NotFound))); - for other in [None, data().map(Badge::Partitions), Some(Badge::Designate { unique: DATA, token: [0; 16] })] { + for other in [None, data().map(Badge::Partitions), Some(Badge::DesignateData)] { assert_eq!(known.describe(1, other, false), Some(Err(Refusal::NotGranted)), "{other:?}"); } - let designate = |unique| Some(Badge::Designate { unique, token: [8; 16] }); + let designate = Some(Badge::DesignateData); let data_disk = Offered { disk: 1, part: part(DATA, DATA_KIND) }; - assert_eq!(known.designation(designate(DATA), false), Some(Ok((data_disk, false)))); - assert!(matches!(known.designation(designate(LOG), false), Some(Ok((_, true)))), "the running system's log"); - assert_eq!(known.designation(designate([9; 16]), false), Some(Err(Refusal::NotFound))); + assert_eq!(known.designation(designate, false), Some(Ok((data_disk, false)))); for other in [None, data().map(Badge::Partitions), Some(Badge::Tables)] { assert_eq!(known.designation(other, false), Some(Err(Refusal::NotGranted)), "{other:?}"); } - assert_eq!(known.listing(designate(DATA), false), Some(Err(Refusal::NotGranted)), "a designation lists nothing"); + assert_eq!(known.listing(designate, false), Some(Err(Refusal::NotGranted)), "a designation lists nothing"); assert_eq!(known.listing(Some(Badge::Tables), false).map(|r| r.map(|l| l.len())), Some(Ok(4))); - known.offered.push(Offered { disk: 0, part: part(DATA, DATA_KIND) }); - assert_eq!(known.designation(designate(DATA), false), Some(Err(Refusal::Unusable)), "on two disks"); + + known.active = Some(vec![DATA]); + assert_eq!(known.designation(designate, false), Some(Ok((data_disk, true))), "the running system's DATA"); + known.active = None; + assert_eq!(known.designation(designate, false), Some(Ok((data_disk, true))), "nothing known is active"); + + let mut cloned = two_disks(); + cloned.active = Some(Vec::new()); + cloned.offered.push(Offered { disk: 0, part: part([7; 16], LOG_KIND) }); + cloned.offered[0].part.unique = DATA; + assert_eq!(cloned.designation(designate, false), Some(Err(Refusal::Unusable)), "its GUID on two disks"); + let mut two = two_disks(); + two.offered.push(Offered { disk: 0, part: part([8; 16], DATA_KIND) }); + assert_eq!(two.designation(designate, false), Some(Err(Refusal::Unusable)), "two DATA partitions"); + let mut none = two_disks(); + none.offered.retain(|o| o.part.kind != DATA_KIND); + assert_eq!(none.designation(designate, false), Some(Err(Refusal::NotFound))); } } diff --git a/system/fileserver/src/main.rs b/system/fileserver/src/main.rs index 37e9022db56..2ed541c0f5a 100644 --- a/system/fileserver/src/main.rs +++ b/system/fileserver/src/main.rs @@ -345,7 +345,10 @@ fn data_on(disk: D, roots: &[&str]) -> Box { println!("fileserver: the DATA volume is ours and does not mount ({why}); it is absent this boot"); Box::new(Absent::new(roots, why)) } - Probed::Foreign => ram(roots, "the DATA partition is not ours"), + // A disk that is there and holds somebody's bytes is no machine without + // one: nothing stands in for it, and `disks designate data` is how it + // becomes ToyOS's. + Probed::Foreign => absent(roots, "the DATA partition holds no volume of ours and no designation stamp".into()), } } diff --git a/system/nvme/src/main.rs b/system/nvme/src/main.rs index 94e78365fab..b8a9c286fb4 100644 --- a/system/nvme/src/main.rs +++ b/system/nvme/src/main.rs @@ -134,7 +134,7 @@ fn main() { ctrl.sectors ); let read = toyos_blockserve::table::read(&mut Sectors { ctrl: &mut ctrl, block: vec![0u8; BLOCK_BYTES] }); - let mut disk = Sessions::new(name.clone(), read, toyos_blockserve::draw_token); + let mut disk = Sessions::new(name.clone(), read); link.offer(DISK, disk.offered()); link.settle(Settled::Offered); serve(&name, &mut ctrl, &mut disk, &mut link, stop.as_ref()) diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 09b12cb3d12..9300f4e8389 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -1163,8 +1163,9 @@ impl<'a> Supervisor<'a> { } /// What the block service is started with: each disk driver running, by - /// its badge, which it waits for, and every disk driver's function the - /// boot started none on. + /// its badge, which it waits for; every disk driver's function the boot + /// started none on; and every partition the loader named, which the + /// running system is on and no designation changes. fn block_args(&self) -> Vec { let mut args = Vec::new(); for instance in self.instances.iter().filter(|i| is_disk_driver(i.row)) { @@ -1173,6 +1174,21 @@ impl<'a> Supervisor<'a> { for undriven in &self.undriven { args.extend(["--undriven".to_string(), undriven.clone()]); } + match inventory(self.syscap) { + Ok(records) => { + let named = records.iter().filter_map(|r| match r { + Record::Loaded(l) => Some(l.unique_guid), + _ => None, + }); + for guid in named { + args.extend(["--active".to_string(), guid_text(guid)]); + } + } + Err(why) => { + say!("supervisor: the inventory was not read ({why}), so the block service designates nothing"); + args.push("--active-unknown".to_string()); + } + } args } @@ -3003,6 +3019,7 @@ fn start<'a>( } let mut view = grants.view(program, minting.session); view.extend(ports); + view.extend(grants.authorizations(program).map_err(StartError::Refused)?); view.extend(grants.netstack(program, minting.session)); if let Some(ns) = build_namespace(program, system, connectors, view, extras)? { let raw = ns.into_raw(); @@ -3370,6 +3387,32 @@ impl Grants<'_> { .map_err(|e| format!("no connector to the block service could be minted: {e:?}")) } + /// What `program`'s row is authorized for on the machine's disks + /// (`toyos_manifest::authorize`), each a connector to the block service + /// minted with that one authorization's badge, under its name. The block + /// service resolves and judges each when it is used, so nothing here + /// waits for a disk. + fn authorizations(&self, program: &Program) -> Result, String> { + use toyos_manifest::authorize::Authorization; + let mut minted = Vec::new(); + for word in &program.authorize { + let authorization = Authorization::parse(word).unwrap_or_else(|| panic!("supervisor: `{word}` is no authorization the build admits")); + let badge = match authorization { + Authorization::Tables => toyos_blockring::wire::Badge::Tables, + Authorization::DesignateData => toyos_blockring::wire::Badge::DesignateData, + }; + let Some(ports) = &self.block else { return Err(format!("`{word}` is authorized and no block service runs")) }; + let ports = ports.borrow(); + let Some((_, acceptor)) = ports.iter().find(|(name, _)| name == toyos_blockring::PORT) else { + return Err("the block service's port has closed for good".into()); + }; + let connector = acceptor.mint(&badge.encode()).map_err(|e| format!("no `{word}` connector could be minted: {e:?}"))?; + say!("supervisor: {} is authorized for `{word}`", program.name); + minted.push((authorization.name().to_string(), connector)); + } + Ok(minted) + } + /// The block service's `disk` port minted for the disk driver of the /// function that arrived as `arrival`. fn disk(&self, arrival: Arrival) -> Result { diff --git a/system/usbd/src/main.rs b/system/usbd/src/main.rs index cac392855b6..15f032883f7 100644 --- a/system/usbd/src/main.rs +++ b/system/usbd/src/main.rs @@ -270,7 +270,7 @@ fn serve( match event { Event::Arrived { disk, name: disk_name, table } => { let label = format!("{name} {disk_name}"); - let sessions = Sessions::new(label.clone(), table, toyos_blockserve::draw_token); + let sessions = Sessions::new(label.clone(), table); if let Some(link) = &mut link { link.offer(disk, sessions.offered()); } diff --git a/tests/common/hostdisk.rs b/tests/common/hostdisk.rs index 7e2386cc129..3001cb04d9e 100644 --- a/tests/common/hostdisk.rs +++ b/tests/common/hostdisk.rs @@ -56,7 +56,8 @@ fn said_by_nvme(line: &str) -> Option<&str> { /// it alone: a driver brought a controller up and offered its partitions, it /// opened at least one session and each on a ToyOS DATA partition, and the file server /// found that DATA neither ours nor designated, and so neither mounted nor -/// formatted it. Answers how many partitions the disks' tables named. +/// formatted it, and served DATA absent rather than from memory. Answers how +/// many partitions the disks' tables named. pub fn left_alone(log: &Serial) -> Result { log.must_say("diskserver: every disk driver has settled")?; let said: Vec<&str> = log.text().lines().filter_map(said_by_nvme).collect(); @@ -89,6 +90,8 @@ pub fn left_alone(log: &Serial) -> Result { } } log.must_say("fileserver: no volume of ours and no designation stamp; nothing will be written to this partition")?; + log.must_say("fileserver: the DATA partition holds no volume of ours and no designation stamp; DATA is absent this boot")?; + log.must_not_say("are in memory and will not survive a reboot")?; log.must_not_say("fileserver: mounted the DATA volume")?; log.must_not_say("designates this partition for ToyOS; formatting it")?; Ok(kinds.len()) From 40106e88e0ee9d7417973fc046168412cb97b0b1 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:10:40 +0100 Subject: [PATCH 06/14] tests: a bench's DATA designated from inside ToyOS, on QEMU and as the T14's row `tests/images/designatecase.toml` is the one image whose runner holds `designate:data`, so the authorization reaches no boot but the one that is for it, and its runner starts nothing a login could reach. - `the_data_partition_beside_the_stick_is_designated` (QEMU x86-64, `Profile::MetalBesideAHostDisk`): a judge-only `disks designate data` writes nothing, the committed one is written by the NVMe driver and read back, and after the stop the host reads the disk image: DATA begins with `bcachefs::designation` for its size and every other byte is as it was. The supervisor minting the authorization, the block service resolving and routing it and the driver's job are four processes and a claim no host build runs together. - `bench_data_designated` (named only, the T14): the same designation on the bench's own NVMe, and a second boot whose DATA file server formats what it stamped and serves it, never from memory. It retires the host stamp of #1079's `toyos-metal --designate-data`. A metal job is a binary name and its arguments: the runner's job list takes what its stdin path always took. Co-Authored-By: Claude Opus 5.5 --- build/image.rs | 1 - loader/src/rootimage.rs | 1 - tests/common/hostdisk.rs | 13 ++++ tests/guest/test-runner/src/main.rs | 14 +++-- tests/images/designatecase.toml | 49 +++++++++++++++ tests/metal/src/image.rs | 5 +- tests/toyos.rs | 96 +++++++++++++++++++++++++++++ 7 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 tests/images/designatecase.toml diff --git a/build/image.rs b/build/image.rs index ed39c589232..8cdd481d3a1 100644 --- a/build/image.rs +++ b/build/image.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; -use std::num::NonZeroU64; use std::path::Path; use bcachefs::engine::{self, Meta, NodeRef, Tree}; diff --git a/loader/src/rootimage.rs b/loader/src/rootimage.rs index e0dd8035a53..85fb5228ac2 100644 --- a/loader/src/rootimage.rs +++ b/loader/src/rootimage.rs @@ -16,7 +16,6 @@ use alloc::alloc::Layout; use alloc::string::String; -use core::num::NonZeroU64; use crate::payload::Payload; diff --git a/tests/common/hostdisk.rs b/tests/common/hostdisk.rs index 3001cb04d9e..c49993f3506 100644 --- a/tests/common/hostdisk.rs +++ b/tests/common/hostdisk.rs @@ -44,6 +44,19 @@ pub fn bytes() -> Vec { disk } +/// Where the disk's ToyOS DATA partition is, in bytes: its first and its +/// length. +pub fn data_span() -> (u64, u64) { + let mut at = EDGE; + for (_, kind, len) in PARTS { + if kind == Guid::TOYOS_DATA { + return (at, len); + } + at += len; + } + unreachable!("the disk carries a ToyOS DATA partition") +} + /// What the nvme driver of `bb:dd.f` said, past its name: `nvme 04:00.0: `. fn said_by_nvme(line: &str) -> Option<&str> { let (_, rest) = line.split_once("nvme ")?; diff --git a/tests/guest/test-runner/src/main.rs b/tests/guest/test-runner/src/main.rs index 4491020c2d7..c20111e3ec9 100644 --- a/tests/guest/test-runner/src/main.rs +++ b/tests/guest/test-runner/src/main.rs @@ -96,7 +96,8 @@ fn main() { let _ = io::stdout().flush(); // **A machine with no host on the other end runs the jobs its manifest - // names**, one binary name per argument through the stdin path's own [`run_one`]. + // names**, one job per argument — a binary name and its arguments — + // through the stdin path's own [`run_one`]. let mut jobs: Vec = std::env::args().skip(1).collect(); // The one argument that is not a job: what the whole list gets. let mut bound_ms = toyos_tco::JOB_BOUND_MS; @@ -108,13 +109,14 @@ fn main() { if !jobs.is_empty() { deadline(bound_ms); for job in &jobs { - // Fatal by name: nobody is reading this console, so a job that did not run must end the boot. - if job.split_whitespace().count() != 1 { - give_the_machine_back(&format!("job {job:?} is not one binary name")); - } + // A binary name and its arguments. Fatal by name: nobody is + // reading this console, so a job that did not run must end the boot. + let mut words = job.split_whitespace(); + let Some(name) = words.next() else { give_the_machine_back("an empty job") }; + let args: Vec<&str> = words.collect(); *RUNNING.lock().expect("the deadline thread does not panic holding this") = job.clone(); - match run_one(job, &[], cap.as_ref()) { + match run_one(name, &args, cap.as_ref()) { Ran::No => { give_the_machine_back(&format!("job {job:?} did not run")) } diff --git a/tests/images/designatecase.toml b/tests/images/designatecase.toml new file mode 100644 index 00000000000..80298bccd1f --- /dev/null +++ b/tests/images/designatecase.toml @@ -0,0 +1,49 @@ +# The boot that designates a bench's DATA partition from inside ToyOS: its +# runner, the one row authorized to (`authorize = ["designate:data"]`), runs +# `disks designate data --commit` once (`tests/toyos.rs`'s +# `bench_data_designated` row). Its own image, so the authorization reaches no +# boot but the one that is for it; no row here starts another, so no login +# holds it. +[boot] +start = ["logkeeper", "diskserver", "fileserver", "acpiserver", "test-runner"] + +[programs.logkeeper] +service = true +syscap = ["logread"] +serves = ["log"] + +[programs.acpiserver] +service = true +devices = ["acpi"] +receives = ["power"] + +# `power` for the reboot that ends the list. Its jobs inherit its namespace, +# the authorization among it. +[programs.test-runner] +receives = ["power"] +authorize = ["designate:data"] + +[programs.disks] + +[programs.toybox] + +[symlinks] +"bin/reboot" = "/system/bin/toybox" + +[programs.diskserver] +service = true +restart = true +serves = ["block", "disk"] + +[programs.nvme] +drives = ["pci:01:08:02"] +receives = ["disk"] + +[programs.usbd] +drives = ["pci:0c:03:30/1033:0194", "pci:0c:03:30@boot"] +receives = ["disk"] + +[programs.fileserver] +restart = true +roles = ["data", "log", "boot"] +receives = ["block"] diff --git a/tests/metal/src/image.rs b/tests/metal/src/image.rs index 04ce4992866..ef4c451e195 100644 --- a/tests/metal/src/image.rs +++ b/tests/metal/src/image.rs @@ -5,8 +5,8 @@ //! no serial port has nothing at the other end to type a job — so the //! stdin path every config in `tests/images/` but `jobcase.toml` leaves the runner on parks //! forever, and the loop refuses after `metal::return_secs`. What ends a boot -//! is `[programs.test-runner] args`: the list's bound, one binary name per -//! job, then `reboot`. +//! is `[programs.test-runner] args`: the list's bound, one job per argument +//! — a binary name and its arguments — then `reboot`. //! //! So a metal image is a committed boot config with that one field derived onto //! it. The derivation is here, pure, because what it changes about the config @@ -145,6 +145,7 @@ mod tests { "tests/images/metalcase.toml", "tests/images/latencycase.toml", "tests/images/acpicase.toml", + "tests/images/designatecase.toml", ]; fn derived(config: &str, jobs: &[&str]) -> String { diff --git a/tests/toyos.rs b/tests/toyos.rs index d5173fa98a1..33673c4899d 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -588,6 +588,14 @@ const MACHINE_TESTS: &[&str] = &[ // host test runs together, and the disk is read back whole on the host // after the stop. QEMU x86-64; on a bench it is `nvme_leaves_the_benchs_disk_alone`. "a_host_disk_beside_the_stick_is_left_alone", + // The same disk's DATA designated from inside the guest: the supervisor + // minting the designation image's one authorization, the block service + // resolving and routing it, and the NVMe driver judging, stamping, + // flushing and reading back are four processes and a claim no host build + // runs together; the host reads the disk image after the stop for the + // stamp and for every other byte. QEMU x86-64; on a bench it is + // `bench_data_designated`. + "the_data_partition_beside_the_stick_is_designated", // clang and LLD built for ToyOS, installed by `pkg` and compiling and // linking inside it: the claim is that they install and run on ToyOS, // which no host test can make, and the T14 would read nothing a guest does @@ -1413,6 +1421,17 @@ const METAL: &[(&str, metal::Metal)] = &[ "nvme_leaves_the_benchs_disk_alone", metal::Metal { arms: TESTCASES, judge: |b| common::hostdisk::left_alone(&b[0].log()).map(|_| ()) }, ), + ( + // The bench's DATA partition designated from inside ToyOS, and the + // next boot's DATA file server formatting it and mounting it: the + // driver of the NVMe disk judges the partition, writes the stamp, + // flushes and reads it back, under the one authorization the + // designation image grants its runner. Destructive and once per + // bench, so named only; the T14, whose p4 is the owner's empty + // ToyOS DATA. + "bench_data_designated", + metal::Metal { arms: DESIGNATE, judge: |b| bench_data_designated(&b[0].log(), &b[1].log()) }, + ), ( // The stick written through usbd while the boot deadline resets the // machine, which touches no USB register (#901 §6): the stick must @@ -1723,6 +1742,17 @@ const TESTCASES_HELD: &[metal::Arm] = &[metal::Arm { ..metal::once("testcases", "tests/images/testcases.toml", &[], &[]) }]; +/// Two boots of the designation image (`tests/images/designatecase.toml`): +/// the first designates the machine's DATA partition, the second only +/// reboots, which is where the file server formats what the first stamped. +const DESIGNATE: &[metal::Arm] = &[ + metal::Arm { + named_only: true, + ..metal::once("designate", "tests/images/designatecase.toml", &[], &["disks designate data --commit"]) + }, + metal::Arm { named_only: true, ..metal::once("designated", "tests/images/designatecase.toml", &[], &[]) }, +]; + /// Its own boot: `kernel_objects_refused` holds every frame a process may, so /// every other process's allocation is refused while it runs. const TESTCASES_REFUSED: &[metal::Arm] = @@ -4456,6 +4486,7 @@ fn run_machine_test(name: &str, test_config: &Path) -> Result<(), String> { "desktop_without_x2apic" => desktop_without_x2apic(), "nvme_disk_boots" => nvme_disk_boots(test_config), "a_host_disk_beside_the_stick_is_left_alone" => a_host_disk_beside_the_stick_is_left_alone(test_config), + "the_data_partition_beside_the_stick_is_designated" => the_data_partition_beside_the_stick_is_designated(), "hosted_clang_hello" => common::hostedclang::hosted_clang_hello(), "libc_children" => common::libcchildren::libc_children(), "stream_holders" => stream_holders(test_config), @@ -4675,6 +4706,71 @@ fn nvme_disk_boots(test_config: &Path) -> Result<(), String> { Ok(()) } +/// The first boot designated the machine's DATA partition, judged and +/// written by the NVMe driver and read back, through the runner's +/// authorization; the second formatted it and serves it, and neither served +/// DATA from memory. +fn bench_data_designated(first: &serial::Serial, second: &serial::Serial) -> Result<(), String> { + first.must_say("supervisor: test-runner is authorized for `designate:data`")?; + let done = first.must_say(": designation of ")?; + if !done.contains("nvme ") || !done.ends_with(": Written") { + return Err(format!("the designation was not written by an NVMe driver: {done:?}")); + } + first.must_say("the DATA partition is designated: the stamp is on the disk and read back")?; + first.must_not_say("are in memory and will not survive a reboot")?; + second.must_say("fileserver: block 0 designates this partition for ToyOS; formatting it")?; + let serving = second.must_say("fileserver: Data serving ")?; + if !serving.contains(" — bcachefs") { + return Err(format!("DATA is served by no bcachefs volume: {serving:?}")); + } + second.must_not_say("are in memory and will not survive a reboot")?; + second.must_not_say("DATA is absent this boot")?; + Ok(()) +} + +/// A boot of the designation image beside [`common::hostdisk`]'s disk: a +/// judge-only `disks designate data` writes nothing and says it would, the +/// committed one is written by the NVMe driver and read back, and once the +/// machine has stopped the disk's DATA partition begins with the stamp for its +/// size and every other byte of the disk is as it was. +fn the_data_partition_beside_the_stick_is_designated() -> Result<(), String> { + let config = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/images/designatecase.toml"); + let options = BootOptions { profile: qemu::Profile::MetalBesideAHostDisk, qmp: true, ..Default::default() }; + let mut qemu = QemuInstance::boot_with_options(&config, &[], &[], options); + let mut console = qemu.boot_log().to_string(); + qemu::await_marker(&mut qemu, &mut console, "fileserver: Data serving", "DATA's file server to serve")?; + let judged = qemu.run_test("disks designate data", Duration::from_secs(60)); + if judged.exit_code != Some(0) || !judged.stdout.contains("would be designated; nothing was written") { + return Err(format!("the judge-only designation: {:?}\n{}", judged.exit_code, judged.serial)); + } + let done = qemu.run_test("disks designate data --commit", Duration::from_secs(60)); + if done.exit_code != Some(0) || !done.stdout.contains("the stamp is on the disk and read back") { + return Err(format!("the designation: {:?}\n{}", done.exit_code, done.serial)); + } + let written = serial::Serial::named("the designation", done.serial.clone()); + let line = written.must_say(": designation of ")?; + if !line.contains("nvme ") || !line.ends_with(": Written") { + return Err(format!("the designation was not written by the NVMe driver: {line:?}")); + } + qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET)).power_button(); + let by = qemu.budget(Duration::from_secs(60)); + console.push_str(&qemu.await_exit(by)?); + let log = serial::Serial::named("the boot", console); + log.must_say("supervisor: test-runner is authorized for `designate:data`")?; + log.must_say("fileserver: the DATA partition holds no volume of ours and no designation stamp; DATA is absent this boot")?; + + let after = std::fs::read(qemu.nvme_image()).map_err(|e| e.to_string())?; + let mut want = common::hostdisk::bytes(); + let (at, len) = common::hostdisk::data_span(); + let at = at as usize; + want[at..at + 4096].copy_from_slice(&bcachefs::designation(len / 4096)); + match want.iter().zip(&after).position(|(w, a)| w != a) { + None if want.len() == after.len() => Ok(()), + None => Err(format!("the disk was {} bytes and is {}", want.len(), after.len())), + Some(byte) => Err(format!("byte {byte} (sector {}) is not what the designation alone leaves", byte / 512)), + } +} + /// `hostdisk::left_alone` of a stick boot beside [`common::hostdisk`]'s disk, /// whose four partitions the driver of QEMU's controller under Intel's id /// named, and every byte of the disk as it was once the machine has stopped. From 3757d0da12a3c996087641114b20088cb3f716cf Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:12:56 +0100 Subject: [PATCH 07/14] tests: the designation judges read the NVMe driver's own line, not the block service's hand-over Co-Authored-By: Claude Opus 5.5 --- tests/toyos.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/toyos.rs b/tests/toyos.rs index 33673c4899d..089625d6c1d 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -4706,16 +4706,23 @@ fn nvme_disk_boots(test_config: &Path) -> Result<(), String> { Ok(()) } +/// The NVMe driver's own account of the designation, which must be that it +/// wrote it: the line, or why there is none. +fn nvme_wrote_the_designation(text: &str) -> Result<&str, String> { + let said: Vec<&str> = text.lines().filter(|l| l.contains("nvme ") && l.contains(": designation of ")).collect(); + match said[..] { + [one] if one.ends_with(": Written") => Ok(one), + _ => Err(format!("the NVMe driver did not say it wrote one designation: {said:?}")), + } +} + /// The first boot designated the machine's DATA partition, judged and /// written by the NVMe driver and read back, through the runner's /// authorization; the second formatted it and serves it, and neither served /// DATA from memory. fn bench_data_designated(first: &serial::Serial, second: &serial::Serial) -> Result<(), String> { first.must_say("supervisor: test-runner is authorized for `designate:data`")?; - let done = first.must_say(": designation of ")?; - if !done.contains("nvme ") || !done.ends_with(": Written") { - return Err(format!("the designation was not written by an NVMe driver: {done:?}")); - } + nvme_wrote_the_designation(first.text())?; first.must_say("the DATA partition is designated: the stamp is on the disk and read back")?; first.must_not_say("are in memory and will not survive a reboot")?; second.must_say("fileserver: block 0 designates this partition for ToyOS; formatting it")?; @@ -4747,11 +4754,7 @@ fn the_data_partition_beside_the_stick_is_designated() -> Result<(), String> { if done.exit_code != Some(0) || !done.stdout.contains("the stamp is on the disk and read back") { return Err(format!("the designation: {:?}\n{}", done.exit_code, done.serial)); } - let written = serial::Serial::named("the designation", done.serial.clone()); - let line = written.must_say(": designation of ")?; - if !line.contains("nvme ") || !line.ends_with(": Written") { - return Err(format!("the designation was not written by the NVMe driver: {line:?}")); - } + eprintln!(" [designate] {}", nvme_wrote_the_designation(&done.serial)?); qemu::QmpShutdown::open(qemu.qmp_socket(), qemu.budget(qemu::GUEST_QUIET)).power_button(); let by = qemu.budget(Duration::from_secs(60)); console.push_str(&qemu.await_exit(by)?); From a72018eb9bae009606549d1c902a9b4404e7581a Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:13:28 +0100 Subject: [PATCH 08/14] blockserve: the designation's tests compare blocks without printing them Co-Authored-By: Claude Opus 5.5 --- lib/blockserve/src/designate.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/blockserve/src/designate.rs b/lib/blockserve/src/designate.rs index 9e82ca6d1d4..ead324504c2 100644 --- a/lib/blockserve/src/designate.rs +++ b/lib/blockserve/src/designate.rs @@ -229,7 +229,7 @@ mod tests { let mut disk = Disk::new(1000, |b| (b % 251) as u8 | 1); let mut job = Designation::new(1000, true); assert_eq!(disk.run(&mut job, &mut memory()), Ok(Designated::Written)); - assert_eq!(disk.blocks[0], bcachefs::designation(1000), "the stamp is on the medium, not in the cache"); + assert!(disk.blocks[0] == bcachefs::designation(1000), "the stamp is on the medium, not in the cache"); assert!(disk.blocks[1..].iter().enumerate().all(|(i, b)| b[0] == ((i + 1) % 251) as u8 | 1)); let reads: u64 = disk .commands @@ -310,6 +310,6 @@ mod tests { assert_eq!(job.next(&mut mem, false), Ask::Wait); let mut disk = Disk { cache: false, ..Disk::new(10, |_| 9) }; assert_eq!(disk.run(&mut job, &mut mem), Ok(Designated::Written)); - assert_eq!(disk.blocks[0], bcachefs::designation(10)); + assert!(disk.blocks[0] == bcachefs::designation(10), "the stamp is on the medium"); } } From 33617eeff255e3a547188f829e08411075d6b9a0 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:24:29 +0100 Subject: [PATCH 09/14] the designation image is a covered config, the gpt test clones nothing it drops, and the table module says what is true `every_shipped_boot_config_is_covered` holds `ALL_CONFIGS` to the tree, so `tests/images/designatecase.toml` is in it and every config gate reads it. The driver changes no table yet, so a table is still served for the disk's life. Co-Authored-By: Claude Opus 5.5 --- build/build.rs | 3 ++- lib/blockserve/src/table.rs | 5 ++--- lib/gpt/tests/edit.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build/build.rs b/build/build.rs index 301a7a48a17..a1477bb5726 100644 --- a/build/build.rs +++ b/build/build.rs @@ -3242,7 +3242,8 @@ mod tests { "tests/images/acpicase.toml", "tests/images/benchcase.toml", "tests/images/clipboardcase.toml", - "tests/images/drivescase.toml", + "tests/images/designatecase.toml", + "tests/images/drivescase.toml", "tests/images/hostedclangcase.toml", "tests/images/jobcase.toml", "tests/images/latencycase.toml", diff --git a/lib/blockserve/src/table.rs b/lib/blockserve/src/table.rs index f67775cff46..3557df64067 100644 --- a/lib/blockserve/src/table.rs +++ b/lib/blockserve/src/table.rs @@ -3,9 +3,8 @@ //! //! Every partition lies outside every other and the table outside all of them, //! so no session can change it: the table a driver read is the one it serves -//! until the driver itself changes the disk. A partition whose range is not -//! whole 4 KiB blocks, or that its own table refuses, is offered and refused -//! by name. +//! for the disk's life. A partition whose range is not whole 4 KiB blocks, or +//! that its own table refuses, is offered and refused by name. use toyos_blockring::BLOCK_BYTES; use toyos_gpt::edit::Copies; diff --git a/lib/gpt/tests/edit.rs b/lib/gpt/tests/edit.rs index a6228d94822..fdeb5c689e2 100644 --- a/lib/gpt/tests/edit.rs +++ b/lib/gpt/tests/edit.rs @@ -255,7 +255,7 @@ fn each_copy_is_read_and_reported_alone() { } // A second valid table in the backup's place alone. - let mut changed = t.clone(); + let mut changed = t; changed.slots.iter_mut().find(|s| s.is_used()).unwrap().name = Slot::named("renamed").unwrap(); let mut differing = copy(&whole); let steps = owned(&changed, &g, false); From fe22e03c6fabbc6c784babb28c07080d93985ed8 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:26:26 +0100 Subject: [PATCH 10/14] usbd: a stick's backup table past its last whole block is read The stick a bench boots carries 7507812 sectors, four past its last whole 4 KiB block, which is where UEFI puts its backup header. With the primary header torn, the table comes from there; with the count floored back to whole blocks, as it was, it is not read at all. Co-Authored-By: Claude Opus 5.5 --- system/usbd/xhci/src/tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/system/usbd/xhci/src/tests.rs b/system/usbd/xhci/src/tests.rs index f7f8c3d4f46..52a93e80f5b 100644 --- a/system/usbd/xhci/src/tests.rs +++ b/system/usbd/xhci/src/tests.rs @@ -493,6 +493,32 @@ fn a_stick_is_offered_once_its_table_is_read_and_the_bus_settles_only_then() { assert_eq!(arrived, Some((0, Some((2048, 8192))))); } +/// A stick whose sector count is not whole 4 KiB blocks keeps its backup +/// table on its true last sector (UEFI 2.10 §5.3.1), past the last whole +/// block: with the primary header torn, the table is still read, from there. +#[test] +fn a_stick_s_backup_past_its_last_whole_block_is_read() { + let sectors = 16384 + 4; + let mut bytes = gpt_disk(sectors, UNIQUE, 2048, 8192); + bytes[512 + 40] ^= 1; + let stick = Stick::new(bytes, "SERIAL1"); + let w = World::new(vec![(FakePort::occupied(QUICK), Some(Box::new(stick)))], 1); + let usb = w.start(BOTH); + let mut tasks = usb.tasks(); + let _end = End(&usb); + let mut owner = Mock::default(); + let mut arrived = None; + for step in 1..=60 { + w.run(&usb, &mut tasks, &mut owner, step * 10 * MS).unwrap(); + for event in usb.disks.take_events() { + if let Event::Arrived { disk, table, .. } = event { + arrived = Some((disk, table)); + } + } + } + assert_eq!(arrived, Some((0, Some((2048, 8192)))), "the backup on sector {} was not read", sectors - 1); +} + /// A session's read and write move straight between the stick and the /// client's lent region, and a flush is SYNCHRONIZE CACHE. #[test] From 1e670670fe5d5c4f81f0b4e3de5fb431b6e30f06 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 10:37:12 +0100 Subject: [PATCH 11/14] an image's GUIDs are drawn by `image::random_guid`, and a metal job carries its arguments Two references the writer's move and the job-list change left stale. Co-Authored-By: Claude Opus 5.5 --- ...n-images-guids-are-drawn-from-the-hosts-random-source.md | 2 +- tests/common/metal.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/an-images-guids-are-drawn-from-the-hosts-random-source.md b/issues/an-images-guids-are-drawn-from-the-hosts-random-source.md index a73b62462b9..cd75748385a 100644 --- a/issues/an-images-guids-are-drawn-from-the-hosts-random-source.md +++ b/issues/an-images-guids-are-drawn-from-the-hosts-random-source.md @@ -8,7 +8,7 @@ opened: 2026-10-12 Two builds of one plan write different images, so ToyOS rebuilding itself cannot reproduce the host's bytes. Every GUID an image carries comes from -`gptwrite::random_guid`, the operating system's random source, drawn again on +`image::random_guid`, the operating system's random source, drawn again on every `create_boot_image` call: it draws the log partition's, the ESP's, the slot table's and each slot's boot and ROOT GUIDs, and writes the log's again into the ESP's `/toyos/log.guid` and each slot's into the slot table; `lay` diff --git a/tests/common/metal.rs b/tests/common/metal.rs index 5cf6c34fed0..a975d929519 100644 --- a/tests/common/metal.rs +++ b/tests/common/metal.rs @@ -44,9 +44,9 @@ pub struct Arm { /// What the image is armed with. Every name here is judged by the /// pre-flash gate (`toyos_metal::FLASHABLE`) before it is written. pub params: &'static [&'static str], - /// Binary names and runner builtins this test needs in the job list, in the - /// order it needs them. `reboot` is appended to every list by the - /// derivation and is never written here. + /// The jobs this test needs in the job list, in the order it needs them: + /// each a binary name or runner builtin and its arguments. `reboot` is + /// appended to every list by the derivation and is never written here. pub jobs: &'static [&'static str], /// The kernel build this boot needs, empty for the one an image ships. /// From 0343c0aa8477379c45017078effea902470f9e11 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 11:12:30 +0100 Subject: [PATCH 12/14] review r1: no login authority holds a disk change, the comments say what is true, and no unreachable disk state - The build refuses an authorization that changes a disk to a login authority too, not only to a row a login can start: a login authority's namespace reaches every program a login runs directly. Its test reds without the check. - `Lba0` and the head's LBA-0 word go: no disk without a served table is described today, so no consumer reaches them. They come with `Init`. - Comments corrected: an authorization is resolved each time it is used and lasts the row's life; the stamp authorises a file server's format; the two-copy write is whole only on a device that keeps sectors whole and honours its flush; `tables` is `disks`'s alone; the signature table names documents where they exist; `toyos-gpt` is the writer too, and is no kernel dependency. `random_guid` and `write_at` each have their own doc. - The link's frame is sized for every frame the service sends. - `bench_data_designated` runs on the AMD bench too, and on the T14 refuses a stick whose last sector would not read. - `issues/a-first-format-interrupted-mid-flush-can-leave-a-superblock-over-a-bitmap-it-never-wrote.md`: the cache writes block 0 first, so a format is not superblock-last on the medium. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- build/build.rs | 15 ++++++--- build/image.rs | 4 +-- ...superblock-over-a-bitmap-it-never-wrote.md | 29 ++++++++++++++++ lib/bcachefs/src/superblock.rs | 4 +-- lib/blockring/src/wire.rs | 33 ++----------------- lib/blockserve/src/content.rs | 4 +-- lib/blockserve/src/link.rs | 9 ++++- lib/blockserve/src/sessions.rs | 8 +---- lib/gpt/Cargo.toml | 8 ++--- lib/gpt/src/edit.rs | 10 +++--- lib/gpt/src/lib.rs | 3 ++ lib/manifest/src/authorize.rs | 12 ++++--- loader/src/rootimage.rs | 1 - system/disks/src/main.rs | 15 +++------ system/diskserver/src/tables.rs | 6 ++-- system/fileserver/src/main.rs | 4 +-- tests/toyos.rs | 13 ++++++-- 18 files changed, 98 insertions(+), 82 deletions(-) create mode 100644 issues/a-first-format-interrupted-mid-flush-can-leave-a-superblock-over-a-bitmap-it-never-wrote.md diff --git a/Cargo.toml b/Cargo.toml index bb84daf99c6..7026709d242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ members = [ "system/compositor/desktop", "system/compositor/virtio-gpu", "system/console", + "system/disks", "system/diskserver", "system/filepicker", "system/fileserver", @@ -102,7 +103,6 @@ members = [ "system/host", "system/inspect", "system/logkeeper", - "system/disks", "system/logout", "system/netstack", "system/netstack/dhcp", diff --git a/build/build.rs b/build/build.rs index a1477bb5726..5b25b5052cd 100644 --- a/build/build.rs +++ b/build/build.rs @@ -719,9 +719,10 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { if program.slots && config.boot.start.contains(name) { return Err(format!("`{name}` asks for `slots` and is in `[boot] start`, and the slots are granted to a launch alone")); } - // A row any login could start would hand every login what it holds: - // an authorization that changes a disk is the image's to grant, to a - // row only the boot starts. + // A row any login could start would hand every login what it holds, + // and so would a login authority, whose namespace reaches every + // program a login runs directly: an authorization that changes a + // disk is the image's to grant, to a row that is neither. for word in &program.authorize { let Some(authorization) = toyos_manifest::authorize::Authorization::parse(word) else { return Err(format!("`{name}` asks to be authorized for `{word}`, which is no disk authorization")); @@ -730,6 +731,9 @@ fn held_by_their_holders_alone(config: &SystemConfig) -> Result<(), String> { if authorization.changes() && !starters.is_empty() { return Err(format!("`{name}` is authorized for `{word}`, which changes a disk, and {starters:?} may start it")); } + if authorization.changes() && program.login { + return Err(format!("`{name}` is authorized for `{word}`, which changes a disk, and is a login authority")); + } } } for (port, holders) in HELD_ALONE { @@ -3006,6 +3010,9 @@ mod tests { assert!(gate("[programs.disks]\nauthorize = [\"tables\"]\n[programs.shell]\nstarts = [\"disks\"]\n").is_ok()); let launched = gate("[programs.disks]\nauthorize = [\"designate:data\"]\n[programs.shell]\nstarts = [\"disks\"]\n"); assert!(launched.unwrap_err().contains("may start it")); + let login = gate("[boot]\nstart = [\"sshserver\"]\n[programs.sshserver]\nlogin = true\nauthorize = [\"designate:data\"]\n"); + assert!(login.unwrap_err().contains("is a login authority")); + assert!(gate("[programs.sshserver]\nlogin = true\nauthorize = [\"tables\"]\n").is_ok(), "reading the tables is no change"); assert!(gate("[programs.disks]\nauthorize = [\"format:everything\"]\n").unwrap_err().contains("no disk authorization")); } @@ -3243,7 +3250,7 @@ mod tests { "tests/images/benchcase.toml", "tests/images/clipboardcase.toml", "tests/images/designatecase.toml", - "tests/images/drivescase.toml", + "tests/images/drivescase.toml", "tests/images/hostedclangcase.toml", "tests/images/jobcase.toml", "tests/images/latencycase.toml", diff --git a/build/image.rs b/build/image.rs index 8cdd481d3a1..19e6a2fdac3 100644 --- a/build/image.rs +++ b/build/image.rs @@ -669,7 +669,6 @@ fn data_row(guid: Guid, len: u64) -> Row { Row { what: "DATA partition".into(), name: "ToyOS data".into(), kind: Guid::TOYOS_DATA, guid, len } } -/// Write `bytes` at byte `at` of `disk`; `what` they are is what a refusal names. /// A version 4 GUID from the host's random source. pub fn random_guid() -> Guid { let mut bytes = [0u8; 16]; @@ -692,6 +691,7 @@ pub fn write_gpt(disk: &mut (impl Write + Seek), total: u64, disk_guid: Guid, sl } } +/// Write `bytes` at byte `at` of `disk`; `what` they are is what a refusal names. fn write_at(disk: &mut (impl Write + Seek), at: u64, bytes: &[u8], what: &str) { disk.seek(SeekFrom::Start(at)) .and_then(|_| disk.write_all(bytes)) @@ -767,7 +767,6 @@ impl toyos_gpt::Sectors for FileSectors<'_> { self.0.metadata().map(|m| m.len()).unwrap_or(0) / u64::from(LBA) } - fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { self.0.seek(SeekFrom::Start(lba * u64::from(LBA))).is_ok() && self.0.read_exact(buf).is_ok() } @@ -877,7 +876,6 @@ impl toyos_gpt::Sectors for ImageSectors<'_> { self.0.len() as u64 / u64::from(LBA) } - fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { let at = lba as usize * LBA as usize; let Some(block) = self.0.get(at..at + buf.len()) else { return false }; diff --git a/issues/a-first-format-interrupted-mid-flush-can-leave-a-superblock-over-a-bitmap-it-never-wrote.md b/issues/a-first-format-interrupted-mid-flush-can-leave-a-superblock-over-a-bitmap-it-never-wrote.md new file mode 100644 index 00000000000..c4c28e4e1c9 --- /dev/null +++ b/issues/a-first-format-interrupted-mid-flush-can-leave-a-superblock-over-a-bitmap-it-never-wrote.md @@ -0,0 +1,29 @@ +--- +status: open +kind: defect +opened: 2026-10-28 +--- + +# A first format interrupted mid-flush can leave a superblock over a bitmap it never wrote + +`bcachefs::Formatted::format` writes the allocation bitmap, the root node and +then the superblock, but through the DATA file server's cache +(`system/fileserver/src/cache.rs`), which writes every dirty block at a +flush lowest first and in runs that may be in flight together, unordered. +Block 0 is the lowest, so the superblock can reach the medium before the +bitmap does. A machine that stops between the two has, in place of the +designation stamp, a superblock whose bitmap blocks hold whatever the +partition held before — and `BitmapAllocator::open` reads them as the +allocation state. The designation a bench runs from inside ToyOS +(`bench_data_designated`) formats 170 GiB on its next boot, about 1,361 +bitmap blocks, so the window is that one flush. + +Found by inspection in the review of the partitions work; no boot has +shown it. + +**Owner:** the DATA file server and the bcachefs crate. + +**Exit condition:** a format reaches the medium superblock-last — every +other block written and made durable before block 0 is written — and a host +test cuts the format's writes at every point against a stamped partition and +finds either the stamp or a whole volume at block 0. diff --git a/lib/bcachefs/src/superblock.rs b/lib/bcachefs/src/superblock.rs index 80add52ea03..8b3209943a1 100644 --- a/lib/bcachefs/src/superblock.rs +++ b/lib/bcachefs/src/superblock.rs @@ -7,8 +7,8 @@ use crate::fs::FsError; pub const MAGIC: [u8; 4] = *b"BCFS"; pub const VERSION: u32 = 2; -/// The designation stamp: the *only* thing that authorises ToyOS to destroy -/// what is on a partition. +/// The designation stamp: the only thing that authorises a file server to +/// format a partition that holds no volume of this format. /// /// Not a bcachefs concept, and it lives here anyway, because it occupies the /// same block as the superblock and the one invariant that matters is that diff --git a/lib/blockring/src/wire.rs b/lib/blockring/src/wire.rs index 6768acb6dd2..85eca5ab47b 100644 --- a/lib/blockring/src/wire.rs +++ b/lib/blockring/src/wire.rs @@ -51,8 +51,8 @@ pub const MSG_SLOT: u32 = 8; /// The description is whole. No payload. pub const MSG_DESCRIBED: u32 = 9; /// Designate the machine's DATA partition, for a [`Badge::DesignateData`] -/// connection: payload one byte, 1 to write the stamp and 0 to judge only. Answered [`MSG_DESIGNATED`] or -/// [`MSG_REFUSED`]. +/// connection: payload one byte, 1 to write the stamp and 0 to judge only. +/// Answered [`MSG_DESIGNATED`] or [`MSG_REFUSED`]. pub const MSG_DESIGNATE: u32 = 10; /// A [`Designated`]. pub const MSG_DESIGNATED: u32 = 11; @@ -346,18 +346,6 @@ impl Badge { } } -/// What LBA 0 of a disk holds, as [`DiskHead`] says it. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Lba0 { - Protective, - /// An MBR that is not a GPT's protective one. - Mbr, - /// No boot signature. - Blank, - /// It would not read. - Unread, -} - /// The longest copy state a [`DiskHead`] carries. pub const MAX_STATE: usize = 200; @@ -367,7 +355,6 @@ pub const MAX_STATE: usize = 200; pub struct DiskHead { pub lba_bytes: u32, pub sectors: u64, - pub lba0: Lba0, /// Both copies valid and the same table. pub agree: bool, pub primary: String, @@ -379,12 +366,6 @@ impl DiskHead { let mut out = Vec::with_capacity(18 + self.primary.len() + self.backup.len() + 4); out.extend_from_slice(&self.lba_bytes.to_le_bytes()); out.extend_from_slice(&self.sectors.to_le_bytes()); - out.push(match self.lba0 { - Lba0::Protective => 0, - Lba0::Mbr => 1, - Lba0::Blank => 2, - Lba0::Unread => 3, - }); out.push(u8::from(self.agree)); for state in [&self.primary, &self.backup] { let state = &state.as_bytes()[..floor_char(state, MAX_STATE)]; @@ -397,14 +378,7 @@ impl DiskHead { pub fn decode(bytes: &[u8]) -> Option { let (lba_bytes, rest) = bytes.split_first_chunk::<4>()?; let (sectors, rest) = rest.split_first_chunk::<8>()?; - let (&[lba0, agree], rest) = rest.split_first_chunk::<2>()?; - let lba0 = match lba0 { - 0 => Lba0::Protective, - 1 => Lba0::Mbr, - 2 => Lba0::Blank, - 3 => Lba0::Unread, - _ => return None, - }; + let (&[agree], rest) = rest.split_first_chunk::<1>()?; let agree = match agree { 0 => false, 1 => true, @@ -415,7 +389,6 @@ impl DiskHead { rest.is_empty().then(|| Self { lba_bytes: u32::from_le_bytes(*lba_bytes), sectors: u64::from_le_bytes(*sectors), - lba0, agree, primary, backup, diff --git a/lib/blockserve/src/content.rs b/lib/blockserve/src/content.rs index b32094356aa..0e3684aec71 100644 --- a/lib/blockserve/src/content.rs +++ b/lib/blockserve/src/content.rs @@ -4,8 +4,8 @@ //! **A bug fence, not a licence**: a partition is designated because its //! authorization says so, and a signature recognised here refuses it, since //! a volume somebody can name is the likeliest mistake. Finding none proves -//! nothing about what the partition holds. Each offset is the format's own, -//! from the document named beside it. +//! nothing about what the partition holds. Each offset is the format's own; +//! where a published document defines it, it is named beside it. /// How much of a partition [`judge`] reads: every signature below lies in it. pub const READ_BYTES: usize = 1 << 20; diff --git a/lib/blockserve/src/link.rs b/lib/blockserve/src/link.rs index a6a5731cd1f..e7a698d9201 100644 --- a/lib/blockserve/src/link.rs +++ b/lib/blockserve/src/link.rs @@ -27,6 +27,13 @@ pub enum Handed { Designate { order: disk::Designation, conn: Connection }, } +/// The longest frame the service sends: a session, a designation, or a disk's number. +const FRAME: usize = max(max(disk::Session::BYTES, disk::Designation::BYTES), 4); + +const fn max(a: usize, b: usize) -> usize { + if a > b { a } else { b } +} + /// Fresh connections in a row that may refuse what the driver says before it /// gives up: a frame refused on an empty connection is not backpressure. const CONNECTS: u32 = 3; @@ -35,7 +42,7 @@ pub struct Link { label: String, /// `None` once the service's port has closed for good. conn: Option, - rx: FrameRx<{ disk::Designation::BYTES }>, + rx: FrameRx<{ FRAME }>, offers: BTreeMap>, settled: Option, } diff --git a/lib/blockserve/src/sessions.rs b/lib/blockserve/src/sessions.rs index fc6419dfeac..f8eb4ecde97 100644 --- a/lib/blockserve/src/sessions.rs +++ b/lib/blockserve/src/sessions.rs @@ -30,7 +30,7 @@ use toyos_blockring::entry::{Completion, Op}; use toyos_blockring::layout::{ServerRings, DEPTH}; use toyos_blockring::server::{ServerSession, Taken}; use toyos_blockring::disk::Designation; -use toyos_blockring::wire::{self, Designated, DiskHead, Lba0, Opened, Refusal, SlotLine}; +use toyos_blockring::wire::{self, Designated, DiskHead, Opened, Refusal, SlotLine}; use toyos_blockring::Status; use crate::designate::{self, Ask}; @@ -409,12 +409,6 @@ impl Sessions { let head = DiskHead { lba_bytes: self.read.lba_bytes, sectors: self.read.sectors, - lba0: match copies.map(|c| c.mbr) { - None => Lba0::Unread, - Some(toyos_gpt::edit::Mbr::Protective) => Lba0::Protective, - Some(toyos_gpt::edit::Mbr::Other) => Lba0::Mbr, - Some(toyos_gpt::edit::Mbr::Blank) => Lba0::Blank, - }, agree: copies.is_some_and(|c| c.agree()), primary: copies.map_or_else(|| "unread".into(), |c| table::state(&c.primary)), backup: copies.map_or_else(|| "unread".into(), |c| table::state(&c.backup)), diff --git a/lib/gpt/Cargo.toml b/lib/gpt/Cargo.toml index 45ff37c3c48..f19d2150285 100644 --- a/lib/gpt/Cargo.toml +++ b/lib/gpt/Cargo.toml @@ -1,16 +1,16 @@ -# A member of the host workspace (root `Cargo.toml`): the -# kernel depends on it by path and its tests run on the host, with no +# A member of the host workspace (root `Cargo.toml`): the loader and the +# disk drivers depend on it by path and its tests run on the host, with no # target-triple juggling. A GPT is bytes somebody else wrote, so the # parser has to be exercised against hostile bytes far more often than a QEMU # boot allows. [package] name = "toyos-gpt" -description = "GPT parser that finds the partition firmware booted from (no_std, no alloc, forbid(unsafe_code))." +description = "GPT: the parser that finds the partition firmware booted from, and the one writer of a whole table (no_std, no alloc, forbid(unsafe_code))." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" [dev-dependencies] -# The differential oracle of `tests/oracle.rs`. +# The differential oracle of `tests/oracle.rs` and `tests/edit.rs`. gpt = "3.1.0" diff --git a/lib/gpt/src/edit.rs b/lib/gpt/src/edit.rs index 03b326e2569..31a770eb1e0 100644 --- a/lib/gpt/src/edit.rs +++ b/lib/gpt/src/edit.rs @@ -289,10 +289,12 @@ impl Encoded { /// when `mbr` asks for it and the primary copy, and a flush. /// /// The backup goes first because §5.3.2 requires it. The flush between is - /// what keeps a crash from tearing both copies at once: the primary is - /// not touched until the backup is whole on the medium, so at every - /// instant one copy is whole — the old primary while the backup is - /// written, the new backup while the primary is. + /// what keeps a crash from tearing both copies at once — on a device that + /// persists each sector whole or not at all and has every write before a + /// completed flush on the medium: then the primary is not touched until + /// the backup is whole, and one copy is whole at every instant, the old + /// primary while the backup is written and the new backup while the + /// primary is. A device that breaks either can leave neither copy valid. pub fn steps<'a>(&'a self, mbr: bool) -> impl Iterator> { let len = self.geometry.lba.bytes_usize(); let block = |b: &'a Block| -> &'a [u8] { b.get(..len).unwrap_or_default() }; diff --git a/lib/gpt/src/lib.rs b/lib/gpt/src/lib.rs index 0c5a0b6f374..493a1004dec 100644 --- a/lib/gpt/src/lib.rs +++ b/lib/gpt/src/lib.rs @@ -17,6 +17,9 @@ //! `no_std`, no allocation, no `unsafe`: the entry array is streamed a block //! at a time through [`Sectors`], so nothing here is sized by a number the //! disk chose. +//! +//! [`edit`] is the other half: a whole table read from either copy, checked +//! and written, for the one writer of a table ToyOS has. #![no_std] #![forbid(unsafe_code)] diff --git a/lib/manifest/src/authorize.rs b/lib/manifest/src/authorize.rs index 0d9ebc31539..a7968ae4be9 100644 --- a/lib/manifest/src/authorize.rs +++ b/lib/manifest/src/authorize.rs @@ -1,16 +1,18 @@ //! What a row may do to the machine's disks beyond its own partitions, which //! only the signed image grants: `authorize = [...]` in its row. The -//! supervisor mints each at the row's start, scoped to one action and target -//! in the state it finds then, and puts it in the row's namespace under -//! [`Authorization::name`]. No login, and no `[apps]` row, ever holds one. +//! supervisor mints each at every start of the row, one action on one target +//! that the block service resolves each time it is used, and puts it in the +//! row's namespace under [`Authorization::name`]. The build refuses one that +//! changes a disk to a login authority or a row a login can start, and no +//! `[apps]` row can spell one. /// One authorization a row may declare. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Authorization { /// Every disk's table, read: a listing and each disk's description. Tables, - /// One designation of the machine's one DATA partition, bound to its - /// disk's state when the row starts. + /// Designations of the machine's one DATA partition, resolved each time + /// one is asked for, for as long as the row runs. DesignateData, } diff --git a/loader/src/rootimage.rs b/loader/src/rootimage.rs index 85fb5228ac2..d39ba28e6e6 100644 --- a/loader/src/rootimage.rs +++ b/loader/src/rootimage.rs @@ -235,7 +235,6 @@ impl Sectors for Disk<'_> { self.lba_count } - fn read_lba(&mut self, lba: u64, buf: &mut [u8]) -> bool { let n = self.lba_bytes as usize; if self.io.read_blocks(self.media_id, lba, &mut self.scratch[..n]).is_err() { diff --git a/system/disks/src/main.rs b/system/disks/src/main.rs index a6d456a0a5d..37cbea3f25a 100644 --- a/system/disks/src/main.rs +++ b/system/disks/src/main.rs @@ -13,7 +13,7 @@ //! a login grants none. Exit status 0 where it was done, 1 where it was //! refused or failed, 2 for a command line it does not take. -use toyos_blockring::wire::{Designated, DiskHead, Lba0, SlotLine}; +use toyos_blockring::wire::{Designated, DiskHead, SlotLine}; use toyos_gpt::Guid; use toyos_manifest::authorize::Authorization; @@ -111,7 +111,7 @@ fn kind(guid: [u8; 16]) -> String { named.iter().find(|(g, _)| g.0 == guid).map_or_else(|| Guid(guid).to_string(), |(_, name)| (*name).to_string()) } -/// `bytes` in the largest binary unit that leaves a whole number above one. +/// `bytes` in the largest binary unit that leaves at least 10 of it. fn size(bytes: u64) -> String { let units = ["B", "KiB", "MiB", "GiB", "TiB"]; let mut at = 0; @@ -126,19 +126,13 @@ fn size(bytes: u64) -> String { /// One disk as `disks list` prints it. fn describe(disk: u32, head: &DiskHead, slots: &[SlotLine]) -> String { let bytes = head.sectors * u64::from(head.lba_bytes); - let lba0 = match head.lba0 { - Lba0::Protective => "a GPT", - Lba0::Mbr => "an MBR that is not a GPT's", - Lba0::Blank => "no partition table", - Lba0::Unread => "a first sector that would not read", - }; let copies = if head.agree { "both copies agree".to_string() } else { format!("primary {}, backup {}", head.primary, head.backup) }; let mut out = format!( - "disk {disk}: {} ({} sectors of {} bytes), {lba0}, {copies}\n", + "disk {disk}: {} ({} sectors of {} bytes), a GPT, {copies}\n", size(bytes), head.sectors, head.lba_bytes @@ -179,7 +173,6 @@ mod tests { let head = DiskHead { lba_bytes: 512, sectors: 500_118_192, - lba0: Lba0::Protective, agree: true, primary: "valid".into(), backup: "valid".into(), @@ -210,7 +203,7 @@ mod tests { } #[test] - fn only_a_done_or_judged_designation_succeeds() { + fn a_designation_is_said_as_what_it_came_to() { assert!(designated(&Designated::Written).contains("read back")); assert!(designated(&Designated::Foreign("an NTFS file system".into())).contains("carries an NTFS file system")); } diff --git a/system/diskserver/src/tables.rs b/system/diskserver/src/tables.rs index c2f284ca567..48da7223035 100644 --- a/system/diskserver/src/tables.rs +++ b/system/diskserver/src/tables.rs @@ -1,6 +1,6 @@ -//! A client's side of a disk's description and of a designation: what -//! `disks` and the supervisor ask the block service through a tables or a -//! designation badge (`toyos_blockring::wire::Badge`). +//! A client's side of a disk's description and of a designation, asked of +//! the block service through a tables or a designation badge +//! (`toyos_blockring::wire::Badge`). use toyos::namespace::Namespace; use toyos_blockring::wire::{self, Designated, DiskHead, Refusal, SlotLine}; diff --git a/system/fileserver/src/main.rs b/system/fileserver/src/main.rs index 2ed541c0f5a..7abacd93ae0 100644 --- a/system/fileserver/src/main.rs +++ b/system/fileserver/src/main.rs @@ -346,8 +346,8 @@ fn data_on(disk: D, roots: &[&str]) -> Box { Box::new(Absent::new(roots, why)) } // A disk that is there and holds somebody's bytes is no machine without - // one: nothing stands in for it, and `disks designate data` is how it - // becomes ToyOS's. + // one: nothing stands in for it, and a designation is how it becomes + // ToyOS's. Probed::Foreign => absent(roots, "the DATA partition holds no volume of ours and no designation stamp".into()), } } diff --git a/tests/toyos.rs b/tests/toyos.rs index 089625d6c1d..d52c662ccf1 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -1427,8 +1427,9 @@ const METAL: &[(&str, metal::Metal)] = &[ // driver of the NVMe disk judges the partition, writes the stamp, // flushes and reads it back, under the one authorization the // designation image grants its runner. Destructive and once per - // bench, so named only; the T14, whose p4 is the owner's empty - // ToyOS DATA. + // bench, so named only: the T14 and the AMD bench, whose p4s are the + // owner's empty ToyOS DATA. On the T14 the same boot reads its stick's + // last sector, past its last whole block. "bench_data_designated", metal::Metal { arms: DESIGNATE, judge: |b| bench_data_designated(&b[0].log(), &b[1].log()) }, ), @@ -4724,6 +4725,14 @@ fn bench_data_designated(first: &serial::Serial, second: &serial::Serial) -> Res first.must_say("supervisor: test-runner is authorized for `designate:data`")?; nvme_wrote_the_designation(first.text())?; first.must_say("the DATA partition is designated: the stamp is on the disk and read back")?; + // The T14's stick carries 7507812 sectors, four past its last whole 4 KiB + // block, and a smaller image than it, so its last sector holds no backup: + // usbd reads it with a short READ(10) and says the copies disagree. A + // read that failed would name the sector. + let usbd = |l: &&str| l.contains("usbd ") && l.contains("two copies do not agree"); + if let Some(failed) = first.text().lines().filter(usbd).find(|l| l.contains("ReadFailed(")) { + return Err(format!("a stick's last sector would not read: {failed:?}")); + } first.must_not_say("are in memory and will not survive a reboot")?; second.must_say("fileserver: block 0 designates this partition for ToyOS; formatting it")?; let serving = second.must_say("fileserver: Data serving ")?; From e364207821e5dfe93e0208fa44b86b005f61bf1c Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 11:45:58 +0100 Subject: [PATCH 13/14] issues: a stop's file sync, and its failure, are never recorded The supervisor makes the log whole before it syncs the file servers and stops the drivers, so what either says is in no file. It left the AMD designation's format unobservable in its own readback. Co-Authored-By: Claude Opus 5.5 --- ...sync-and-its-failure-are-never-recorded.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 issues/a-stops-file-sync-and-its-failure-are-never-recorded.md diff --git a/issues/a-stops-file-sync-and-its-failure-are-never-recorded.md b/issues/a-stops-file-sync-and-its-failure-are-never-recorded.md new file mode 100644 index 00000000000..3a910b4980c --- /dev/null +++ b/issues/a-stops-file-sync-and-its-failure-are-never-recorded.md @@ -0,0 +1,27 @@ +--- +status: open +kind: defect +opened: 2026-10-28 +--- + +# A stop's file sync, and its failure, are never recorded + +`Supervisor::halt` (`system/supervisor/src/stopping.rs`) has `logkeeper` make +the log whole, then syncs every writable file server (`sync_files`), then +stops the drivers and asks the kernel to stop. Whatever `sync_files` and the +drivers' stop say — a server that would not sync, one that did not answer in +`toyos_quiesce::SYNC_MS`, a driver's shutdown not complete in its bound — is +said after the log's last write, so it reaches the kernel's ring and no +file: a boot's readback can never tell a stop that made DATA durable from +one that lost it. The partitions work's first designation on the AMD bench +(PR #1085) formatted 600 GiB at boot with 4,803 blocks dirty when the +reboot began, and whether they reached the medium was not observable in its +readback; it took the next boot's mount to say. + +**Owner:** the supervisor's stop and `logkeeper`. + +**Exit condition:** a stop's sync outcome for each file server, and each +disk driver's shutdown outcome, are on the log partition when the machine +stops — the log made whole after them, its own server synced last — and a +guest test stops a machine whose DATA server will not sync and reads the +refusal off the log the next boot sees. From 05720ad636a3b159562703cd76340e42944c4fc4 Mon Sep 17 00:00:00 2001 From: japabu Date: Wed, 28 Oct 2026 13:33:23 +0100 Subject: [PATCH 14/14] tests: the bench's disk is left alone but for its DATA, which may be designated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nvme_leaves_the_benchs_disk_alone` held DATA foreign on every boot, so a bench whose DATA ToyOS has designated reds on every testcases boot: the AMD bench's #1089 testcases boot mounted its volume. Its claim is now that nothing on the bench's disk is touched but its ToyOS DATA partition — every NVMe session on that partition alone — and that DATA is left alone while it is foreign, and formatted or mounted and served as bcachefs once it is designated, never stood in for by memory. `hostdisk::left_alone` answers which of the three DATA was; the QEMU twin, whose disk is never designated and whose every byte is read back, still requires it foreign. Re-judged with it: the AMD's #1089 testcases readback and the T14's #1074 diag3 readback pass. A copy of the AMD's with an NVMe session on the host's root partition reds, as does one whose DATA is said to be in memory. Co-Authored-By: Claude Opus 5.5 --- tests/common/hostdisk.rs | 51 ++++++++++++++++++++++++++++++---------- tests/toyos.rs | 18 ++++++++------ 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/tests/common/hostdisk.rs b/tests/common/hostdisk.rs index c49993f3506..e82da3eb56a 100644 --- a/tests/common/hostdisk.rs +++ b/tests/common/hostdisk.rs @@ -65,13 +65,26 @@ fn said_by_nvme(line: &str) -> Option<&str> { (at.len() == 7 && at[2] == b':' && at[5] == b'.').then_some(said) } -/// A boot off a stick drove an NVMe disk that is another system's and left -/// it alone: a driver brought a controller up and offered its partitions, it -/// opened at least one session and each on a ToyOS DATA partition, and the file server -/// found that DATA neither ours nor designated, and so neither mounted nor -/// formatted it, and served DATA absent rather than from memory. Answers how -/// many partitions the disks' tables named. -pub fn left_alone(log: &Serial) -> Result { +/// What a boot's DATA file server made of the bench's ToyOS DATA partition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Data { + /// No volume of ours and no designation stamp: neither mounted nor + /// formatted, and DATA served absent. + Foreign, + /// A designated partition, formatted off its stamp and served. + Formatted, + /// A volume of ours, mounted and served. + Mounted, +} + +/// A boot off a stick drove an NVMe disk that is another system's and +/// touched nothing on it but its ToyOS DATA partition: a driver brought a +/// controller up and offered its partitions, it opened at least one session +/// and each on a ToyOS DATA partition, and the file server either left that +/// DATA alone, finding it neither ours nor designated, or — once it is +/// designated — formatted or mounted it; never DATA from memory. Answers how +/// many partitions the disks' tables named, and which of those DATA was. +pub fn left_alone(log: &Serial) -> Result<(usize, Data), String> { log.must_say("diskserver: every disk driver has settled")?; let said: Vec<&str> = log.text().lines().filter_map(said_by_nvme).collect(); if !said.iter().any(|s| s.starts_with("up: ")) { @@ -102,10 +115,24 @@ pub fn left_alone(log: &Serial) -> Result { other => return Err(format!("an nvme driver opened a session on {guid}, of type {other:?}, which is no ToyOS DATA")), } } - log.must_say("fileserver: no volume of ours and no designation stamp; nothing will be written to this partition")?; - log.must_say("fileserver: the DATA partition holds no volume of ours and no designation stamp; DATA is absent this boot")?; log.must_not_say("are in memory and will not survive a reboot")?; - log.must_not_say("fileserver: mounted the DATA volume")?; - log.must_not_say("designates this partition for ToyOS; formatting it")?; - Ok(kinds.len()) + let said = |line: &str| log.text().lines().any(|l| l.contains(line)); + let fates = [ + (Data::Foreign, said("fileserver: the DATA partition holds no volume of ours and no designation stamp; DATA is absent this boot")), + (Data::Formatted, said("designates this partition for ToyOS; formatting it")), + (Data::Mounted, said("fileserver: mounted the DATA volume")), + ]; + let fate = match fates.iter().filter(|(_, said)| *said).map(|(fate, _)| *fate).collect::>()[..] { + [one] => one, + ref many => return Err(format!("DATA's file server said {many:?} of foreign, formatted and mounted, and one is its answer")), + }; + if fate == Data::Foreign { + log.must_say("fileserver: no volume of ours and no designation stamp; nothing will be written to this partition")?; + } else { + let serving = log.must_say("fileserver: Data serving ")?; + if !serving.contains(" — bcachefs") { + return Err(format!("DATA is {fate:?} and served by no bcachefs volume: {serving:?}")); + } + } + Ok((kinds.len(), fate)) } diff --git a/tests/toyos.rs b/tests/toyos.rs index d52c662ccf1..67457a149a2 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -1414,12 +1414,14 @@ const METAL: &[(&str, metal::Metal)] = &[ metal::Metal { arms: TESTCASES, judge: |b| usbd_serves_the_boot_stick(&b[0].kernel(), &b[0].log()) }, ), ( - // The bench's own NVMe disk, its host's partitions and the owner's - // unformatted ToyOS DATA, driven beside the stick by the row's class - // and left alone (`hostdisk::left_alone`). The T14 and the AMD bench; - // the metal loop's next boot of the host off that disk is the other half. + // The bench's own NVMe disk, driven beside the stick by the row's + // class: nothing on it is touched but its ToyOS DATA partition, which + // is left alone while it is foreign and formatted or mounted once it + // is designated (`hostdisk::left_alone`), and never stood in for by + // memory. The T14 and the AMD bench; the metal loop's next boot of the + // host off that disk is the other half. "nvme_leaves_the_benchs_disk_alone", - metal::Metal { arms: TESTCASES, judge: |b| common::hostdisk::left_alone(&b[0].log()).map(|_| ()) }, + metal::Metal { arms: TESTCASES, judge: |b| common::hostdisk::left_alone(&b[0].log()).map(drop) }, ), ( // The bench's DATA partition designated from inside ToyOS, and the @@ -4797,9 +4799,11 @@ fn a_host_disk_beside_the_stick_is_left_alone(test_config: &Path) -> Result<(), console.push_str(&qemu.await_exit(by)?); let log = serial::Serial::named("the boot", console); log.must_say("vendor=8086 device=5845 prog_if=02")?; + // This disk's DATA is never designated: what it is judged on is that + // nothing writes to a disk that is not ours. match common::hostdisk::left_alone(&log)? { - 4 => {} - named => return Err(format!("the driver named {named} partitions of a disk that has 4")), + (4, common::hostdisk::Data::Foreign) => {} + (named, data) => return Err(format!("the driver named {named} partitions of a disk that has 4, and DATA was {data:?}")), } let after = std::fs::read(qemu.nvme_image()).map_err(|e| e.to_string())?; let before = common::hostdisk::bytes();