Skip to main content

bux_e2fs/
ext4.rs

1//! Safe, high-level API for creating and manipulating ext4 filesystem images.
2//!
3//! The central type is [`Filesystem`] — an RAII wrapper around `ext2_filsys`
4//! that guarantees resource cleanup via [`Drop`]. All unsafe FFI interactions
5//! are confined to this module.
6//!
7//! For common use cases, the module-level convenience functions
8//! [`create_from_dir`] and [`inject_file`] compose `Filesystem` operations
9//! into single calls.
10
11#![allow(unsafe_code)]
12#![allow(
13    clippy::cast_possible_truncation,
14    clippy::cast_possible_wrap,
15    clippy::cast_sign_loss
16)]
17
18use std::ffi::CString;
19use std::path::Path;
20
21use crate::error::{Error, Result};
22use crate::sys;
23
24/// Block size for an ext4 filesystem.
25#[non_exhaustive]
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[repr(u32)]
28pub enum BlockSize {
29    /// 1024 bytes.
30    B1024 = 0,
31    /// 2048 bytes.
32    B2048 = 1,
33    /// 4096 bytes (default, recommended).
34    B4096 = 2,
35}
36
37impl BlockSize {
38    /// Returns the block size in bytes.
39    #[must_use]
40    pub const fn bytes(self) -> u32 {
41        match self {
42            Self::B1024 => 1024,
43            Self::B2048 => 2048,
44            Self::B4096 => 4096,
45        }
46    }
47}
48
49/// Options for creating a new ext4 filesystem.
50#[non_exhaustive]
51#[derive(Debug, Clone, Copy)]
52pub struct CreateOptions {
53    /// Block size (default: 4096).
54    pub block_size: BlockSize,
55    /// Reserved block percentage, 0–50 (default: 0 for containers).
56    pub reserved_ratio: u8,
57}
58
59impl Default for CreateOptions {
60    fn default() -> Self {
61        Self {
62            block_size: BlockSize::B4096,
63            reserved_ratio: 0,
64        }
65    }
66}
67
68/// File type for directory entries (maps to `EXT2_FT_*` constants).
69#[non_exhaustive]
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[repr(i32)]
72pub enum FileType {
73    /// Unknown or unspecified file type.
74    Unknown = 0,
75    /// Regular file.
76    RegularFile = 1,
77    /// Directory.
78    Directory = 2,
79    /// Character device.
80    CharDevice = 3,
81    /// Block device.
82    BlockDevice = 4,
83    /// Named pipe (FIFO).
84    Fifo = 5,
85    /// Unix domain socket.
86    Socket = 6,
87    /// Symbolic link.
88    Symlink = 7,
89}
90
91/// RAII wrapper around an `ext2_filsys` handle.
92///
93/// [`Drop`] flushes and closes the filesystem, preventing resource leaks
94/// even when operations fail or panic.
95///
96/// # Example
97///
98/// ```no_run
99/// use std::path::Path;
100/// use bux_e2fs::{Filesystem, CreateOptions};
101///
102/// let mut fs = Filesystem::create(
103///     Path::new("/tmp/image.raw"),
104///     512 * 1024 * 1024,
105///     &CreateOptions::default(),
106/// ).unwrap();
107/// fs.populate(Path::new("/tmp/rootfs")).unwrap();
108/// fs.add_journal().unwrap();
109/// // Drop closes the filesystem automatically.
110/// ```
111pub struct Filesystem {
112    /// Raw libext2fs filesystem handle.
113    inner: sys::ext2_filsys,
114}
115
116impl std::fmt::Debug for Filesystem {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("Filesystem")
119            .field("open", &!self.inner.is_null())
120            .finish()
121    }
122}
123
124impl Drop for Filesystem {
125    fn drop(&mut self) {
126        if !self.inner.is_null() {
127            // ext2fs operations (populate_fs, add_journal, etc.) set EXT2_FLAG_DIRTY
128            // internally. ext2fs_close checks the flag and flushes if set.
129            // We do NOT force-dirty here to avoid flushing partially-initialized images.
130            unsafe {
131                let _ = sys::ext2fs_close(self.inner);
132            }
133        }
134    }
135}
136
137impl Filesystem {
138    /// Creates a new ext4 filesystem image at `path`.
139    ///
140    /// Equivalent to `mke2fs -t ext4 -b <block_size> -m <reserved> <path> <size>`.
141    pub fn create(path: &Path, size_bytes: u64, opts: &CreateOptions) -> Result<Self> {
142        let c_path = to_cstring(path)?;
143        let bs = opts.block_size;
144        let blocks = size_bytes / u64::from(bs.bytes());
145        let reserved = blocks * u64::from(opts.reserved_ratio) / 100;
146
147        unsafe {
148            let mut fs: sys::ext2_filsys = std::ptr::null_mut();
149            let mut param: sys::ext2_super_block = std::mem::zeroed();
150            param.s_blocks_count = blocks as u32;
151            param.s_log_block_size = bs as u32;
152            param.s_rev_level = sys::EXT2_DYNAMIC_REV;
153            param.s_r_blocks_count = reserved as u32;
154
155            check(
156                "ext2fs_initialize",
157                sys::ext2fs_initialize(
158                    c_path.as_ptr(),
159                    sys::EXT2_FLAG_EXCLUSIVE as i32,
160                    std::ptr::from_mut(&mut param),
161                    sys::unix_io_manager,
162                    std::ptr::from_mut(&mut fs),
163                ),
164            )?;
165
166            // Wrap immediately — Drop guarantees cleanup if allocate_tables fails.
167            let this = Self { inner: fs };
168            check(
169                "ext2fs_allocate_tables",
170                sys::ext2fs_allocate_tables(this.inner),
171            )?;
172            Ok(this)
173        }
174    }
175
176    /// Opens an existing ext4 image for read-write operations.
177    pub fn open(path: &Path) -> Result<Self> {
178        let c_path = to_cstring(path)?;
179
180        unsafe {
181            let mut fs: sys::ext2_filsys = std::ptr::null_mut();
182            check(
183                "ext2fs_open",
184                sys::ext2fs_open(
185                    c_path.as_ptr(),
186                    sys::EXT2_FLAG_RW as i32,
187                    0,
188                    0,
189                    sys::unix_io_manager,
190                    std::ptr::from_mut(&mut fs),
191                ),
192            )?;
193            Ok(Self { inner: fs })
194        }
195    }
196
197    /// Populates the filesystem from a host directory.
198    ///
199    /// Recursively copies all files, directories, symlinks, and permissions
200    /// from `source_dir` into the image root.
201    pub fn populate(&mut self, source_dir: &Path) -> Result<()> {
202        let c_src = to_cstring(source_dir)?;
203        unsafe {
204            check(
205                "populate_fs",
206                sys::populate_fs(
207                    self.inner,
208                    sys::EXT2_ROOT_INO,
209                    c_src.as_ptr(),
210                    sys::EXT2_ROOT_INO,
211                ),
212            )
213        }
214    }
215
216    /// Flushes all pending changes to disk without closing the filesystem.
217    pub fn flush(&mut self) -> Result<()> {
218        unsafe { check("ext2fs_flush", sys::ext2fs_flush(self.inner)) }
219    }
220
221    /// Writes a single host file into the filesystem image.
222    ///
223    /// Equivalent to `debugfs -w -R "write <host_path> <guest_path>"`.
224    pub fn write_file(&mut self, host_path: &Path, guest_path: &str) -> Result<()> {
225        let c_host = to_cstring(host_path)?;
226        let c_guest = str_to_cstring(guest_path)?;
227        unsafe {
228            check(
229                "do_write_internal",
230                sys::do_write_internal(
231                    self.inner,
232                    sys::EXT2_ROOT_INO,
233                    c_host.as_ptr(),
234                    c_guest.as_ptr(),
235                    sys::EXT2_ROOT_INO,
236                ),
237            )
238        }
239    }
240
241    /// Creates a directory inside the filesystem image.
242    pub fn mkdir(&mut self, name: &str) -> Result<()> {
243        let c_name = str_to_cstring(name)?;
244        unsafe {
245            check(
246                "do_mkdir_internal",
247                sys::do_mkdir_internal(
248                    self.inner,
249                    sys::EXT2_ROOT_INO,
250                    c_name.as_ptr(),
251                    sys::EXT2_ROOT_INO,
252                ),
253            )
254        }
255    }
256
257    /// Creates a symlink inside the filesystem image.
258    pub fn symlink(&mut self, name: &str, target: &str) -> Result<()> {
259        let c_name = str_to_cstring(name)?;
260        let c_target = str_to_cstring(target)?;
261        unsafe {
262            check(
263                "do_symlink_internal",
264                sys::do_symlink_internal(
265                    self.inner,
266                    sys::EXT2_ROOT_INO,
267                    c_name.as_ptr(),
268                    c_target.as_ptr().cast_mut(),
269                    sys::EXT2_ROOT_INO,
270                ),
271            )
272        }
273    }
274
275    /// Adds an ext3/4 journal (size auto-calculated by libext2fs).
276    pub fn add_journal(&mut self) -> Result<()> {
277        unsafe {
278            check(
279                "ext2fs_add_journal_inode",
280                sys::ext2fs_add_journal_inode(self.inner, 0, 0),
281            )
282        }
283    }
284
285    /// Creates a directory entry linking `name` to inode `ino` in directory `dir`.
286    pub fn link(&mut self, dir: u32, name: &str, ino: u32, file_type: FileType) -> Result<()> {
287        let c_name = str_to_cstring(name)?;
288        unsafe {
289            check(
290                "ext2fs_link",
291                sys::ext2fs_link(self.inner, dir, c_name.as_ptr(), ino, file_type as i32),
292            )
293        }
294    }
295
296    /// Reads the on-disk inode structure for the given inode number.
297    pub fn read_inode(&self, ino: u32) -> Result<sys::ext2_inode> {
298        unsafe {
299            let mut inode: sys::ext2_inode = std::mem::zeroed();
300            check(
301                "ext2fs_read_inode",
302                sys::ext2fs_read_inode(self.inner, ino, &raw mut inode),
303            )?;
304            Ok(inode)
305        }
306    }
307
308    /// Writes the inode structure back to the filesystem.
309    pub fn write_inode(&mut self, ino: u32, inode: &sys::ext2_inode) -> Result<()> {
310        unsafe {
311            let mut copy = *inode;
312            check(
313                "ext2fs_write_inode",
314                sys::ext2fs_write_inode(self.inner, ino, &raw mut copy),
315            )
316        }
317    }
318
319    /// Writes a freshly allocated inode (initializes the on-disk slot).
320    pub fn write_new_inode(&mut self, ino: u32, inode: &sys::ext2_inode) -> Result<()> {
321        unsafe {
322            let mut copy = *inode;
323            check(
324                "ext2fs_write_new_inode",
325                sys::ext2fs_write_new_inode(self.inner, ino, &raw mut copy),
326            )
327        }
328    }
329
330    /// Allocates a new inode number near `dir` with the given POSIX `mode`.
331    ///
332    /// Updates the inode bitmap and allocation statistics automatically.
333    /// Requires bitmaps to be loaded (always true after [`create`](Self::create)).
334    pub fn alloc_inode(&mut self, dir: u32, mode: u16) -> Result<u32> {
335        unsafe {
336            let mut ino: sys::ext2_ino_t = 0;
337            let map = (*self.inner).inode_map;
338            check(
339                "ext2fs_new_inode",
340                sys::ext2fs_new_inode(self.inner, dir, i32::from(mode), map, &raw mut ino),
341            )?;
342            let is_dir = i32::from(mode & 0o040_000 != 0);
343            sys::ext2fs_inode_alloc_stats2(self.inner, ino, 1, is_dir);
344            Ok(ino)
345        }
346    }
347
348    /// Allocates a new block near `goal`.
349    ///
350    /// Updates the block bitmap and allocation statistics automatically.
351    /// Requires bitmaps to be loaded (always true after [`create`](Self::create)).
352    pub fn alloc_block(&mut self, goal: u64) -> Result<u64> {
353        unsafe {
354            let mut blk: sys::blk64_t = 0;
355            let map = (*self.inner).block_map;
356            check(
357                "ext2fs_new_block2",
358                sys::ext2fs_new_block2(self.inner, goal, map, &raw mut blk),
359            )?;
360            sys::ext2fs_block_alloc_stats2(self.inner, blk, 1);
361            Ok(blk)
362        }
363    }
364}
365
366/// Creates an ext4 image populated from a host directory.
367///
368/// This is the primary convenience function combining [`Filesystem::create`],
369/// [`Filesystem::populate`], and [`Filesystem::add_journal`].
370///
371/// # Example
372///
373/// ```no_run
374/// use std::path::Path;
375///
376/// let size = bux_e2fs::estimate_image_size(Path::new("/tmp/rootfs")).unwrap();
377/// bux_e2fs::create_from_dir(
378///     Path::new("/tmp/rootfs"),
379///     Path::new("/tmp/image.raw"),
380///     size,
381/// ).unwrap();
382/// ```
383pub fn create_from_dir(source_dir: &Path, output: &Path, size_bytes: u64) -> Result<()> {
384    let mut fs = Filesystem::create(output, size_bytes, &CreateOptions::default())?;
385    fs.populate(source_dir)?;
386    fs.add_journal()?;
387    Ok(())
388}
389
390/// Injects a single host file into an existing ext4 image.
391///
392/// Equivalent to `debugfs -w -R "write <host_file> <guest_path>" <image>`.
393pub fn inject_file(image: &Path, host_file: &Path, guest_path: &str) -> Result<()> {
394    let mut fs = Filesystem::open(image)?;
395    fs.write_file(host_file, guest_path)
396}
397
398/// Estimates the required image size for a directory tree.
399///
400/// Accounts for file content, inode overhead, ext4 metadata, and journal.
401/// Returns the recommended image size in bytes (minimum 256 MiB).
402pub fn estimate_image_size(dir: &Path) -> Result<u64> {
403    let mut total_bytes: u64 = 0;
404    let mut inode_count: u64 = 0;
405
406    walk(dir, &mut |meta| {
407        inode_count += 1;
408        if meta.is_file() {
409            // Round up to 4 KiB block boundary.
410            total_bytes += (meta.len() + 4095) & !4095;
411        } else if meta.is_dir() {
412            total_bytes += 4096;
413        } else if meta.is_symlink() && meta.len() > 60 {
414            // Symlink targets <= 60 bytes are stored inline in the inode.
415            // Longer targets need a data block.
416            total_bytes += 4096;
417        }
418    })?;
419
420    // 256 bytes per inode + 10% metadata overhead + 64 MiB journal.
421    let raw = total_bytes + inode_count * 256;
422    let sized = raw * 11 / 10 + 64 * 1024 * 1024;
423    Ok(sized.max(256 * 1024 * 1024))
424}
425
426/// Checks a libext2fs `errcode_t`, converting non-zero values to [`Error::Ext2fs`].
427const fn check(op: &'static str, code: sys::errcode_t) -> Result<()> {
428    if code == 0 {
429        Ok(())
430    } else {
431        Err(Error::Ext2fs { op, code })
432    }
433}
434
435/// Converts a [`Path`] to a [`CString`].
436fn to_cstring(path: &Path) -> Result<CString> {
437    let s = path
438        .to_str()
439        .ok_or_else(|| Error::InvalidPath(path.display().to_string()))?;
440    CString::new(s).map_err(|e| Error::InvalidPath(e.to_string()))
441}
442
443/// Converts a `&str` to a [`CString`].
444fn str_to_cstring(s: &str) -> Result<CString> {
445    CString::new(s).map_err(|e| Error::InvalidPath(e.to_string()))
446}
447
448/// Walks a directory tree, calling `f` for each entry's metadata.
449/// Uses `symlink_metadata` to avoid following symlinks.
450fn walk(dir: &Path, f: &mut impl FnMut(&std::fs::Metadata)) -> Result<()> {
451    for entry in std::fs::read_dir(dir)? {
452        let path = entry?.path();
453        if let Ok(meta) = path.symlink_metadata() {
454            f(&meta);
455            if meta.is_dir() {
456                walk(&path, f)?;
457            }
458        }
459    }
460    Ok(())
461}