Skip to main content

partitions/
mutation.rs

1//! Partition-table mutation API.
2//!
3//! Builds an in-memory partition set from a probe (or from scratch), lets
4//! callers add / remove / resize entries with 1 MiB alignment and a
5//! first-fit free-space finder, and serialises the result on `commit`.
6//!
7//! The C ABI surface in [`crate::capi`] does not yet cover this module — the
8//! current FFI handle the C side knows about is read-only. A follow-up pass
9//! will add a writable handle. Keeping the C ABI frozen for this change
10//! avoids reshuffling consumer integrations while the Rust API settles.
11
12use crate::error::{Error, Result};
13use crate::gpt::{self, type_guids};
14use crate::mbr::{self, types as mbr_types};
15use crate::probe::{Partition, PartitionKind, TableKind};
16use fs_core::{BlockDevice, BlockRead};
17
18/// Spec-mandated GPT slot count.
19const GPT_FIRST_USABLE_LBA: u64 = 34;
20const GPT_BACKUP_RESERVE_SECTORS: u64 = 33; // entry array (32) + backup header (1)
21const SECTOR_SIZE: u64 = 512;
22/// 1 MiB alignment in 512-byte sectors. Most partition-table editors use
23/// this as the default and Windows / macOS treat it as a soft requirement.
24const ALIGNMENT_SECTORS: u64 = 2048;
25/// MBR LBAs are 32-bit. Cap any computed range at this.
26const MBR_LBA_MAX: u64 = 0xFFFF_FFFF;
27
28/// Cross-table-format identifier for "what kind of partition is this?". The
29/// caller picks a logical role and the mutation API translates it to the
30/// right on-disk encoding (GPT type GUID or MBR type byte).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PartitionTypeId {
33    EfiSystem,
34    MicrosoftBasicData,
35    LinuxFilesystem,
36    LinuxSwap,
37    AppleHfsPlus,
38    AppleApfs,
39    /// Escape hatch: a raw type GUID. MBR tables reject this with
40    /// [`Error::Invalid`].
41    GptCustom([u8; 16]),
42    /// Escape hatch: a raw MBR type byte. GPT tables reject this with
43    /// [`Error::Invalid`].
44    MbrCustom(u8),
45}
46
47impl PartitionTypeId {
48    fn to_gpt_guid(self) -> Result<[u8; 16]> {
49        Ok(match self {
50            PartitionTypeId::EfiSystem => type_guids::EFI_SYSTEM,
51            PartitionTypeId::MicrosoftBasicData => type_guids::MICROSOFT_BASIC_DATA,
52            PartitionTypeId::LinuxFilesystem => type_guids::LINUX_FILESYSTEM,
53            PartitionTypeId::LinuxSwap => type_guids::LINUX_SWAP,
54            PartitionTypeId::AppleHfsPlus => type_guids::APPLE_HFS_PLUS,
55            PartitionTypeId::AppleApfs => type_guids::APPLE_APFS,
56            PartitionTypeId::GptCustom(g) => g,
57            PartitionTypeId::MbrCustom(_) => {
58                return Err(Error::Invalid("MBR type byte used in GPT context"))
59            }
60        })
61    }
62    fn to_mbr_byte(self) -> Result<u8> {
63        Ok(match self {
64            PartitionTypeId::EfiSystem => mbr_types::EFI_SYSTEM,
65            PartitionTypeId::MicrosoftBasicData => mbr_types::NTFS_OR_EXFAT,
66            PartitionTypeId::LinuxFilesystem => mbr_types::LINUX,
67            PartitionTypeId::LinuxSwap => mbr_types::LINUX_SWAP,
68            PartitionTypeId::AppleHfsPlus => mbr_types::HFS_PLUS,
69            // No legacy MBR encoding for APFS — callers should use GPT.
70            PartitionTypeId::AppleApfs => {
71                return Err(Error::Invalid("APFS has no MBR type byte; use GPT"));
72            }
73            PartitionTypeId::MbrCustom(b) => b,
74            PartitionTypeId::GptCustom(_) => {
75                return Err(Error::Invalid("GPT type GUID used in MBR context"))
76            }
77        })
78    }
79}
80
81/// How callers identify a partition for `remove` / `resize`. Index is
82/// always accepted; UUID is GPT-only.
83#[derive(Debug, Clone, Copy)]
84pub enum PartitionRef {
85    Index(usize),
86    Uuid([u8; 16]),
87}
88
89/// In-memory mutable partition set. All edits stay in this struct until
90/// `commit` writes them back.
91#[derive(Debug, Clone)]
92pub struct PartitionSet {
93    pub table_kind: TableKind,
94    pub partitions: Vec<Partition>,
95    pub disk_size: u64,
96    /// Disk GUID for the GPT header. Ignored when `table_kind == Mbr`.
97    pub disk_guid: [u8; 16],
98}
99
100impl PartitionSet {
101    /// Probe the device and return its current partition set, ready for
102    /// editing. The disk GUID for GPT is read from the on-disk header; for
103    /// MBR it is left at all-zeros and unused.
104    pub fn from_probe(dev: &dyn BlockRead) -> Result<Self> {
105        let (table_kind, partitions) = crate::probe(dev)?;
106        let disk_size = dev.size_bytes();
107        let disk_guid = if table_kind == TableKind::Gpt {
108            // Re-read LBA 1 to pull the disk GUID out.
109            let mut sector = [0u8; 512];
110            dev.read_at(SECTOR_SIZE, &mut sector)?;
111            gpt::parse_header(&sector)?.disk_guid
112        } else {
113            [0u8; 16]
114        };
115        Ok(PartitionSet {
116            table_kind,
117            partitions,
118            disk_size,
119            disk_guid,
120        })
121    }
122
123    /// Empty GPT table sized for `disk_size` bytes. Generates a v4 disk GUID.
124    pub fn empty_gpt(disk_size: u64) -> Self {
125        PartitionSet {
126            table_kind: TableKind::Gpt,
127            partitions: Vec::new(),
128            disk_size,
129            disk_guid: random_uuid(),
130        }
131    }
132
133    /// Empty MBR table sized for `disk_size` bytes.
134    pub fn empty_mbr(disk_size: u64) -> Self {
135        PartitionSet {
136            table_kind: TableKind::Mbr,
137            partitions: Vec::new(),
138            disk_size,
139            disk_guid: [0u8; 16],
140        }
141    }
142
143    /// Add a partition. `start_hint = None` triggers a first-fit search at
144    /// 1 MiB alignment. A non-`None` hint that isn't already aligned is
145    /// rounded up to the next 1 MiB boundary, and `length` is rounded up to
146    /// the next sector if it isn't already a multiple of 512.
147    ///
148    /// Returns the index of the newly added partition.
149    pub fn add(
150        &mut self,
151        start_hint: Option<u64>,
152        length: u64,
153        type_id: PartitionTypeId,
154        label: Option<String>,
155    ) -> Result<usize> {
156        if length == 0 {
157            return Err(Error::Invalid("zero length"));
158        }
159        if self.table_kind == TableKind::Mbr && self.partitions.len() >= 4 {
160            return Err(Error::Invalid("MBR primary table is full (4 entries)"));
161        }
162        if self.table_kind == TableKind::Gpt && self.partitions.len() >= 128 {
163            return Err(Error::Invalid("GPT canonical table is full (128 entries)"));
164        }
165
166        // Round length up to a sector multiple.
167        let length_sectors = length.div_ceil(SECTOR_SIZE);
168        // Round length up to alignment too — trailing tail bytes inside an
169        // unaligned region are unreachable to most consumers, so it is
170        // friendlier to grow than to leave a dangling stub.
171        let length_sectors = align_up(length_sectors, ALIGNMENT_SECTORS);
172
173        let (first_usable_lba, last_usable_lba) = self.usable_range();
174        if first_usable_lba > last_usable_lba {
175            return Err(Error::DeviceTooSmall);
176        }
177
178        let start_lba = match start_hint {
179            Some(byte) => {
180                let raw_sectors = byte / SECTOR_SIZE + if byte % SECTOR_SIZE != 0 { 1 } else { 0 };
181                let aligned = align_up(raw_sectors.max(first_usable_lba), ALIGNMENT_SECTORS);
182                if aligned < first_usable_lba {
183                    return Err(Error::Invalid("hinted start before first usable LBA"));
184                }
185                aligned
186            }
187            None => self.find_free(length_sectors, first_usable_lba, last_usable_lba)?,
188        };
189
190        let end_lba = start_lba
191            .checked_add(length_sectors - 1)
192            .ok_or(Error::Invalid("partition end overflows"))?;
193        if end_lba > last_usable_lba {
194            return Err(Error::Invalid("partition extends past last usable LBA"));
195        }
196        if self.table_kind == TableKind::Mbr
197            && (start_lba > MBR_LBA_MAX || length_sectors > MBR_LBA_MAX)
198        {
199            return Err(Error::Invalid("partition exceeds MBR 32-bit LBA range"));
200        }
201
202        // Overlap check against existing partitions.
203        for p in &self.partitions {
204            let p_start = p.start / SECTOR_SIZE;
205            let p_end = (p.start + p.length) / SECTOR_SIZE - 1;
206            if start_lba <= p_end && end_lba >= p_start {
207                return Err(Error::Invalid("partition overlaps existing entry"));
208            }
209        }
210
211        // New partitions default to non-bootable / no special attributes.
212        // Callers that want to mark an active partition or set GPT
213        // attribute bits should mutate the partition kind in-place after
214        // the add() call.
215        let kind = match self.table_kind {
216            TableKind::Gpt => PartitionKind::Gpt {
217                type_guid: type_id.to_gpt_guid()?,
218                attributes: 0,
219            },
220            TableKind::Mbr => PartitionKind::Mbr {
221                type_byte: type_id.to_mbr_byte()?,
222                active: false,
223            },
224        };
225        let uuid = if self.table_kind == TableKind::Gpt {
226            Some(random_uuid())
227        } else {
228            None
229        };
230
231        let part = Partition {
232            start: start_lba * SECTOR_SIZE,
233            length: length_sectors * SECTOR_SIZE,
234            kind,
235            label,
236            uuid,
237        };
238        let idx = self.partitions.len();
239        self.partitions.push(part);
240        Ok(idx)
241    }
242
243    /// Remove a partition. Returns [`Error::Invalid`] if the ref doesn't
244    /// match anything.
245    pub fn remove(&mut self, target: PartitionRef) -> Result<()> {
246        let idx = self.resolve(target)?;
247        self.partitions.remove(idx);
248        Ok(())
249    }
250
251    /// Resize a partition. The new length is rounded up to a sector multiple
252    /// and re-validated against the device bounds and other partitions.
253    pub fn resize(&mut self, target: PartitionRef, new_length: u64) -> Result<()> {
254        if new_length == 0 {
255            return Err(Error::Invalid("zero length"));
256        }
257        let idx = self.resolve(target)?;
258        let new_length_sectors = align_up(new_length.div_ceil(SECTOR_SIZE), ALIGNMENT_SECTORS);
259        let (_, last_usable_lba) = self.usable_range();
260
261        let start_lba = self.partitions[idx].start / SECTOR_SIZE;
262        let new_end_lba = start_lba
263            .checked_add(new_length_sectors - 1)
264            .ok_or(Error::Invalid("partition end overflows"))?;
265        if new_end_lba > last_usable_lba {
266            return Err(Error::Invalid("resize extends past last usable LBA"));
267        }
268        if self.table_kind == TableKind::Mbr && new_length_sectors > MBR_LBA_MAX {
269            return Err(Error::Invalid("resize exceeds MBR 32-bit LBA range"));
270        }
271
272        // Overlap check excluding self.
273        for (j, p) in self.partitions.iter().enumerate() {
274            if j == idx {
275                continue;
276            }
277            let p_start = p.start / SECTOR_SIZE;
278            let p_end = (p.start + p.length) / SECTOR_SIZE - 1;
279            if start_lba <= p_end && new_end_lba >= p_start {
280                return Err(Error::Invalid("resize would overlap another partition"));
281            }
282        }
283        self.partitions[idx].length = new_length_sectors * SECTOR_SIZE;
284        Ok(())
285    }
286
287    /// Serialise to disk. Dispatches to the GPT or MBR writer depending on
288    /// `table_kind`, then flushes the device.
289    pub fn commit(&self, dev: &dyn BlockDevice) -> Result<()> {
290        match self.table_kind {
291            TableKind::Gpt => {
292                crate::gpt_write::write_gpt(dev, &self.partitions, self.disk_guid)?;
293            }
294            TableKind::Mbr => {
295                mbr::write_mbr(dev, &self.partitions)?;
296            }
297        }
298        dev.flush()?;
299        Ok(())
300    }
301
302    // --- helpers -----------------------------------------------------------
303
304    fn usable_range(&self) -> (u64, u64) {
305        let total_sectors = self.disk_size / SECTOR_SIZE;
306        match self.table_kind {
307            TableKind::Gpt => {
308                if total_sectors < GPT_FIRST_USABLE_LBA + GPT_BACKUP_RESERVE_SECTORS + 1 {
309                    return (GPT_FIRST_USABLE_LBA, GPT_FIRST_USABLE_LBA - 1);
310                }
311                let last = total_sectors - 1 - GPT_BACKUP_RESERVE_SECTORS;
312                (GPT_FIRST_USABLE_LBA, last)
313            }
314            TableKind::Mbr => {
315                if total_sectors < 2 {
316                    return (1, 0);
317                }
318                let last = (total_sectors - 1).min(MBR_LBA_MAX);
319                (1, last)
320            }
321        }
322    }
323
324    fn resolve(&self, target: PartitionRef) -> Result<usize> {
325        match target {
326            PartitionRef::Index(i) => {
327                if i >= self.partitions.len() {
328                    return Err(Error::Invalid("index out of range"));
329                }
330                Ok(i)
331            }
332            PartitionRef::Uuid(u) => self
333                .partitions
334                .iter()
335                .position(|p| p.uuid == Some(u))
336                .ok_or(Error::Invalid("uuid not found")),
337        }
338    }
339
340    /// First-fit free-space finder, walking partitions sorted by start LBA.
341    fn find_free(&self, length_sectors: u64, first_usable: u64, last_usable: u64) -> Result<u64> {
342        let mut sorted: Vec<&Partition> = self.partitions.iter().collect();
343        sorted.sort_by_key(|p| p.start);
344
345        let mut cursor = align_up(first_usable, ALIGNMENT_SECTORS);
346        for p in &sorted {
347            let p_start = p.start / SECTOR_SIZE;
348            let p_end = (p.start + p.length) / SECTOR_SIZE - 1;
349            if cursor + length_sectors - 1 < p_start {
350                // Gap before this partition is big enough.
351                if cursor + length_sectors - 1 <= last_usable {
352                    return Ok(cursor);
353                }
354            }
355            // Move cursor past this partition, re-aligned.
356            cursor = align_up(p_end + 1, ALIGNMENT_SECTORS);
357        }
358        if cursor + length_sectors - 1 <= last_usable {
359            Ok(cursor)
360        } else {
361            Err(Error::Invalid("no free space large enough"))
362        }
363    }
364}
365
366/// Round `value` up to the next multiple of `align`. `align` must be > 0.
367fn align_up(value: u64, align: u64) -> u64 {
368    debug_assert!(align > 0);
369    let rem = value % align;
370    if rem == 0 {
371        value
372    } else {
373        value + (align - rem)
374    }
375}
376
377// ---------------------------------------------------------------------------
378// Random UUID generator
379// ---------------------------------------------------------------------------
380//
381// Mints RFC 4122 v4 (random) UUIDs without pulling in `uuid` or `rand`.
382// Strategy:
383//   1. Try `/dev/urandom` on Unix-like systems — every POSIX kernel exposes
384//      it, and a single 16-byte read is unhindered by buffering or
385//      locale.
386//   2. Fall back to a small xorshift64 PRNG seeded from `SystemTime` and
387//      `ProcessId`. This branch is only meant for non-Unix (Windows) and is
388//      not cryptographically strong, but UUIDs only need to be unique
389//      within a partition table, not unguessable.
390//
391// The version (high nibble of byte 7) and variant (high two bits of byte 8)
392// are stamped per RFC 4122 §4.4.
393
394pub(crate) fn random_uuid() -> [u8; 16] {
395    let mut bytes = [0u8; 16];
396    if !fill_from_urandom(&mut bytes) {
397        fill_from_prng(&mut bytes);
398    }
399    // Version 4 (random): high nibble of byte 7.
400    bytes[7] = (bytes[7] & 0x0F) | 0x40;
401    // Variant 10xxxxxx: high two bits of byte 8.
402    bytes[8] = (bytes[8] & 0x3F) | 0x80;
403    bytes
404}
405
406#[cfg(unix)]
407fn fill_from_urandom(buf: &mut [u8]) -> bool {
408    use std::io::Read;
409    match std::fs::File::open("/dev/urandom") {
410        Ok(mut f) => f.read_exact(buf).is_ok(),
411        Err(_) => false,
412    }
413}
414
415#[cfg(not(unix))]
416fn fill_from_urandom(_buf: &mut [u8]) -> bool {
417    false
418}
419
420fn fill_from_prng(buf: &mut [u8]) {
421    use std::time::{SystemTime, UNIX_EPOCH};
422    let nanos = SystemTime::now()
423        .duration_since(UNIX_EPOCH)
424        .map(|d| d.as_nanos() as u64)
425        .unwrap_or(0x9E37_79B9_7F4A_7C15);
426    let pid = std::process::id() as u64;
427    let mut state = nanos.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid);
428    if state == 0 {
429        state = 0x9E37_79B9_7F4A_7C15;
430    }
431    for chunk in buf.chunks_mut(8) {
432        // xorshift64
433        state ^= state << 13;
434        state ^= state >> 7;
435        state ^= state << 17;
436        let b = state.to_le_bytes();
437        chunk.copy_from_slice(&b[..chunk.len()]);
438    }
439}