From f7334d4d13c04e47f02388d10ac932a3e89bcd90 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 22 Oct 2026 13:41:29 -0700 Subject: [PATCH 1/2] nvx-rs: call a GPT disk's p1 its first partition "p1" was shorthand that each reader had to decode. Name the partition explicitly in nvx::gpt's API, errors, and documentation: `Disk::p1` and `Disk::read_p1` become `Disk::first_partition` and `Disk::read_first_partition`, and `GptError::P1TooLarge` becomes `GptError::FirstPartitionTooLarge`. Tests and comments name the other partitions by their numbers. Nothing else in the repository calls these yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b4b569c-a481-43aa-bfd8-c14a9762c371 --- nvx-rs/README.md | 8 +-- nvx-rs/src/gpt/error.rs | 12 ++-- nvx-rs/src/gpt/mod.rs | 67 +++++++++++---------- nvx-rs/src/gpt/tests.rs | 128 +++++++++++++++++++++++++--------------- 4 files changed, 122 insertions(+), 93 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index a484f3bdb..e1e020db9 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -29,9 +29,9 @@ nvx-rs/ │ │ no dependencies, shared by the host and the guest │ ├── contract.rs control-contract versions and release-manifest compatibility │ ├── net/ CIDR parsing and subtraction, and hostname and guest-network forms -│ ├── gpt/ strict reading of GPT disks whose p1 configures an adapter and whose other -│ │ partitions hold a root's read-only layers; no dependencies, shared by the -│ │ host and the guest +│ ├── gpt/ strict reading of GPT disks whose first partition configures an adapter and +│ │ whose other partitions hold a root's read-only layers; no dependencies, +│ │ shared by the host and the guest │ ├── uuid.rs UUIDs in their canonical text and in GPT's layout; no dependencies, shared │ │ by the host and the guest │ ├── hypervisor.rs @@ -65,7 +65,7 @@ nvx-rs/ | `nvx::protocol::share` | The guest targets that a sandbox's live shares may not take, which the host's `sandbox::mount` and the guest's `root` both refuse: `RESERVED_MOUNT_TARGETS`, the trees where the container runtime mounts or binds, `RESERVED_EXACT_MOUNT_TARGETS`, the directories that it creates, `RESERVED_TARGET_CHARACTERS`, and `reservation`, which tells why a target is reserved | | `nvx::contract` | The OpenVMM microVM ABI, broker protocol, and control-contract revision that the crate speaks, and `manifest_is_compatible` for a release's `SOURCE-MANIFEST.json` | | `nvx::net` | `Cidr`: IPv4 and IPv6 networks and their subtraction; `is_valid_hostname` and `is_valid_guest_network`, the forms of a VM's hostname and guest address | -| `nvx::gpt` | `Disk::read`: the protective MBR, primary header, and primary partition entry array of a GPT disk, read through a `ReadAt` source that the caller gives and checked strictly, as UEFI specifies them and as Linux requires them, without ever reading the backup: 128-byte entries, at most 1 MiB of them, used entries first and without holes, p1 and 1 to 254 layers, extents in the usable blocks without overlap, and unique GUIDs, with checked arithmetic in the disk's logical blocks. `Expected` adds the disk GUID and the partitions' unique GUIDs and sizes that a caller knows, and `Disk::read_p1` returns p1's opaque bytes, up to a bound and without trailing NUL bytes and whitespace. GUIDs are `nvx::uuid::Uuid`s. It has no dependencies, so the guest and the host's checks of the images that it stages share it | +| `nvx::gpt` | `Disk::read`: the protective MBR, primary header, and primary partition entry array of a GPT disk, read through a `ReadAt` source that the caller gives and checked strictly, as UEFI specifies them and as Linux requires them, without ever reading the backup: 128-byte entries, at most 1 MiB of them, used entries first and without holes, a first partition and 1 to 254 layers, extents in the usable blocks without overlap, and unique GUIDs, with checked arithmetic in the disk's logical blocks. `Expected` adds the disk GUID and the partitions' unique GUIDs and sizes that a caller knows, and `Disk::read_first_partition` returns the first partition's opaque bytes, up to a bound and without trailing NUL bytes and whitespace. GUIDs are `nvx::uuid::Uuid`s. It has no dependencies, so the guest and the host's checks of the images that it stages share it | | `nvx::uuid` | `Uuid`: a UUID, such as the GUID of a GPT disk or partition or the UUID of a filesystem, in the order of its canonical text, the one text that it displays and parses: 32 lowercase hexadecimal digits in groups of 8, 4, 4, 4, and 12, joined by hyphens. `from_bytes_le` and `to_bytes_le` convert the layout in which GPT and other EFI GUIDs store their first three groups little-endian. It has no dependencies, so the host and the guest share it | | `nvx::Hypervisor` | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | `nvx::host::artifacts` | `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and Alpine initramfs, from a release, a repository checkout, the environment, a directory next to the executable, or the bundle. `ArtifactDir` and `Guest`: every artifact of the guests that `nvx.py` runs, the OpenVMM executable, the kernel, the Alpine, Ubuntu, and Azure Linux initramfs images and their package manifests, and the Ubuntu distro layer and its manifest, in a checkout's `build/` and `openvmm/target/release/` or a release's `guest/` and `bin/`, checked in the order and with the messages of `nvx.py`. `ArtifactPaths` lists them all and serializes as JSON | diff --git a/nvx-rs/src/gpt/error.rs b/nvx-rs/src/gpt/error.rs index edb149b01..3c12973d1 100644 --- a/nvx-rs/src/gpt/error.rs +++ b/nvx-rs/src/gpt/error.rs @@ -149,9 +149,9 @@ pub enum GptError { /// The partition's size, in bytes. found: u64, }, - /// p1 is larger than the caller's bound. - P1TooLarge { - /// The size of p1, in bytes. + /// The first partition is larger than the caller's bound. + FirstPartitionTooLarge { + /// The size of the first partition, in bytes. size: u64, /// The caller's bound, in bytes. bound: usize, @@ -250,7 +250,7 @@ impl fmt::Display for GptError { Self::PartitionCount(count) => write!( formatter, "the disk has {count} partitions, not {MIN_PARTITIONS} through {MAX_PARTITIONS}: \ - p1 and at least one layer" + the first partition and at least one layer" ), Self::Extent { number, @@ -295,9 +295,9 @@ impl fmt::Display for GptError { formatter, "partition {number} has {found} bytes, not the expected {expected}" ), - Self::P1TooLarge { size, bound } => write!( + Self::FirstPartitionTooLarge { size, bound } => write!( formatter, - "p1 has {size} bytes, more than the bound of {bound}" + "the first partition has {size} bytes, more than the bound of {bound}" ), } } diff --git a/nvx-rs/src/gpt/mod.rs b/nvx-rs/src/gpt/mod.rs index 00618b32d..193fe9fbb 100644 --- a/nvx-rs/src/gpt/mod.rs +++ b/nvx-rs/src/gpt/mod.rs @@ -1,4 +1,4 @@ -//! GUID Partition Table (GPT) disks that hold a layered root: a first partition, p1, whose bytes +//! GUID Partition Table (GPT) disks that hold a layered root: a first partition whose bytes //! configure an adapter, and the read-only filesystem layers of the root in the partitions after //! it. //! @@ -17,11 +17,11 @@ //! - **Entry array:** entries of [`ENTRY_SIZE`] bytes, the only size that Linux reads, at most //! [`MAX_ENTRY_ARRAY_BYTES`] of them in all, and a matching CRC-32. //! - **Partitions:** the used entries, those whose type GUID is not nil, come first, with no unused -//! entry among them, and number from [`MIN_PARTITIONS`], p1 and at least one layer, through -//! [`MAX_PARTITIONS`], the most that Linux creates. Each lies in the usable blocks, shares no -//! block with another, and has a unique GUID that is not nil and repeats no other GUID of the -//! disk. Linux numbers each partition one more than its entry's index, as [`Partition::number`] -//! does. +//! entry among them, and number from [`MIN_PARTITIONS`], the first partition and at least one +//! layer, through [`MAX_PARTITIONS`], the most that Linux creates. Each lies in the usable +//! blocks, shares no block with another, and has a unique GUID that is not nil and repeats no +//! other GUID of the disk. Linux numbers each partition one more than its entry's index, as +//! [`Partition::number`] does. //! //! The backup header and entry array are never read: a disk whose primary table is invalid fails //! even when its backup is intact. Linux, too, falls back to the backup only when its command @@ -31,8 +31,8 @@ //! the entry array, which it allocates only once the header has bounded its size, and computes //! positions with checked arithmetic, in the disk's logical blocks. [`Expected`] adds the //! identities and sizes that a caller knows, such as the disk GUID that a boot parameter names. -//! [`Disk::read_p1`] then reads p1 as opaque bytes, up to a bound that the caller sets, which it -//! neither interprets nor probes as a filesystem. +//! [`Disk::read_first_partition`] then reads the first partition as opaque bytes, up to a bound +//! that the caller sets, which it neither interprets nor probes as a filesystem. //! //! GUIDs are [`Uuid`]s, which GPT stores in the layout that [`Uuid::from_bytes_le`] reads. //! @@ -48,10 +48,10 @@ //! # fn main() -> Result<(), Box> { //! let image = File::open("disk.img")?; //! let disk = Disk::read(&image, 512, image.metadata()?.len(), &Expected::new())?; -//! let configuration = disk.read_p1(&image, 64 * 1024)?; -//! println!("p1 holds {} bytes of configuration", configuration.len()); +//! let configuration = disk.read_first_partition(&image, 64 * 1024)?; +//! println!("the first partition holds {} bytes of configuration", configuration.len()); //! for layer in disk.lowers() { -//! println!("p{}: {} bytes at {}", layer.number(), layer.size(), layer.offset()); +//! println!("partition {}: {} bytes at {}", layer.number(), layer.size(), layer.offset()); //! } //! # Ok(()) //! # } @@ -84,7 +84,7 @@ pub const ENTRY_SIZE: u32 = 128; /// allocation and the work of its CRC: 8,192 entries. UEFI's default array holds 128. pub const MAX_ENTRY_ARRAY_BYTES: u32 = 1024 * 1024; -/// The fewest partitions of a disk: p1 and one layer. +/// The fewest partitions of a disk: the first partition and one layer. pub const MIN_PARTITIONS: usize = 2; /// The most partitions of a disk: Linux creates partitions 1 through 255 only. @@ -182,7 +182,7 @@ mod entry { pub struct Expected { /// The disk's GUID, if the caller expects one. disk_guid: Option, - /// The partitions, p1 first, if the caller expects them. + /// The partitions, in the order of their numbers, if the caller expects them. partitions: Option>, } @@ -198,8 +198,8 @@ impl Expected { self } - /// Expects exactly as many partitions as `partitions` holds, p1 first, and each to have the - /// unique GUID and size that its expectation sets. + /// Expects exactly as many partitions as `partitions` holds, in the order of their numbers, + /// and each to have the unique GUID and size that its expectation sets. pub fn partitions(mut self, partitions: Vec) -> Self { self.partitions = Some(partitions); self @@ -277,7 +277,7 @@ impl ExpectedPartition { } /// A GPT disk whose protective MBR, primary header, and primary partition entry array -/// [`Disk::read`] checked, and its partitions: p1 and the layers after it. +/// [`Disk::read`] checked, and its partitions: the first partition and the layers after it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Disk { /// The size of a logical block, in bytes. @@ -331,44 +331,43 @@ impl Disk { self.guid } - /// Returns every partition, p1 first, in the order of their numbers, which run from 1 - /// without a gap. + /// Returns every partition, in the order of their numbers, which run from 1 without a gap. pub fn partitions(&self) -> &[Partition] { &self.partitions } - /// Returns p1, the partition whose bytes configure the adapter. - pub fn p1(&self) -> &Partition { + /// Returns the first partition, whose bytes configure the adapter. + pub fn first_partition(&self) -> &Partition { &self.partitions[0] } - /// Returns the partitions after p1, which hold the read-only layers of the root: at least - /// one. + /// Returns the partitions after the first, which hold the read-only layers of the root: at + /// least one. pub fn lowers(&self) -> &[Partition] { &self.partitions[1..] } - /// Reads p1 from `source`, the disk that [`Disk::read`] read: every byte of the partition, - /// less the trailing NUL bytes and ASCII whitespace (space, tab, line feed, form feed, and - /// carriage return), as opaque bytes that need not be UTF-8. + /// Reads the first partition from `source`, the disk that [`Disk::read`] read: every byte of + /// the partition, less the trailing NUL bytes and ASCII whitespace (space, tab, line feed, + /// form feed, and carriage return), as opaque bytes that need not be UTF-8. /// - /// A p1 larger than `bound` bytes fails before any of it is read, so that no more than - /// `bound` bytes are read or allocated. - pub fn read_p1( + /// A first partition larger than `bound` bytes fails before any of it is read, so that no + /// more than `bound` bytes are read or allocated. + pub fn read_first_partition( &self, source: &S, bound: usize, ) -> Result, GptError> { - let p1 = self.p1(); - let length = usize::try_from(p1.size) + let partition = self.first_partition(); + let length = usize::try_from(partition.size) .ok() .filter(|length| *length <= bound) - .ok_or(GptError::P1TooLarge { - size: p1.size, + .ok_or(GptError::FirstPartitionTooLarge { + size: partition.size, bound, })?; let mut bytes = vec![0; length]; - source.read_exact_at(p1.offset, &mut bytes)?; + source.read_exact_at(partition.offset, &mut bytes)?; let end = bytes .iter() .rposition(|byte| *byte != 0 && !byte.is_ascii_whitespace()) @@ -401,7 +400,7 @@ pub struct Partition { impl Partition { /// Returns the partition's number, as Linux numbers it: one more than its entry's index, so - /// p1 is 1, and the first layer 2. + /// the first partition is 1, and the first layer 2. pub fn number(&self) -> u8 { self.number } diff --git a/nvx-rs/src/gpt/tests.rs b/nvx-rs/src/gpt/tests.rs index 8de0d7bab..c0976cd6c 100644 --- a/nvx-rs/src/gpt/tests.rs +++ b/nvx-rs/src/gpt/tests.rs @@ -101,14 +101,14 @@ struct Spec { entry_array_crc: Option, /// The partition entries by index; `None` leaves an entry unused. entries: Vec>, - /// Bytes written at the start of a block, such as p1's contents. + /// Bytes written at the start of a block, such as the first partition's contents. contents: Vec<(u64, Vec)>, } impl Spec { /// Returns a valid disk of `blocks` blocks of `block_size` bytes, whose `entry_count` - /// entries start at LBA 2, as do UEFI's defaults, and with three partitions: p1, one block, - /// and two layers of 8 and 16 blocks after it. + /// entries start at LBA 2, as do UEFI's defaults, and with three partitions: the first, one + /// block, and two layers of 8 and 16 blocks after it. fn new(block_size: u32, blocks: u64, entry_count: u32) -> Self { let array_blocks = (u64::from(entry_count) * 128).div_ceil(u64::from(block_size)); let first_usable_lba = 2 + array_blocks; @@ -307,12 +307,12 @@ fn a_disk_reads_with_its_partitions_in_number_order() { (3, 43, 58, 43 * 512, 16 * 512), ] ); - assert_eq!(disk.p1().number(), 1); - assert_eq!(disk.p1().unique_guid(), guid(1)); - assert_eq!(disk.p1().type_guid(), LINUX_DATA); + assert_eq!(disk.first_partition().number(), 1); + assert_eq!(disk.first_partition().unique_guid(), guid(1)); + assert_eq!(disk.first_partition().type_guid(), LINUX_DATA); // GPT stores the first three groups of a GUID little-endian. assert_eq!( - disk.p1().type_guid().to_string(), + disk.first_partition().type_guid().to_string(), "0fc63daf-8483-4772-8e79-3d69d8477de4" ); let lowers: Vec<_> = disk.lowers().iter().map(Partition::number).collect(); @@ -516,12 +516,12 @@ fn the_entries_are_128_bytes_and_bounded_in_number() { // The largest array, 1 MiB in 256 4096-byte blocks, and the smallest, one entry with the // others of its block outside the array, which its CRC does not cover. let spec = Spec::new(4096, 600, 8192); - assert_eq!(spec.read().unwrap().p1().first_lba(), 258); + assert_eq!(spec.read().unwrap().first_partition().first_lba(), 258); let mut spec = Spec::new(512, 2048, 3); spec.contents .push((2, [spec.array(), vec![0xFF; 128]].concat())); let disk = spec.read().unwrap(); - assert_eq!(disk.p1().first_lba(), 3); + assert_eq!(disk.first_partition().first_lba(), 3); } #[test] @@ -544,7 +544,7 @@ fn the_entry_array_lies_after_the_header_and_before_the_usable_blocks() { spec.first_usable_lba = 35; spec.entry(1).first_lba = 59; spec.entry(1).last_lba = 59; - assert_eq!(spec.read().unwrap().p1().offset(), 59 * 512); + assert_eq!(spec.read().unwrap().first_partition().offset(), 59 * 512); } #[test] @@ -639,9 +639,12 @@ fn the_reader_never_reads_past_the_usable_blocks() { reads: RefCell::new(Vec::new()), }; let disk = Disk::read(&source, 512, image.len() as u64, &Expected::new()).unwrap(); - assert_eq!(disk.read_p1(&source, 512).unwrap(), b"configuration"); + assert_eq!( + disk.read_first_partition(&source, 512).unwrap(), + b"configuration" + ); let reads = source.reads.into_inner(); - // The MBR, the header, the array, and p1, each read once. + // The MBR, the header, the array, and the first partition, each read once. assert_eq!( reads, [(0, 512), (512, 512), (1024, 128 * 128), (34 * 512, 512)] @@ -694,7 +697,7 @@ fn partitions_share_no_block() { spec.entry(3).first_lba = 34; spec.entry(3).last_lba = 34; let disk = spec.read().unwrap(); - assert_eq!(disk.p1().first_lba(), 100); + assert_eq!(disk.first_partition().first_lba(), 100); assert_eq!(disk.lowers()[1].first_lba(), 34); } @@ -728,7 +731,7 @@ fn used_entries_come_first_without_holes() { } #[test] -fn a_disk_has_p1_and_one_to_254_layers() { +fn a_disk_has_a_first_partition_and_one_to_254_layers() { let mut spec = Spec::standard(); spec.entries.clear(); assert_fails!(spec.read(), GptError::PartitionCount(0)); @@ -774,7 +777,7 @@ fn partition_guids_are_unique_and_not_nil() { let mut spec = Spec::standard(); spec.entry(1).type_guid = guid(0x77); let disk = spec.read().unwrap(); - assert_eq!(disk.p1().type_guid(), guid(0x77)); + assert_eq!(disk.first_partition().type_guid(), guid(0x77)); assert_eq!(disk.lowers()[0].type_guid(), LINUX_DATA); } @@ -847,9 +850,9 @@ fn expectations_follow_the_format_checks() { ); } -/// Returns the standard disk whose p1, of `blocks` blocks of `block_size` bytes, starts with -/// `contents`. -fn disk_with_p1(block_size: u32, blocks: u64, contents: &[u8]) -> (Disk, Vec) { +/// Returns the standard disk whose first partition, of `blocks` blocks of `block_size` bytes, +/// starts with `contents`. +fn disk_with_first_partition(block_size: u32, blocks: u64, contents: &[u8]) -> (Disk, Vec) { let mut spec = if block_size == 512 { Spec::standard() } else { @@ -874,60 +877,84 @@ fn disk_with_p1(block_size: u32, blocks: u64, contents: &[u8]) -> (Disk, Vec } #[test] -fn p1_is_its_opaque_bytes_without_trailing_padding() { +fn the_first_partition_is_its_opaque_bytes_without_trailing_padding() { // One sector of configuration, padded with NUL bytes. - let (disk, image) = disk_with_p1(512, 1, b"{\"layers\":2}\n"); - assert_eq!(disk.p1().size(), 512); + let (disk, image) = disk_with_first_partition(512, 1, b"{\"layers\":2}\n"); + assert_eq!(disk.first_partition().size(), 512); assert_eq!( - disk.read_p1(image.as_slice(), 512).unwrap(), + disk.read_first_partition(image.as_slice(), 512).unwrap(), b"{\"layers\":2}" ); // Bytes that are not UTF-8, with NUL bytes and whitespace inside, are kept. let bytes = [0xFF, 0x00, 0xC3, 0x28, b' ', 0x80, b'\t', 0x01]; - let (disk, image) = disk_with_p1(512, 1, &bytes); - assert_eq!(disk.read_p1(image.as_slice(), 512).unwrap(), bytes); + let (disk, image) = disk_with_first_partition(512, 1, &bytes); + assert_eq!( + disk.read_first_partition(image.as_slice(), 512).unwrap(), + bytes + ); // Trailing NUL bytes and whitespace go, in any order; leading ones stay, as does a // vertical tab, which is not ASCII whitespace. - let (disk, image) = disk_with_p1(512, 1, b" \tx \t\r\n\x0c\0 \0\n"); - assert_eq!(disk.read_p1(image.as_slice(), 512).unwrap(), b" \tx"); - let (disk, image) = disk_with_p1(512, 1, b"x\x0b\0"); - assert_eq!(disk.read_p1(image.as_slice(), 512).unwrap(), b"x\x0b"); - let (disk, image) = disk_with_p1(512, 1, b"\0\n \0"); - assert_eq!(disk.read_p1(image.as_slice(), 512).unwrap(), b""); - - // A full sector, and p1 across several sectors. + let (disk, image) = disk_with_first_partition(512, 1, b" \tx \t\r\n\x0c\0 \0\n"); + assert_eq!( + disk.read_first_partition(image.as_slice(), 512).unwrap(), + b" \tx" + ); + let (disk, image) = disk_with_first_partition(512, 1, b"x\x0b\0"); + assert_eq!( + disk.read_first_partition(image.as_slice(), 512).unwrap(), + b"x\x0b" + ); + let (disk, image) = disk_with_first_partition(512, 1, b"\0\n \0"); + assert_eq!( + disk.read_first_partition(image.as_slice(), 512).unwrap(), + b"" + ); + + // A full sector, and a first partition across several sectors. let full = vec![b'a'; 512]; - let (disk, image) = disk_with_p1(512, 1, &full); - assert_eq!(disk.read_p1(image.as_slice(), 512).unwrap(), full); + let (disk, image) = disk_with_first_partition(512, 1, &full); + assert_eq!( + disk.read_first_partition(image.as_slice(), 512).unwrap(), + full + ); let long: Vec = (0..1500).map(|index| (index % 251) as u8 + 1).collect(); - let (disk, image) = disk_with_p1(512, 3, &long); - assert_eq!(disk.read_p1(image.as_slice(), 1536).unwrap(), long); + let (disk, image) = disk_with_first_partition(512, 3, &long); + assert_eq!( + disk.read_first_partition(image.as_slice(), 1536).unwrap(), + long + ); // One sector of a disk of 4096-byte blocks. - let (disk, image) = disk_with_p1(4096, 1, b"config\n"); - assert_eq!(disk.p1().size(), 4096); - assert_eq!(disk.read_p1(image.as_slice(), 4096).unwrap(), b"config"); + let (disk, image) = disk_with_first_partition(4096, 1, b"config\n"); + assert_eq!(disk.first_partition().size(), 4096); + assert_eq!( + disk.read_first_partition(image.as_slice(), 4096).unwrap(), + b"config" + ); } #[test] -fn p1_over_the_bound_fails_before_it_is_read() { - let (disk, image) = disk_with_p1(512, 2, b"x"); +fn a_first_partition_over_the_bound_fails_before_it_is_read() { + let (disk, image) = disk_with_first_partition(512, 2, b"x"); for bound in [0, 1, 1023] { assert_fails!( - disk.read_p1(image.as_slice(), bound), - GptError::P1TooLarge { size: 1024, bound: found } if found == bound + disk.read_first_partition(image.as_slice(), bound), + GptError::FirstPartitionTooLarge { size: 1024, bound: found } if found == bound ); } let source = Recording { bytes: &image, reads: RefCell::new(Vec::new()), }; - assert!(disk.read_p1(&source, 1023).is_err()); + assert!(disk.read_first_partition(&source, 1023).is_err()); assert!(source.reads.borrow().is_empty()); - assert_eq!(disk.read_p1(&source, 1024).unwrap(), b"x"); - assert_eq!(disk.read_p1(&source, usize::MAX).unwrap(), b"x"); + assert_eq!(disk.read_first_partition(&source, 1024).unwrap(), b"x"); + assert_eq!( + disk.read_first_partition(&source, usize::MAX).unwrap(), + b"x" + ); } #[test] @@ -945,7 +972,7 @@ fn a_short_source_fails_its_read() { } let disk = Disk::read(image.as_slice(), 512, size, &Expected::new()).unwrap(); assert_fails!( - disk.read_p1(&image[..34 * 512 + 100], 512), + disk.read_first_partition(&image[..34 * 512 + 100], 512), GptError::Io(error) if error.kind() == io::ErrorKind::UnexpectedEof ); } @@ -981,7 +1008,10 @@ fn files_read_at_offsets() { let mut file = tempfile::tempfile().unwrap(); file.write_all(&spec.image()).unwrap(); let disk = Disk::read(&file, 512, 2048 * 512, &Expected::new()).unwrap(); - assert_eq!(disk.read_p1(&file, 512).unwrap(), b"from a file"); + assert_eq!( + disk.read_first_partition(&file, 512).unwrap(), + b"from a file" + ); } #[test] @@ -993,7 +1023,7 @@ fn errors_describe_the_check_and_keep_their_cause() { ); assert_eq!( GptError::PartitionCount(256).to_string(), - "the disk has 256 partitions, not 2 through 255: p1 and at least one layer" + "the disk has 256 partitions, not 2 through 255: the first partition and at least one layer" ); assert_eq!( GptError::EntryCount(0).to_string(), From 934b0aa3d35f6c4d2beb7787ac19fb518dc58e8e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 22 Oct 2026 13:49:13 -0700 Subject: [PATCH 2/2] nvx-rs: read a GPT disk's table through the guest's block device A guest that boots from a GPT disk needs the disk's table, whose disk GUID a boot parameter names, from the block device that the kernel attached. nvx::gpt reads a table through any source at a geometry that its caller states, so the guest only has to supply the device's. Add nvx::guest::gpt::read_disk, which asks a block device that the caller opened for its logical block size and its size, with BLKSSZGET and BLKGETSIZE64, as blockdev does, and reads and checks the table there with Disk::read and the caller's Expected values. It only reads the device, at explicit offsets, so it never moves the file's position and callers may share the file. It neither compares the partitions that the kernel created with the table nor asks the kernel to reread it; a disk that the host stages read-only does not change under the guest. The two ioctl wrappers live in guest::sys beside BLKFLSBUF. They borrow the caller's descriptor, which they neither read nor write. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b4b569c-a481-43aa-bfd8-c14a9762c371 --- doc/project-structure.md | 3 +- nvx-rs/README.md | 10 ++- nvx-rs/src/guest/gpt.rs | 126 ++++++++++++++++++++++++++++++++++ nvx-rs/src/guest/mod.rs | 6 +- nvx-rs/src/guest/sys/ioctl.rs | 65 ++++++++++++++++++ nvx-rs/src/guest/sys/mod.rs | 4 +- nvx-rs/src/lib.rs | 10 +-- 7 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 nvx-rs/src/guest/gpt.rs diff --git a/doc/project-structure.md b/doc/project-structure.md index 492ce6f9a..0b7cdcaa1 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -103,9 +103,10 @@ nvx/ | | |-- protocol/ Control-console codec, NVXS session, and the guest targets that live shares may not take, shared by the host and the guest | | |-- contract.rs Control-contract versions and release compatibility | | |-- net/ CIDR parsing and subtraction, and hostname and guest-network forms +| | |-- gpt/ Strict reading of GPT disks whose first partition configures an adapter and whose other partitions hold a root's read-only layers, shared by the host and the guest | | |-- host/ Artifacts, launch, network and egress-policy lowering, sessions, VM instances, sandbox stores, the sandbox launch profile, and the `nvx-rs` CLI (feature `cli`) | | |-- testing/ Fake OpenVMM (feature `testing`) -| | |-- guest/ Programs that run in Linux guests, the guest's NVXS connection, the libraries through which any guest init or agent starts a program, collects its children's statuses, settles its executions' cgroups, drives each execution, bind-mounts host mappings below the roots that it names, and assembles and tears down a layered root, and the typed calls through which it runs the time ABI's steps and the reseed (feature `guest`) +| | |-- guest/ Programs that run in Linux guests, the guest's NVXS connection, the libraries through which any guest init or agent starts a program, collects its children's statuses, settles its executions' cgroups, drives each execution, bind-mounts host mappings below the roots that it names, assembles and tears down a layered root, and reads a GPT disk's partition table through its block device, and the typed calls through which it runs the time ABI's steps and the reseed (feature `guest`) | | `-- bin/ nvx-rs CLI, nvx-fake-openvmm test double, and the guest binaries | |-- tests/ Fake-OpenVMM instance lifecycle, sandbox store, CLI, NVXS session, guest-binary, and hypervisor managed-lifecycle tests, the store's record fixtures, and the golden fixtures that Python recorded | |-- build.rs Stages the bundled artifacts (feature `bundled`) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index e1e020db9..d7449df10 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -45,9 +45,10 @@ nvx-rs/ │ ├── guest/ feature `guest`: the programs that run in Linux guests, the guest's NVXS │ │ connection, the libraries through which a guest init or agent starts a │ │ program, collects its children's statuses, settles its executions' -│ │ cgroups, bind-mounts host mappings below the roots that it names, and -│ │ assembles and tears down a layered root, and the typed calls through which -│ │ it runs the time ABI's steps and the reseed +│ │ cgroups, bind-mounts host mappings below the roots that it names, +│ │ assembles and tears down a layered root, and reads a GPT disk's partition +│ │ table through its block device, and the typed calls through which it runs +│ │ the time ABI's steps and the reseed │ └── bin/ nvx-rs, nvx-fake-openvmm, and the guest binaries ├── tests/ fake-OpenVMM, CLI, NVXS session, process-supervision, store, and guest-binary │ tests, the store's record fixtures, and the golden fixtures that Python @@ -93,6 +94,7 @@ nvx-rs/ | `nvx::guest::time` | x86_64: `TimeAbi`, the steps of NVX time ABI v1 as typed calls in the caller's process, for any guest init or agent: the cold-boot time step and, separately, the start of the time daemon in a child process that the caller supervises, the VM generation ID, a `CaptureRequest` that repairs a `Restore`, and a `RestoreFinish`. A call takes the permission for OpenVMM's ports on the calling thread for each port transaction alone, and fails with `TimeError::PortRelease` if the thread may still hold some of it, which a process that it started would inherit, and from which `nvx::guest::spawn` starts none. No call waits for a child that it did not start. `nvx-time` runs its steps through them | | `nvx::guest::reseed` | `Seed::read` and `reseed`: a restore's fresh entropy, which starts with its `GenerationId`, and the reseed of the kernel's random number generator with it, in the caller's process. `nvx-reseed` runs them | | `nvx::guest::root` | The operations of a layered root, for a guest init or agent, whether or not it is PID 1. `bootstrap`: the agent's and the workload's sibling cgroups of a `Layout`, with every process of their parent moved into the agent's, the agent's `memory.low`, and the workload cgroup prepared through `ExecutionCgroup::prepare`, with its `memory.max` and `pids.max`, and user namespaces kept unavailable. `assemble`: the `Assembly`'s block devices, found by their MMIO windows, checked against their expected sizes and flushed, and the `Root` that it mounts, at a runtime directory, a canonical path that no symbolic link leads through, that it holds exclusively until its teardown, where nothing is mounted already: a read-only EROFS image, whose expected `nvx::uuid::Uuid` it checks in its superblock, under an overlay with `metacopy=on,xino=on`, mounted `nosuid,nodev`, whose upper and work directories are on an ext4 scratch mounted `nosuid,nodev` too. `Root::mount_share` and `Root::mount_aggregate`: virtio-fs live shares at targets in the root that it checks, against the targets that `protocol::share` reserves among others, with one aggregate per root, whose children it binds through `mappings::Mapping`. `Root::teardown`: the shares in reverse mount order, then the overlay, the image, and the scratch, with every failed unmount reported. Each failure is a typed `Error`. The operations collect no child's status, change neither the caller's root nor its credentials, start no async runtime, and never power the VM off | +| `nvx::guest::gpt` | `read_disk`: the partition table of a GPT disk, read and checked with `nvx::gpt`'s `Disk::read` through the disk's block device, which the caller opened, at the logical block size and size that `BLKSSZGET` and `BLKGETSIZE64` report, with the caller's `Expected` values, such as the disk GUID that a boot parameter names. It only reads the device, at explicit offsets, so it never moves the file's position. It neither compares the partitions that the kernel created with the table nor asks the kernel to reread the table. Each failure is a typed `DeviceError` | Lowering functions take the names by which messages refer to their inputs, such as `EgressRules::name` and `PathList::name`, so callers' messages name their own @@ -602,6 +604,8 @@ mount of the host's export and `/` as the roots, and any other guest init or agent can use them too. Any guest init or agent can also run the steps of `nvx-time` and `nvx-reseed` in its own process, through the `nvx::guest::time` and `nvx::guest::reseed` calls, on which both programs run. +Nor is the `gpt` library a program: it reads the partition table of a GPT +disk's block device. | Binary | Program | | --- | --- | diff --git a/nvx-rs/src/guest/gpt.rs b/nvx-rs/src/guest/gpt.rs new file mode 100644 index 000000000..639ff0563 --- /dev/null +++ b/nvx-rs/src/guest/gpt.rs @@ -0,0 +1,126 @@ +//! GPT disks that the guest's kernel attached as block devices. +//! +//! [`read_disk`] reads the partition table of a disk through the disk's block device, such as +//! `/dev/vda`, that the caller opened. It asks the device for its logical block size and its +//! size, as `blockdev --getss` and `blockdev --getsize64` do, and reads and checks the table at +//! that geometry with [`Disk::read`], as [`crate::gpt`] describes, with the caller's [`Expected`] +//! values, such as the disk GUID that a boot parameter names. +//! +//! It only reads the device, which may be open for reading only, at explicit offsets, so it never +//! moves the file's position. The kernel created the disk's partitions, such as `/dev/vda1`, from +//! the table that the disk held when the kernel found it; [`read_disk`] neither compares them +//! with the table that it reads nor asks the kernel to reread the table. +//! +//! ```no_run +//! use std::fs::File; +//! +//! use nvx::gpt::Expected; +//! +//! # fn main() -> Result<(), Box> { +//! let device = File::open("/dev/vda")?; +//! let expected = Expected::new().disk_guid("8d2f3a4e-1c5b-4e7a-9f60-2b3c4d5e6f70".parse()?); +//! let disk = nvx::guest::gpt::read_disk(&device, &expected)?; +//! let configuration = disk.read_first_partition(&device, 64 * 1024)?; +//! println!("the first partition holds {} bytes of configuration", configuration.len()); +//! # Ok(()) +//! # } +//! ``` + +use std::fmt; +use std::fs::File; +use std::io; +use std::os::fd::AsFd; + +use super::sys; +use crate::gpt::{Disk, Expected, GptError}; + +/// Reads the partition table of the block device that `device` opened, at the device's logical +/// block size and size, and checks it and the caller's `expected` values, as the module +/// describes. +pub fn read_disk(device: &File, expected: &Expected) -> Result { + let logical_block_size = sys::logical_block_size(device.as_fd()) + .map_err(|error| DeviceError::LogicalBlockSize(error.into()))?; + let size = + sys::block_device_size(device.as_fd()).map_err(|error| DeviceError::Size(error.into()))?; + Disk::read(device, logical_block_size, size, expected).map_err(DeviceError::Table) +} + +/// Why [`read_disk`] failed: an operation on the block device, or its partition table. +#[derive(Debug)] +#[non_exhaustive] +pub enum DeviceError { + /// The device's logical block size could not be read, as for a file that is not a block + /// device, which fails with `ENOTTY`. + LogicalBlockSize(io::Error), + /// The device's size could not be read. + Size(io::Error), + /// The disk's partition table is invalid, or does not meet the caller's expected values. + Table(GptError), +} + +impl fmt::Display for DeviceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LogicalBlockSize(error) => write!( + formatter, + "failed to read the device's logical block size: {error}" + ), + Self::Size(error) => write!(formatter, "failed to read the device's size: {error}"), + Self::Table(error) => write!(formatter, "invalid partition table: {error}"), + } + } +} + +impl std::error::Error for DeviceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::LogicalBlockSize(error) | Self::Size(error) => Some(error), + Self::Table(error) => Some(error), + } + } +} + +#[cfg(test)] +mod tests { + //! The reads of files that are not block devices, and the errors. + + use std::error::Error as _; + use std::io::Write; + + use super::*; + use crate::guest::sys::Errno; + + #[test] + fn a_file_that_is_not_a_block_device_fails_at_its_logical_block_size() { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&[0; 4096]).unwrap(); + match read_disk(&file, &Expected::new()) { + Err(DeviceError::LogicalBlockSize(error)) => { + assert_eq!(error.raw_os_error(), Some(Errno::ENOTTY as i32)); + } + other => panic!("unexpected result: {other:?}"), + } + } + + #[test] + fn errors_name_what_failed_and_keep_their_source() { + let cases = [ + ( + DeviceError::LogicalBlockSize(io::Error::other("BLKSSZGET failed")), + "failed to read the device's logical block size: BLKSSZGET failed", + ), + ( + DeviceError::Size(io::Error::other("BLKGETSIZE64 failed")), + "failed to read the device's size: BLKGETSIZE64 failed", + ), + ( + DeviceError::Table(GptError::MbrSignature), + "invalid partition table: LBA 0 has no MBR signature", + ), + ]; + for (error, message) in cases { + assert_eq!(error.to_string(), message); + assert!(error.source().is_some(), "{error}"); + } + } +} diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 8d5aad92d..2a8a3c6f8 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -5,8 +5,9 @@ //! accounts, kills, and settles the processes of its executions; [`mappings`], through which it //! bind-mounts host mappings below the roots that it names; [`exec`], which drives each program //! as one execution, with its launch, its bounded output and input, its ending, and its outcome; -//! and [`root`], through which it bootstraps the guest for a layered root, assembles the root, -//! mounts live shares in it, and tears it down. +//! [`root`], through which it bootstraps the guest for a layered root, assembles the root, mounts +//! live shares in it, and tears it down; and [`gpt`], through which it reads the partition table +//! of a GPT disk that the kernel attached as a block device. //! //! Each other module holds one program, and the crate's binary of the same name runs its `main`. //! Two of them also offer their steps as typed calls, which any guest init or agent runs in its @@ -83,6 +84,7 @@ pub mod device_io; pub mod exec; #[cfg(target_arch = "x86_64")] pub mod exit; +pub mod gpt; pub mod ingress; pub mod managed_agent; pub mod mappings; diff --git a/nvx-rs/src/guest/sys/ioctl.rs b/nvx-rs/src/guest/sys/ioctl.rs index 5eac19830..d7f425787 100644 --- a/nvx-rs/src/guest/sys/ioctl.rs +++ b/nvx-rs/src/guest/sys/ioctl.rs @@ -2,6 +2,7 @@ use std::ffi::c_int; use std::mem::size_of; +use std::os::fd::{AsRawFd, BorrowedFd}; use super::{Errno, FileDescriptor}; @@ -49,6 +50,22 @@ nix::ioctl_none!( 97 ); +nix::ioctl_read_bad!( + /// Reads a block device's logical block size: Linux `BLKSSZGET`, `_IO(0x12, 104)`, which + /// writes an `int` although its number declares no argument. + blksszget, + nix::request_code_none!(0x12, 104), + c_int +); + +nix::ioctl_read_bad!( + /// Reads a block device's size in bytes: Linux `BLKGETSIZE64`, `_IOR(0x12, 114, size_t)`, + /// which writes a 64-bit size whatever the size of `size_t`. + blkgetsize64, + nix::request_code_read!(0x12, 114, size_of::()), + u64 +); + /// Returns the number of bytes available for reading from `descriptor`. /// /// The local integer outlives `ioctl`, and the wrapper returns it only after the kernel reports @@ -90,10 +107,35 @@ pub(crate) fn flush_block_buffers(descriptor: &FileDescriptor) -> Result<(), Err Ok(()) } +/// Returns the logical block size, in bytes, of the block device that `descriptor` opened, as +/// `blockdev --getss` prints it. +/// +/// It borrows the descriptor, which it neither reads nor writes, and whose file position it +/// leaves alone, so that a caller may ask this of a device that the standard library opened. +pub(crate) fn logical_block_size(descriptor: BorrowedFd<'_>) -> Result { + let mut size: c_int = 0; + // SAFETY: `size` is writable for one C `int` and outlives the synchronous `BLKSSZGET`. + unsafe { blksszget(descriptor.as_raw_fd(), &raw mut size) }?; + u32::try_from(size).map_err(|_| Errno::EINVAL) +} + +/// Returns the size, in bytes, of the block device that `descriptor` opened, as +/// `blockdev --getsize64` prints it. +/// +/// It borrows the descriptor as `logical_block_size` does. +pub(crate) fn block_device_size(descriptor: BorrowedFd<'_>) -> Result { + let mut size: u64 = 0; + // SAFETY: `size` is writable for one `u64` and outlives the synchronous `BLKGETSIZE64`. + unsafe { blkgetsize64(descriptor.as_raw_fd(), &raw mut size) }?; + Ok(size) +} + #[cfg(test)] mod tests { //! ABI checks for the ioctls. + use std::os::fd::AsFd; + use super::*; #[test] @@ -113,4 +155,27 @@ mod tests { // `BLKFLSBUF` in linux/fs.h. assert_eq!(nix::request_code_none!(0x12, 97) as u32, 0x1261); } + + #[test] + fn the_block_geometry_ioctl_numbers_match_linux() { + // `BLKSSZGET` and `BLKGETSIZE64` in linux/fs.h. The latter is `_IOR(0x12, 114, size_t)`: + // the read direction, then the size of `size_t`. + assert_eq!(nix::request_code_none!(0x12, 104) as u32, 0x1268); + assert_eq!( + nix::request_code_read!(0x12, 114, size_of::()) as u32, + (2 << 30) | ((size_of::() as u32) << 16) | 0x1272 + ); + #[cfg(target_pointer_width = "64")] + assert_eq!( + nix::request_code_read!(0x12, 114, size_of::()) as u32, + 0x8008_1272 + ); + } + + #[test] + fn the_block_geometry_ioctls_fail_on_other_files() { + let file = tempfile::tempfile().unwrap(); + assert_eq!(logical_block_size(file.as_fd()), Err(Errno::ENOTTY)); + assert_eq!(block_device_size(file.as_fd()), Err(Errno::ENOTTY)); + } } diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index efe97cdb1..ce6cb9097 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -87,7 +87,9 @@ pub(crate) use filesystem::{ chown, current_directory, enter_private_mount_namespace, mount, mount_id, path_c_string, path_exists, read_file, read_line, read_number, unmount, write_file, write_number, }; -pub(crate) use ioctl::{console_pending, flush_block_buffers, reseed_random}; +pub(crate) use ioctl::{ + block_device_size, console_pending, flush_block_buffers, logical_block_size, reseed_random, +}; pub(crate) use mapping::MemoryMapping; #[cfg(target_arch = "x86_64")] pub(crate) use msr::{MsrAccess, MsrFile}; diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 6daa4b547..b7be79b0f 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -32,11 +32,11 @@ //! each child that it names (`guest::process`), places, kills, and settles the processes of //! its executions in cgroups (`guest::cgroup`), bind-mounts host mappings below the roots that //! it names (`guest::mappings`), drives each program as one execution, with its launch, bounded -//! output, input, ending, and outcome (`guest::exec`), and bootstraps the guest for a layered -//! root, assembles the root, mounts live shares in it, and tears it down (`guest::root`), and -//! the typed calls through which it runs the time ABI's steps (`guest::time`) and reseeds after -//! a restore (`guest::reseed`) in its own process. The crate's `nvx-*` guest binaries run the -//! programs. +//! output, input, ending, and outcome (`guest::exec`), bootstraps the guest for a layered root, +//! assembles the root, mounts live shares in it, and tears it down (`guest::root`), and reads +//! the partition table of a GPT disk's block device (`guest::gpt`), and the typed calls through +//! which it runs the time ABI's steps (`guest::time`) and reseeds after a restore +//! (`guest::reseed`) in its own process. The crate's `nvx-*` guest binaries run the programs. //! //! # Example //!