Skip to main content

Filesystem

Struct Filesystem 

Source
pub struct Filesystem {
    pub dev: Arc<dyn BlockDevice>,
    pub sb: Superblock,
    pub groups: Vec<BlockGroupDescriptor>,
    pub csum: Checksummer,
    pub flavor: FsFlavor,
    pub journal: Option<Mutex<JournalWriter>>,
    /* private fields */
}

Fields§

§dev: Arc<dyn BlockDevice>§sb: Superblock§groups: Vec<BlockGroupDescriptor>§csum: Checksummer§flavor: FsFlavor

Dialect detected at mount time from the superblock’s feature flags. Drives runtime dispatch where ext2 / ext3 / ext4 differ — most notably the inode block-mapping scheme (extent vs indirect) used when allocating new inodes.

§journal: Option<Mutex<JournalWriter>>

Live-write journal writer, present iff the FS has a journal AND the device is writable. None for read-only mounts and for ext2- style images. Locked per-op so mutating capi calls serialize on the JBD2 sequence cursor.

Implementations§

Source§

impl Filesystem

Source

pub fn mount(dev: Arc<dyn BlockDevice>) -> Result<Self>

Mount the ext4 filesystem on dev. Read-only unless the device reports is_writable(), in which case a dirty journal is replayed before returning so callers see a consistent on-disk state.

When RO_COMPAT_METADATA_CSUM is set, the superblock checksum is verified — failure aborts the mount with Error::BadChecksum.

Source

pub fn mount_lazy(dev: Arc<dyn BlockDevice>) -> Result<Self>

Like mount, but skips the mount-time journal replay even when the device is writable. The caller is responsible for invoking Filesystem::replay_journal_if_dirty once the underlying write path is actually ready to service writes (e.g. in the FSKit case the kernel-level write FD on FSBlockDeviceResource only becomes writable AFTER loadResource returns successfully — replaying mid- loadResource produces EIO).

Until replay runs, reads observe the on-disk pre-replay state and any write through this handle will fail (the journal still says dirty). This is the lazy/deferred-replay sibling of mount; for most callers mount is correct.

Source

pub fn replay_journal_if_dirty(&self) -> Result<usize>

Run journal replay now if the journal is dirty. Idempotent — calling this on a clean (or read-only) volume is a no-op that returns 0. Designed to pair with Filesystem::mount_lazy, but safe to call on any handle.

Source

pub fn orphan_list(&self) -> Result<Vec<u32>>

Phase 6.1 — walk the orphan inode chain rooted at s_last_orphan and return its members in chain order.

Each orphan inode is a unlink-while-open candidate: its data blocks should be reclaimed by recovery. The chain is encoded by overloading i_dtime as “next orphan inode number”; the chain terminates when dtime == 0. We cap at inodes_count to avoid runaway loops on cycle-corrupted images.

Read-only (no recovery yet — that’s Phase 6.2). Returns Ok([]) when there are no orphans.

Source

pub fn recover_orphans(&self) -> Result<usize>

Phase 6.2 — orphan replay. For each inode on the s_last_orphan chain, free its data blocks + inode-bitmap slot, zero its inode body (with i_dtime = now), and clear s_last_orphan. Runs as ONE multi-block journaled transaction so a crash mid-recovery either commits all the frees or none of them.

Returns the number of orphan inodes reclaimed. No-op (returns 0) when the chain is empty or the device is read-only.

Designed to be called from the mount path AFTER journal replay, so the orphans we’re about to reclaim are guaranteed not still in use by an in-flight kernel-level transaction.

Source

pub fn read_block(&self, block_num: u64) -> Result<Vec<u8>>

Read a whole block by its logical block number. Routes through self.dev, which at mount time is wrapped in a CachedDevice — so this single call benefits from the buffer cache that holds post-commit, pre-checkpoint journaled writes.

Source

pub fn read_inode_raw(&self, ino: u32) -> Result<Vec<u8>>

Read raw inode bytes for a given inode number (does not parse).

Source

pub fn read_inode_verified(&self, ino: u32) -> Result<(Inode, Vec<u8>)>

Read + parse + checksum-verify an inode in one shot.

When RO_COMPAT_METADATA_CSUM is enabled the inode CRC32C is checked (salted by inode number + generation per ext4 spec). A mismatch returns Error::BadChecksum { what: "inode" }.

Source

pub fn map_inode_logical( &self, inode: &Inode, logical_block: u64, ) -> Result<Option<u64>>

