diff --git a/CHANGELOG.md b/CHANGELOG.md index d073392..4608ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Added `Ext4::uuid` to get the filesystem UUID. * Made the `Corrupt` type opaque. It is no longer possible to `match` on specific types of corruption. +* Added support for reading filesystems that weren't cleanly unmounted. ## 0.7.0 diff --git a/src/checksum.rs b/src/checksum.rs index 8685600..e432092 100644 --- a/src/checksum.rs +++ b/src/checksum.rs @@ -54,6 +54,11 @@ impl Checksum { self.digest.update(data); } + /// Extend the digest with a big-endian `u32`. + pub(crate) fn update_u32_be(&mut self, data: u32) { + self.update(&data.to_be_bytes()); + } + /// Extend the digest with a little-endian `u16`. pub(crate) fn update_u16_le(&mut self, data: u16) { self.update(&data.to_le_bytes()); diff --git a/src/error.rs b/src/error.rs index bed7d38..b824e98 100644 --- a/src/error.rs +++ b/src/error.rs @@ -236,6 +236,27 @@ pub(crate) enum CorruptKind { /// Journal superblock checksum is invalid. JournalSuperblockChecksum, + /// Journal block size does not match the filesystem block size. + JournalBlockSize, + + /// Journal does not have the expected number of blocks. + JournalTruncated, + + /// Journal first commit doesn't match the sequence number in the superblock. + JournalSequence, + + /// Journal commit block checksum is invalid. + JournalCommitBlockChecksum, + + /// Journal descriptor block checksum is invalid. + JournalDescriptorBlockChecksum, + + /// Journal descriptor tag checksum is invalid. + JournalDescriptorTagChecksum, + + /// Journal has a descriptor block that contains no tag with the last-tag flag set. + JournalDescriptorBlockMissingLastTag, + /// An inode's checksum is invalid. InodeChecksum(InodeIndex), @@ -319,6 +340,30 @@ impl Display for CorruptKind { Self::JournalSuperblockChecksum => { write!(f, "journal superblock checksum is invalid") } + Self::JournalBlockSize => { + write!( + f, + "journal block size does not match filesystem block size" + ) + } + Self::JournalTruncated => write!(f, "journal is truncated"), + Self::JournalSequence => write!( + f, + "journal's first commit doesn't match the expected sequence" + ), + Self::JournalCommitBlockChecksum => { + write!(f, "journal commit block checksum is invalid") + } + Self::JournalDescriptorBlockChecksum => { + write!(f, "journal descriptor block checksum is invalid") + } + Self::JournalDescriptorTagChecksum => { + write!(f, "journal descriptor tag checksum is invalid") + } + Self::JournalDescriptorBlockMissingLastTag => write!( + f, + "a journal descriptor block has no tag with the last-tag flag set" + ), Self::InodeChecksum(inode) => { write!(f, "invalid checksum for inode {inode}") } @@ -464,6 +509,12 @@ pub enum Incompatible { /// Raw feature bits. u32, ), + + /// The journal contains an unsupported block type. + JournalBlockType( + /// Raw journal block type. + u32, + ), } impl Display for Incompatible { @@ -487,6 +538,9 @@ impl Display for Incompatible { Self::JournalSuperblockType(val) => { write!(f, "journal superblock type is not supported: {val}") } + Self::JournalBlockType(val) => { + write!(f, "journal block type is not supported: {val}") + } Self::JournalChecksumType(val) => { write!(f, "journal checksum type is not supported: {val}") } diff --git a/src/journal.rs b/src/journal.rs index 6080c8c..6d0b521 100644 --- a/src/journal.rs +++ b/src/journal.rs @@ -6,34 +6,197 @@ // option. This file may not be copied, modified, or distributed // except according to those terms. -#[expect(unused)] // TODO mod block_header; -#[expect(unused)] // TODO +mod descriptor_block; mod superblock; -use crate::{Ext4, Ext4Error}; +use crate::checksum::Checksum; +use crate::error::{CorruptKind, Ext4Error, Incompatible}; +use crate::inode::Inode; +use crate::iters::file_blocks::FileBlocks; +use crate::util::{read_u32be, usize_from_u32}; +use crate::Ext4; +use alloc::collections::BTreeMap; +use alloc::vec; +use block_header::{JournalBlockHeader, JournalBlockType}; +use descriptor_block::{ + is_descriptor_block_checksum_valid, JournalDescriptorBlockTag, +}; +use superblock::JournalSuperblock; #[derive(Debug)] pub(crate) struct Journal { - // TODO: add journal data. + block_map: BTreeMap, } impl Journal { - /// Create an empty journal. pub(crate) fn empty() -> Self { - Self {} + Self { + block_map: BTreeMap::new(), + } } - /// Load a journal from the filesystem. + /// Load the journal. + /// + /// If the filesystem has no journal, an empty journal is returned. + /// + /// Note: ext4 is all little-endian, except for the journal, which + /// is all big-endian. pub(crate) fn load(fs: &Ext4) -> Result { - let Some(_journal_inode) = fs.0.superblock.journal_inode else { + let Some(journal_inode) = fs.0.superblock.journal_inode else { // Return an empty journal if this filesystem does not have // a journal. return Ok(Self::empty()); }; - // TODO: actually load the journal. + let journal_inode = Inode::read(fs, journal_inode)?; + let superblock = JournalSuperblock::load(fs, &journal_inode)?; - Ok(Self {}) + // Ensure the journal block size matches the rest of the + // filesystem. + let block_size = fs.0.superblock.block_size; + if superblock.block_size != block_size { + return Err(CorruptKind::JournalBlockSize.into()); + } + + let block_map = load_block_map(fs, &superblock, &journal_inode)?; + + Ok(Self { block_map }) + } + + /// Map from an absolute block index to a block in the journal. + /// + /// If the journal does not contain a replacement for the input + /// block, the input block is returned. + pub(crate) fn map_block_index(&self, block_index: u64) -> u64 { + *self.block_map.get(&block_index).unwrap_or(&block_index) + } +} + +fn load_block_map( + fs: &Ext4, + superblock: &JournalSuperblock, + journal_inode: &Inode, +) -> Result, Ext4Error> { + // Get an iterator over the journal's block indices. + let journal_block_iter = FileBlocks::new(fs.clone(), journal_inode)?; + + // Skip forward to the start block. + let mut journal_block_iter = + journal_block_iter.skip(usize_from_u32(superblock.start_block)); + + // TODO: the loop below currently returns an error if something + // bad is encountered (e.g. a wrong checksum). We should + // actually still apply valid commits, and just stop reading the + // journal when bad data is encountered. + + let mut block = vec![0; fs.0.superblock.block_size.to_usize()]; + let mut block_map = BTreeMap::new(); + let mut uncommitted_block_map = BTreeMap::new(); + let mut sequence = superblock.sequence; + while let Some(block_index) = journal_block_iter.next() { + let block_index = block_index?; + + fs.read_from_block(block_index, 0, &mut block)?; + + let Some(header) = JournalBlockHeader::read_bytes(&block)? else { + // Journal block magic is not present, so we've reached + // the end of the journal. + break; + }; + + if header.sequence != sequence { + return Err(CorruptKind::JournalSequence.into()); + } + + if header.block_type == JournalBlockType::DESCRIPTOR { + if !is_descriptor_block_checksum_valid(superblock, &block) { + return Err(CorruptKind::JournalDescriptorBlockChecksum.into()); + } + + let tags = + JournalDescriptorBlockTag::read_bytes_to_vec(&block[12..]) + .unwrap(); + + for tag in &tags { + let block_index = journal_block_iter + .next() + .ok_or(CorruptKind::JournalTruncated)??; + + let mut checksum = Checksum::new(); + checksum.update(superblock.uuid.as_bytes()); + checksum.update_u32_be(sequence); + fs.read_from_block(block_index, 0, &mut block)?; + checksum.update(&block); + if checksum.finalize() != tag.checksum { + return Err( + CorruptKind::JournalDescriptorTagChecksum.into() + ); + } + + uncommitted_block_map.insert(tag.block_number, block_index); + } + } else if header.block_type == JournalBlockType::COMMIT { + if !is_commit_block_checksum_valid(superblock, &block) { + return Err(CorruptKind::JournalCommitBlockChecksum.into()); + } + + // Move the entries from `uncommitted_block_map` to `block_map`. + block_map.extend(uncommitted_block_map.iter()); + uncommitted_block_map.clear(); + + // TODO: unwrap + sequence = sequence.checked_add(1).unwrap(); + } else { + return Err( + Incompatible::JournalBlockType(header.block_type.0).into() + ); + } + } + + Ok(block_map) +} + +fn is_commit_block_checksum_valid( + superblock: &JournalSuperblock, + block: &[u8], +) -> bool { + // The kernel documentation says that fields 0xc and 0xd contain the + // checksum type and size, but this is not correct. If the + // superblock features include `CHECKSUM_V3`, the type/size fields + // are both zero. + + const CHECKSUM_OFFSET: usize = 16; + const CHECKSUM_SIZE: usize = 4; + + let expected_checksum = read_u32be(block, CHECKSUM_OFFSET); + + let mut checksum = Checksum::new(); + checksum.update(superblock.uuid.as_bytes()); + checksum.update(&block[..CHECKSUM_OFFSET]); + checksum.update(&[0; CHECKSUM_SIZE]); + checksum.update(&block[CHECKSUM_OFFSET + CHECKSUM_SIZE..]); + + checksum.finalize() == expected_checksum +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use crate::test_util::load_compressed_filesystem; + use alloc::rc::Rc; + + #[test] + fn test_journal() { + let mut fs = + load_compressed_filesystem("test_disk_4k_block_journal.bin.zst"); + + let test_dir = "/dir500"; + + // With the journal in place, this directory exists. + assert!(fs.exists(test_dir).unwrap()); + + // Clear the journal, and verify that the directory no longer exists. + Rc::get_mut(&mut fs.0).unwrap().journal.block_map.clear(); + assert!(!fs.exists(test_dir).unwrap()); } } diff --git a/src/journal/descriptor_block.rs b/src/journal/descriptor_block.rs new file mode 100644 index 0000000..179802a --- /dev/null +++ b/src/journal/descriptor_block.rs @@ -0,0 +1,119 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use crate::checksum::Checksum; +use crate::error::{CorruptKind, Ext4Error}; +use crate::journal::superblock::JournalSuperblock; +use crate::util::{read_u32be, u64_from_hilo}; +use alloc::vec::Vec; +use bitflags::bitflags; + +pub(super) fn is_descriptor_block_checksum_valid( + superblock: &JournalSuperblock, + block: &[u8], +) -> bool { + // OK to unwrap: minimum block length is 1024. + let checksum_offset = block.len().checked_sub(4).unwrap(); + let expected_checksum = read_u32be(block, checksum_offset); + let mut checksum = Checksum::new(); + checksum.update(superblock.uuid.as_bytes()); + checksum.update(&block[..checksum_offset]); + checksum.update_u32_be(0); + + checksum.finalize() == expected_checksum +} + +// TODO: the kernel docs for this are a mess +#[derive(Debug)] +pub(super) struct JournalDescriptorBlockTag { + pub(super) block_number: u64, + pub(super) flags: JournalDescriptorBlockTagFlags, + pub(super) checksum: u32, + #[expect(dead_code)] // TODO + uuid: [u8; 16], +} + +impl JournalDescriptorBlockTag { + pub(super) fn read_bytes(bytes: &[u8]) -> (Self, usize) { + // TODO: for now assuming the `incompat_features` assert above. + + let t_blocknr = read_u32be(bytes, 0); + let t_flags = read_u32be(bytes, 4); + let t_blocknr_high = read_u32be(bytes, 8); + let t_checksum = read_u32be(bytes, 12); + + let flags = JournalDescriptorBlockTagFlags::from_bits_retain(t_flags); + let mut size: usize = 16; + + let mut uuid = [0; 16]; + if !flags.contains(JournalDescriptorBlockTagFlags::UUID_OMITTED) { + // OK to unwrap: length is 16. + uuid = bytes[16..32].try_into().unwrap(); + // TODO: unwrap + size = size.checked_add(16).unwrap(); + } + + ( + Self { + block_number: u64_from_hilo(t_blocknr_high, t_blocknr), + flags, + checksum: t_checksum, + uuid, + }, + size, + ) + } + + // TODO: this could be an iterator instead of allocating. + pub(super) fn read_bytes_to_vec( + mut bytes: &[u8], + ) -> Result, Ext4Error> { + let mut v = Vec::new(); + + while !bytes.is_empty() { + let (tag, size) = Self::read_bytes(bytes); + let is_end = + tag.flags.contains(JournalDescriptorBlockTagFlags::LAST_TAG); + v.push(tag); + + if is_end { + return Ok(v); + } + + bytes = &bytes[size..]; + } + + Err(CorruptKind::JournalDescriptorBlockMissingLastTag.into()) + } +} + +bitflags! { + #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] + pub struct JournalDescriptorBlockTagFlags: u32 { + const ESCAPED = 0x1; + const UUID_OMITTED = 0x2; + const DELETED = 0x4; + const LAST_TAG = 0x8; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_journal_descriptor_block_tags() { + let mut bytes = vec![]; + assert_eq!( + JournalDescriptorBlockTag::read_bytes_to_vec(&bytes).unwrap_err(), + CorruptKind::JournalDescriptorBlockMissingLastTag + ); + + // TODO, more stuff. + } +} diff --git a/src/journal/superblock.rs b/src/journal/superblock.rs index cd55438..01089ab 100644 --- a/src/journal/superblock.rs +++ b/src/journal/superblock.rs @@ -163,6 +163,28 @@ bitflags! { #[cfg(all(test, feature = "std"))] mod tests { use super::*; + use crate::test_util::load_compressed_filesystem; + + #[test] + fn test_load_journal_superblock() { + let fs = + load_compressed_filesystem("test_disk_4k_block_journal.bin.zst"); + let journal_inode = + Inode::read(&fs, fs.0.superblock.journal_inode.unwrap()).unwrap(); + let superblock = JournalSuperblock::load(&fs, &journal_inode).unwrap(); + assert_eq!( + superblock, + JournalSuperblock { + block_size: 4096, + sequence: 3, + start_block: 289, + uuid: Uuid([ + 0x6c, 0x48, 0x4f, 0x1b, 0x7f, 0x71, 0x47, 0x4c, 0xa1, 0xf9, + 0x3b, 0x50, 0x0c, 0xc1, 0xe2, 0x74 + ]), + } + ); + } fn write_u32be(bytes: &mut [u8], offset: usize, value: u32) { bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); diff --git a/src/lib.rs b/src/lib.rs index 9520872..ee92948 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -306,6 +306,8 @@ impl Ext4 { ) }; + let block_index = self.0.journal.map_block_index(block_index); + // The first 1024 bytes are reserved for non-filesystem // data. This conveniently allows for something like a null // pointer check. diff --git a/src/superblock.rs b/src/superblock.rs index 8cfcbcf..8ad43e8 100644 --- a/src/superblock.rs +++ b/src/superblock.rs @@ -121,22 +121,24 @@ impl Superblock { return Err(CorruptKind::InodeSize.into()); } - let journal_inode = - if compatible_features.contains(CompatibleFeatures::HAS_JOURNAL) { - // For now a separate journal device is not supported, so - // assert that feature is not present. This assert cannot - // fail because of the call to `check_incompat_features` - // above. - assert!(!incompatible_features - .contains(IncompatibleFeatures::SEPARATE_JOURNAL_DEVICE)); - - Some( - InodeIndex::new(s_journal_inum) - .ok_or(CorruptKind::JournalInode)?, - ) - } else { - None - }; + let journal_inode = if compatible_features + .contains(CompatibleFeatures::HAS_JOURNAL) + && incompatible_features.contains(IncompatibleFeatures::RECOVERY) + { + // For now a separate journal device is not supported, so + // assert that feature is not present. This assert cannot + // fail because of the call to `check_incompat_features` + // above. + assert!(!incompatible_features + .contains(IncompatibleFeatures::SEPARATE_JOURNAL_DEVICE)); + + Some( + InodeIndex::new(s_journal_inum) + .ok_or(CorruptKind::JournalInode)?, + ) + } else { + None + }; // Validate the superblock checksum. if read_only_compatible_features @@ -197,7 +199,6 @@ fn check_incompat_features( // relax some of these in the future. let required_features = IncompatibleFeatures::FILE_TYPE_IN_DIR_ENTRY; let disallowed_features = IncompatibleFeatures::COMPRESSION - | IncompatibleFeatures::RECOVERY | IncompatibleFeatures::SEPARATE_JOURNAL_DEVICE | IncompatibleFeatures::META_BLOCK_GROUPS | IncompatibleFeatures::MULTIPLE_MOUNT_PROTECTION @@ -391,10 +392,12 @@ mod tests { assert_eq!( check_incompat_features( - required | IncompatibleFeatures::RECOVERY.bits() + required | IncompatibleFeatures::SEPARATE_JOURNAL_DEVICE.bits() ) .unwrap_err(), - Incompatible::Incompatible(IncompatibleFeatures::RECOVERY) + Incompatible::Incompatible( + IncompatibleFeatures::SEPARATE_JOURNAL_DEVICE + ) ); } }