Map a logical block within inode to its physical block, choosing between the extent tree and the legacy direct/indirect scheme based on EXT4_EXTENTS_FL. Returns None for sparse holes and (for the extent path) uninitialised extents — callers wanting zeros there must handle the None case explicitly.

This is the per-inode dispatcher every directory traversal / extent-walking call site should use instead of touching extent::map_logical directly — without it, an ext2/3 inode with raw block pointers in i_block gets misparsed as an extent header (yielding CorruptExtentTree("bad extent header magic")).

The indirect path internally maintains its own block cache for the duration of the call; sequential lookups via repeated calls don’t share that cache (file_io’s read paths build a longer-lived cache to amortize across blocks).

Source

pub fn write_inode_raw(&self, ino: u32, raw: &[u8]) -> Result<()>

Write the given raw inode bytes back to disk. Read-only devices return the default Error::Corrupt from BlockDevice::write_at.

Not checksum-aware: callers that update fields affecting the inode CRC32C (anything except checksum_lo / checksum_hi) must recompute

  • patch the checksum into raw before calling this. Not wrapped in a journal transaction — see E11 / journal_apply for the journaled version. Use only when the caller has the full write-ordering story under control.
Source

pub fn patch_inode_size_and_blocks( raw: &mut [u8], new_size: u64, new_block_count: u64, ) -> Result<()>

Patch fields in a raw inode image: size, blocks_count. Leaves all other bytes (including the extent tree header + entries in i_block) intact. new_block_count is in 512-byte sectors per spec (same convention as Inode::blocks).

Source

pub fn patch_inode_block_area(raw: &mut [u8], new_root: &[u8]) -> Result<()>

Overwrite the 60-byte i_block area of an inode image with new_root. Used when an extent-tree mutation changes the inline root.

Source

pub fn apply_truncate_shrink(&self, ino: u32, new_size: u64) -> Result<()>

Shrink a file to new_size. Composes file_mut::plan_truncate_shrink (extent-tree updates + freed-block ranges) with actual disk writes — rewrites the inode and zeros the freed bitmap bits.

Journaled. The inode write, the bitmap writes, the BGD and the superblock accumulate into one BlockBuffer and commit as a single transaction, so they are atomic with respect to a crash.

This said “Not journaled … safe only in a test scratch image”, and promised the transaction as future work. The future work landed; the warning outlived it and was steering callers away from an API that is safe.

Source

pub fn apply_truncate_grow(&self, ino: u32, new_size: u64) -> Result<()>

Extend a file to new_size. The new range is a sparse hole — ext4’s extent tree treats unmapped logical blocks as zeros, so no extent mutation and no block allocation are required. Only i_size, i_mtime, i_ctime, and the inode checksum change.

Caller (capi dispatch) guarantees new_size >= inode.size. If new_size == inode.size this is a no-op that still bumps the timestamps — matches truncate(2) semantics.

Source

pub fn apply_fallocate_keep_size( &self, ino: u32, offset: u64, len: u64, ) -> Result<()>

Phase 2.2: fallocate(FALLOC_FL_KEEP_SIZE) — preallocate blocks in the byte range [offset, offset+len) as uninitialized extents. The blocks are reserved (count against i_blocks) but reads return zeros until they’re written. i_size is left unchanged per KEEP_SIZE semantics.

v1 limitations:

  • Range must be entirely unmapped — partially-overlapping ranges return Error::InvalidArgument. (Splitting around existing extents is a follow-up.)
  • Single contiguous physical allocation. If the bitmap can’t serve ceil(len / block_size) contiguous blocks, returns Error::Corrupt("no group has a contiguous free run...").
  • Extent insertion must succeed against the inline-root depth-0 tree (or trigger the existing depth-1 promotion). Multi-level trees aren’t yet supported.
Source

pub fn apply_fallocate_punch_hole( &self, ino: u32, offset: u64, len: u64, ) -> Result<()>

Phase 2.3 — fallocate(FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE). Frees the data blocks underlying [offset, offset+len), splitting straddling extents as needed. Reads of the punched range return zeros (sparse hole) thereafter; i_size is unchanged.

v1 limits:

  • Depth-0 inline-root extent trees only. Surviving entries must fit in 4 slots (the inline-root capacity); anything larger returns Corrupt(...). A real punch on a heavily-fragmented file may need depth ≥ 1, which is a Phase 4 follow-up.
  • Indirect-block (ext2/3) inodes return EINVAL — punch is an ext4-specific kernel API.
Source

pub fn apply_fallocate_zero_range( &self, ino: u32, offset: u64, len: u64, ) -> Result<()>

Phase 2.4 — fallocate(FALLOC_FL_ZERO_RANGE). Logically zero the byte range [offset, offset+len) without writing actual data. Implemented as punch-hole + KEEP_SIZE preallocate of the same range, so reads return zeros (uninitialized-extent semantics) and future writes don’t need an allocation.

Two separate transactions today (punch then alloc); a future optimization could fold them into one.

Source

pub fn apply_chmod(&self, path: &str, mode: u16) -> Result<()>

Change the permission bits on path. Only the low 12 bits of mode (S_ISUID|S_ISGID|S_ISVTX plus rwx/rwx/rwx) are applied; the file-type bits (S_IFMT) are preserved from the existing inode.

Updates i_ctime = now and recomputes the inode checksum on csum- enabled mounts. Returns Error::NotFound if the path doesn’t resolve, Error::ReadOnly on a RO mount.

Source

pub fn apply_chown(&self, path: &str, uid: u32, gid: u32) -> Result<()>

Change the owner of path to (uid, gid). Both values are full 32-bit — the inode stores them as hi+lo u16 halves at different offsets per the ext4 on-disk format. Passing u32::MAX for either field leaves that value untouched (Linux lchown(2) convention).

Updates i_ctime = now and recomputes the inode checksum on csum-enabled mounts.

Source

pub fn apply_set_flags(&self, path: &str, flags: u32) -> Result<()>

Set the i_flags field (FS_IOC_SETFLAGS) for the inode at path.

Bumps ctime. Fails with Error::ReadOnly on read-only mounts, or Error::InvalidArgument if the caller attempts to flip any of the layout-critical flags managed internally (EXTENTS_FL, INLINE_DATA_FL, EA_INODE_FL) — changing those without rewriting the inode payload would corrupt the filesystem.

Source

pub fn apply_removexattr(&self, path: &str, name: &str) -> Result<()>

Remove the extended attribute named name from the inode at path. name must carry a known namespace prefix (e.g. "user.color").

v1 scope: in-inode xattrs only. The in-inode region (bytes between 128 + i_extra_isize and the end of the on-disk inode) is decoded, the matching entry is dropped, and the region is re-encoded in place. External xattr blocks (pointed at by Search the in-inode region first, then the external xattr block. If the external block becomes empty after removal, free it and zero i_file_acl (matches kernel behavior — empty xattr blocks are reaped on the spot rather than left dangling).

Returns:

  • Ok(()) on success.
  • Error::NotFound if the entry isn’t present in either region.
  • Error::InvalidArgument on namespace-prefix issues.
Source

pub fn apply_setxattr(&self, path: &str, name: &str, value: &[u8]) -> Result<()>

Set (create or replace) the extended attribute name with value on the inode at path. name must carry a known namespace prefix (e.g. "user.com.apple.FinderInfo").

Try-order, matching the kernel:

  1. In-inode region — between 128 + i_extra_isize and the end of the on-disk inode. Cheapest; no extra block.
  2. External xattr block — when in-inode is full, fall back to a dedicated block referenced by i_file_acl. Allocates a fresh block when none exists, otherwise rewrites the existing one. Returns Error::NoSpaceLeftOnDevice if even a full block can’t hold the new layout.
Source

pub fn apply_utimens( &self, path: &str, atime_sec: i64, atime_nsec: u32, mtime_sec: i64, mtime_nsec: u32, ) -> Result<()>

Set the access + modification times on path. Mirrors POSIX utimensat(2): atime_sec/nsec and mtime_sec/nsec each replace the inode’s atime/mtime. ctime is bumped to now (POSIX requires the change-time stamp on any attribute write). The TIME_OMIT sentinel on either _sec leaves that pair unchanged (lets callers touch just atime or just mtime).

Seconds are signed and 64-bit because that is what the format means: the on-disk base is a signed 32-bit count, extended by the low two bits of the matching *_extra field. A u32 here could not express a pre-1970 date at all, and stored every date past 2038 as one in the 1900s — the base was written and the epoch bits left zero, so the value read back 136 years early.

nsec values are the sub-second timestamp in nanoseconds and are only written when the inode’s i_extra_isize region is large enough to hold them (requires ≥ 160-byte inodes — the ext4 tooling default). That same region holds the epoch bits, so on an inode too small to carry it, a time needing them is refused rather than silently stored as the wrong century.

Unlink a regular file / symlink / special file at path.

Semantics:

  • Refuses to unlink a directory (use a future apply_rmdir).
  • Decrements the target inode’s i_links_count. When that reaches zero, frees every data block via plan_truncate_shrink(size → 0), clears the inode bitmap bit, zeroes the inode body, and sets i_dtime = now. When links_count > 1 we only drop the dir entry and decrement — matches POSIX unlink semantics for hard-linked files.
  • Mutates: parent-dir block (entry removal), target inode, block + inode bitmaps, BGD counters, SB counters. No journaling yet — safe only on scratch images (same caveat as apply_truncate_shrink).

Returns Error::NotFound if the path doesn’t exist, Error::NotADirectory if the parent isn’t a directory, and Error::IsADirectory (POSIX EISDIR) if the target is a directory.

Source

pub fn apply_create(&self, path: &str, mode: u16) -> Result<u32>

Create a new regular file at path with permission bits mode (e.g. 0o644). Returns the allocated inode number on success.

Semantics:

  • Parent must exist and be a directory.
  • Refuses if path already exists.
  • Allocates an inode via plan_inode_allocation (hints to the parent’s group), marks the bitmap, bumps BGD + SB counters.
  • Initialises the inode as a regular file with EXTENTS flag and an empty extent tree (size=0, blocks=0). Timestamps set to now.
  • Adds the directory entry into the first parent block with room (linear; htree-extending dirs are a follow-up).
  • Not journaled — scratch-image safe, same caveat as other Phase-4 applies.
Source

pub fn apply_mknod( &self, path: &str, mode: u16, major: u32, minor: u32, ) -> Result<u32>

Create a special file (FIFO, socket, char device, block device). mode must include the type bits (S_IFIFO, S_IFSOCK, S_IFCHR, or S_IFBLK) plus the permission bits. major and minor are the device numbers (both 0 for FIFOs and sockets). Mirrors POSIX mknod.

Create a symbolic link at linkpath whose target is target. Mirrors POSIX symlink(target, linkpath): allocates a fresh inode with mode S_IFLNK, installs the target bytes, and adds a dir entry at the link path.

Two storage paths:

  • Fast symlink (target.len() <= 60): target stored inline in the 60-byte i_block area; no data-block allocation.
  • Slow symlink (61..=255 bytes): one filesystem block is allocated and the target is written there, with an EXTENTS i_block pointing at it.

POSIX caps symlink targets at SYMLINK_MAX (255 bytes on Linux + macOS). Longer returns Error::NameTooLong → ENAMETOOLONG.

Source

pub fn apply_replace_file_content(&self, path: &str, data: &[u8]) -> Result<u64>

Replace the content of path with data. The file must already exist. Frees every existing extent, allocates a single contiguous run of blocks large enough for data, writes the bytes (zero-padding the tail of the last block), then inserts one extent into the inode.

This is the “Finder just saved a document” path — complete rewrite of a file. Piecewise writes / appends / sparse writes come later.

Journaled, and atomic across the whole replace: freeing the old data, allocating the new run, the bitmap, BGD and superblock updates, the new block contents and the inode all commit as one transaction — as the comment twenty-eight lines into the body already said.

Returns the new file size on success.

Source

pub fn apply_pwrite(&self, path: &str, offset: u64, data: &[u8]) -> Result<u64>

Positional write: splice data into the file at byte offset, allocating new physical blocks for any logical blocks that aren’t yet mapped (sparse holes, or blocks past EOF). Existing mapped blocks are read-modify-written for partial overlap; full-block writes go in fresh.

This is the primitive needed by streaming write paths (FUSE/WinFsp/FSKit cache-manager dispatches) — apply_replace_file_content is “save-as”, apply_pwrite is pwrite(2).

Returns the new file size on success.

Allocation behaviour:

  • Each unmapped logical run is satisfied by one or more physical runs. If plan_block_allocation can’t find a single contiguous group-local run sized for the whole logical run, the request is halved and retried — each successful sub-run becomes its own extent. True ENOSPC (single-block allocation also fails) surfaces as Error::NoSpaceLeftOnDevice.
  • Extent inserts try the inline-root path first; on LEAF_FULL_NEEDS_PROMOTION they fall back to plan_insert_extent_deep, which promotes the tree to depth ≥ 1 and allocates the additional internal/leaf node blocks via the same buffer-aware allocator. Tail checksums on tree blocks are patched when metadata_csum is on.

v1 limitations:

  • Extent-tree inodes only. Legacy ext2/3 (direct/indirect blocks) returns Error::InvalidArgument. The streaming-copy use case for this path is on freshly-mkfs’d ext4 volumes that always have EXTENTS_FL.
  • Pre-existing uninitialised extents (from fallocate) in the write range: not handled — the unmapped-run walk treats them the same as holes and tries to insert a fresh extent that would overlap, hitting CorruptExtentTree("extent overlaps existing"). Skipping fallocate-then-write, the streaming copy path doesn’t trigger this.
Source

pub fn apply_mkdir(&self, path: &str, mode: u16) -> Result<u32>

Create a subdirectory at path with POSIX mode bits (low 12 bits of mode). Returns the new directory’s inode number. Steps: allocate inode (Orlov-hinted) → allocate one data block → seed it with . / .. → build dir inode → write inode + data block → add dir entry in parent → bump parent’s i_links_count → commit BGD/SB counters.

Not journaled — safe only in scratch-image contexts until transaction wrapping lands.

Create a hard link at dst pointing to the same inode as src.

Semantics:

  • src must exist and must NOT be a directory (POSIX forbids directory hardlinks to avoid reference cycles).
  • dst’s parent must exist and be a directory.
  • dst must not already exist.
  • On success the shared inode’s i_links_count is incremented by 1.

Not journaled — same caveat as other Phase-4 ops.

Source

pub fn apply_rename( &self, src: &str, dst: &str, replace_if_exists: bool, ) -> Result<()>

Rename srcdst within the same filesystem.

Semantics:

  • Both endpoints are within this mount.
  • Works for files and directories.
  • Cross-parent moves update the moved dir’s .. entry + bump / decrement both parents’ i_links_count.
  • Refuses to move a directory into its own subtree (cycle check).
  • Same source and dest: no-op success.
  • When dst already exists:
    • replace_if_exists = false → returns Error::AlreadyExists.
    • replace_if_exists = true → overwrites dst. See “Atomicity” below for exactly how far that holds. Type-compatibility rules (POSIX rename(2)):
      • file→dir → Error::IsADirectory
      • dir→file → Error::NotADirectory
      • non-empty-dir overwrite → Error::DirectoryNotEmpty
      • src and dst resolve to the same inode (hardlink) → no-op success. Otherwise the previous dst inode’s link count is decremented in the same buffer; if that drops it to zero the inode’s extents and slot are freed in the same atomic commit.
§Atomicity, and the one place it does not hold

Both paths stage their work into a single [BlockBuffer] and commit it through the journal, so a crash either applies the whole rename or none of it.

Except when the destination directory has no room for the new entry. Then the buffer is committed early and extend_dir_and_add_entry — which is not journaled — runs afterwards. That splits the operation in two, and the window between them is a real one:

  • On the overwrite path, the early commit has already removed dst’s directory entry. A crash there leaves dst’s name gone and src still present: the file that was at dst is unreachable, and src has not moved.
  • On the no-overwrite path, the early commit is empty, so a crash in the extend leaves the filesystem as it was — but a crash after it leaves both names pointing at src’s inode with a link count of one.

Closing this needs extend_dir_and_add_entry to stage into the buffer rather than write on its own, which is a change to the directory-growth path rather than to this function. Until then the guarantee is: atomic unless the destination directory has to grow.

Source

pub fn apply_rmdir(&self, path: &str) -> Result<()>

Remove an empty directory at path. Requires the target to contain only . and ... Frees the data block(s) + inode, removes the entry from the parent, decrements parent’s i_links_count.

Source§

impl Filesystem

Source

pub fn audit( &self, max_dirs_visited: u32, max_entries_per_dir: u32, ) -> Result<AuditReport>

Run an ext4 audit tool-style read-only audit.

Walks from root, counts how many directory entries reference each inode, and compares that against each inode’s i_links_count. Returns an AuditReport — empty anomalies means every invariant we check held.

The pass is bounded: never visits more than max_dirs_visited directories and never scans more than max_entries_per_dir entries within a single directory. Pass u32::MAX for an unbounded pass.

Source

pub fn audit_repair( &self, max_dirs_visited: u32, max_entries_per_dir: u32, repair: bool, ) -> Result<AuditReport>

Audit + repair convenience wrapper. See audit_with_repair for semantics. No-op on read-only mounts when repair == true (returns Error::ReadOnly).

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.