Skip to main content

ext4fs/forensic/
journal.rs

1#![forbid(unsafe_code)]
2
3use crate::error::{Ext4Error, Result};
4use crate::inode::InodeReader;
5use crate::ondisk::journal::{
6    JournalBlockTag, JournalBlockType, JournalCommit, JournalHeader, JournalRevoke,
7    JournalSuperblock,
8};
9use std::io::{Read, Seek};
10
11/// A mapping from a journal data block to a filesystem block.
12#[derive(Debug, Clone)]
13pub struct JournalMapping {
14    pub journal_block: u64,
15    pub filesystem_block: u64,
16}
17
18/// A parsed journal transaction.
19#[derive(Debug, Clone)]
20pub struct Transaction {
21    pub sequence: u32,
22    pub commit_seconds: i64,
23    pub commit_nanoseconds: u32,
24    pub mappings: Vec<JournalMapping>,
25    pub revoked_blocks: Vec<u64>,
26}
27
28/// Parsed journal.
29#[derive(Debug, Clone)]
30pub struct Journal {
31    pub block_size: u32,
32    pub total_blocks: u32,
33    pub first_block: u32,
34    pub transactions: Vec<Transaction>,
35    pub is_64bit: bool,
36    pub has_csum_v3: bool,
37}
38
39/// A historical version of an inode from the journal.
40#[derive(Debug, Clone)]
41pub struct InodeVersion {
42    pub sequence: u32,
43    pub commit_seconds: i64,
44    pub commit_nanoseconds: u32,
45    pub inode: crate::ondisk::Inode,
46}
47
48/// Parse the jbd2 journal from the journal inode.
49pub fn parse_journal<R: Read + Seek>(reader: &mut InodeReader<R>) -> Result<Journal> {
50    // Copy superblock fields before mutable borrow
51    let (has_journal, journal_ino, fs_block_size) = {
52        let sb = reader.block_reader().superblock();
53        (
54            sb.has_journal(),
55            u64::from(sb.journal_inum),
56            sb.block_size as usize,
57        )
58    };
59
60    if !has_journal {
61        return Err(Ext4Error::NoJournal);
62    }
63    if journal_ino == 0 {
64        return Err(Ext4Error::NoJournal);
65    }
66
67    // Read entire journal inode data
68    let journal_data = reader.read_inode_data(journal_ino)?;
69    if journal_data.len() < 1024 {
70        return Err(Ext4Error::JournalCorrupt("journal too small".into()));
71    }
72
73    // Parse journal superblock (first block of journal)
74    let jsb = JournalSuperblock::parse(&journal_data[..fs_block_size.min(journal_data.len())])?;
75    let j_block_size = jsb.block_size as usize;
76    if j_block_size == 0 {
77        return Err(Ext4Error::JournalCorrupt("journal block size is 0".into()));
78    }
79
80    let is_64bit = jsb.is_64bit();
81    let has_csum_v3 = jsb.has_csum_v3();
82    let total_blocks = jsb.max_len;
83    let first_block = jsb.first;
84
85    // Scan journal blocks for transactions
86    let mut transactions = Vec::new();
87    let mut current_mappings: Vec<JournalMapping> = Vec::new();
88    let mut current_revoked: Vec<u64> = Vec::new();
89    let mut pending_tags: Vec<u64> = Vec::new();
90
91    let mut block_idx = first_block as usize;
92    while block_idx < total_blocks as usize {
93        let offset = block_idx * j_block_size;
94        if offset + 12 > journal_data.len() {
95            break;
96        }
97
98        let end = (offset + j_block_size).min(journal_data.len());
99        let block_data = &journal_data[offset..end];
100
101        if let Ok(header) = JournalHeader::parse(block_data) {
102            match header.block_type {
103                JournalBlockType::Descriptor => {
104                    pending_tags.clear();
105                    current_mappings.clear();
106                    current_revoked.clear();
107                    let mut tag_offset = 12;
108                    loop {
109                        if tag_offset + 16 > block_data.len() {
110                            break;
111                        }
112                        // In-bounds by the `tag_offset + 16 > len` guard above,
113                        // so parse_v3 is Ok here; the else-break degrades loudly
114                        // (stops scanning) rather than panicking if that ever
115                        // changes.
116                        let Ok(tag) =
117                            JournalBlockTag::parse_v3(&block_data[tag_offset..], is_64bit)
118                        else {
119                            break;
120                        };
121                        pending_tags.push(tag.blocknr);
122                        let is_last = tag.last_tag;
123                        if tag.tag_size == 0 {
124                            break;
125                        }
126                        tag_offset += tag.tag_size;
127                        if is_last {
128                            break;
129                        }
130                    }
131                    // Map each tag to its corresponding data block
132                    for (i, &fs_block) in pending_tags.iter().enumerate() {
133                        let data_block = block_idx as u64 + 1 + i as u64;
134                        current_mappings.push(JournalMapping {
135                            journal_block: data_block,
136                            filesystem_block: fs_block,
137                        });
138                    }
139                    // Skip past descriptor + data blocks
140                    block_idx += 1 + pending_tags.len();
141                    continue;
142                }
143                JournalBlockType::Commit => {
144                    // Only record the transaction if we can parse the commit
145                    // block. Fabricating an epoch timestamp on parse failure
146                    // would be forensically misleading.
147                    if let Ok(commit) = JournalCommit::parse(block_data) {
148                        transactions.push(Transaction {
149                            sequence: commit.sequence,
150                            commit_seconds: commit.commit_seconds,
151                            commit_nanoseconds: commit.commit_nanoseconds,
152                            mappings: std::mem::take(&mut current_mappings),
153                            revoked_blocks: std::mem::take(&mut current_revoked),
154                        });
155                    } else {
156                        // Discard the uncommitted mappings/revokes
157                        current_mappings.clear();
158                        current_revoked.clear();
159                    }
160                }
161                JournalBlockType::Revoke => {
162                    if let Ok(revoke) = JournalRevoke::parse(block_data, is_64bit) {
163                        current_revoked.extend(revoke.revoked_blocks);
164                    }
165                }
166                JournalBlockType::SuperblockV1
167                | JournalBlockType::SuperblockV2
168                | JournalBlockType::Unknown(_) => {}
169            }
170        }
171        block_idx += 1;
172    }
173
174    Ok(Journal {
175        block_size: j_block_size as u32,
176        total_blocks,
177        first_block,
178        transactions,
179        is_64bit,
180        has_csum_v3,
181    })
182}
183
184/// Find all journal versions of a specific inode's metadata block.
185///
186/// Scans journal transactions for writes to the block containing the given
187/// inode, then parses the inode from each historical copy of that block.
188/// Results are sorted by transaction sequence number (oldest first).
189pub fn inode_history<R: Read + Seek>(
190    reader: &mut InodeReader<R>,
191    journal: &Journal,
192    ino: u64,
193) -> Result<Vec<InodeVersion>> {
194    // Copy superblock fields before mutable borrow
195    let (ipg, inode_size_u64, inode_size_u16, block_size, journal_ino) = {
196        let sb = reader.block_reader().superblock();
197        (
198            u64::from(sb.inodes_per_group),
199            u64::from(sb.inode_size),
200            sb.inode_size,
201            u64::from(sb.block_size),
202            u64::from(sb.journal_inum),
203        )
204    };
205
206    let group = ((ino - 1) / ipg) as u32;
207    let index = (ino - 1) % ipg;
208    let inode_table = reader.block_reader().inode_table_block(group)?;
209    let inode_offset_in_table = index * inode_size_u64;
210    let target_block = inode_table + inode_offset_in_table / block_size;
211    let offset_in_block = (inode_offset_in_table % block_size) as usize;
212
213    let journal_data = reader.read_inode_data(journal_ino)?;
214    let j_block_size = journal.block_size as usize;
215
216    let mut versions = Vec::new();
217    for txn in &journal.transactions {
218        for mapping in &txn.mappings {
219            if mapping.filesystem_block == target_block {
220                let j_offset = mapping.journal_block as usize * j_block_size;
221                if j_offset + j_block_size <= journal_data.len() {
222                    let block_data = &journal_data[j_offset..j_offset + j_block_size];
223                    let end = offset_in_block + inode_size_u64 as usize;
224                    if end <= block_data.len() {
225                        if let Ok(inode) = crate::ondisk::Inode::parse(
226                            &block_data[offset_in_block..end],
227                            inode_size_u16,
228                        ) {
229                            versions.push(InodeVersion {
230                                sequence: txn.sequence,
231                                commit_seconds: txn.commit_seconds,
232                                commit_nanoseconds: txn.commit_nanoseconds,
233                                inode,
234                            });
235                        }
236                    }
237                }
238            }
239        }
240    }
241
242    versions.sort_by_key(|v| v.sequence);
243    Ok(versions)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::block::BlockReader;
250    use crate::inode::InodeReader;
251    use std::io::Cursor;
252
253    fn open_minimal() -> Option<InodeReader<Cursor<Vec<u8>>>> {
254        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
255        let data = std::fs::read(path).ok()?;
256        let br = BlockReader::open(Cursor::new(data)).ok()?;
257        Some(InodeReader::new(br))
258    }
259
260    fn open_forensic() -> Option<InodeReader<Cursor<Vec<u8>>>> {
261        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
262        let data = std::fs::read(path).ok()?;
263        let br = BlockReader::open(Cursor::new(data)).ok()?;
264        Some(InodeReader::new(br))
265    }
266
267    fn forensic_raw() -> Option<Vec<u8>> {
268        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
269        std::fs::read(path).ok()
270    }
271
272    // ---------------------------------------------------------------
273    // Existing tests (minimal.img — no journal, exercises skip paths)
274    // ---------------------------------------------------------------
275
276    #[test]
277    fn parse_journal_from_minimal() {
278        let mut reader = if let Some(r) = open_minimal() {
279            r
280        } else {
281            eprintln!("skip: minimal.img not found");
282            return;
283        };
284        if !reader.block_reader().superblock().has_journal() {
285            eprintln!("skip: no journal in image");
286            return;
287        }
288        let journal = parse_journal(&mut reader).unwrap();
289        assert!(journal.block_size > 0);
290        assert!(!journal.transactions.is_empty());
291    }
292
293    #[test]
294    fn transactions_have_commit_timestamps() {
295        let mut reader = if let Some(r) = open_minimal() {
296            r
297        } else {
298            eprintln!("skip: minimal.img not found");
299            return;
300        };
301        if !reader.block_reader().superblock().has_journal() {
302            return;
303        }
304        let journal = parse_journal(&mut reader).unwrap();
305        for txn in &journal.transactions {
306            assert!(txn.sequence > 0);
307        }
308    }
309
310    // ---------------------------------------------------------------
311    // NoJournal error path — minimal.img has no journal
312    // ---------------------------------------------------------------
313
314    #[test]
315    fn parse_journal_no_journal_error() {
316        let mut reader = if let Some(r) = open_minimal() {
317            r
318        } else {
319            eprintln!("skip: minimal.img not found");
320            return;
321        };
322        // minimal.img has no journal feature
323        assert!(!reader.block_reader().superblock().has_journal());
324        match parse_journal(&mut reader) {
325            Err(Ext4Error::NoJournal) => {} // expected
326            other => panic!("expected NoJournal, got: {other:?}"),
327        }
328    }
329
330    // ---------------------------------------------------------------
331    // forensic.img tests — HAS journal
332    // ---------------------------------------------------------------
333
334    #[test]
335    fn parse_journal_from_forensic() {
336        let mut reader = if let Some(r) = open_forensic() {
337            r
338        } else {
339            eprintln!("skip: forensic.img not found");
340            return;
341        };
342        assert!(reader.block_reader().superblock().has_journal());
343        let journal = parse_journal(&mut reader).unwrap();
344        assert!(journal.block_size > 0);
345        assert!(journal.total_blocks > 0);
346        assert!(journal.first_block > 0);
347        // A 32 MiB image that's been used should have at least one transaction
348        assert!(
349            !journal.transactions.is_empty(),
350            "forensic.img journal should have transactions"
351        );
352    }
353
354    #[test]
355    fn forensic_transactions_have_sequence_and_timestamps() {
356        let mut reader = if let Some(r) = open_forensic() {
357            r
358        } else {
359            eprintln!("skip: forensic.img not found");
360            return;
361        };
362        let journal = parse_journal(&mut reader).unwrap();
363        for txn in &journal.transactions {
364            assert!(txn.sequence > 0, "sequence should be positive");
365            // Commit timestamps should be reasonable (after 2020)
366            assert!(
367                txn.commit_seconds >= 1_577_836_800, // 2020-01-01
368                "commit_seconds {} too small",
369                txn.commit_seconds
370            );
371        }
372    }
373
374    #[test]
375    fn forensic_transactions_have_mappings() {
376        let mut reader = if let Some(r) = open_forensic() {
377            r
378        } else {
379            eprintln!("skip: forensic.img not found");
380            return;
381        };
382        let journal = parse_journal(&mut reader).unwrap();
383        // At least some transactions should have block mappings
384        let has_mappings = journal.transactions.iter().any(|t| !t.mappings.is_empty());
385        assert!(has_mappings, "some transactions should have block mappings");
386
387        // Verify mapping fields are reasonable
388        for txn in &journal.transactions {
389            for m in &txn.mappings {
390                assert!(m.journal_block > 0, "journal_block should be > 0");
391            }
392        }
393    }
394
395    #[test]
396    fn forensic_journal_sequence_monotonic() {
397        let mut reader = if let Some(r) = open_forensic() {
398            r
399        } else {
400            eprintln!("skip: forensic.img not found");
401            return;
402        };
403        let journal = parse_journal(&mut reader).unwrap();
404        // Transactions should be in increasing sequence order
405        for w in journal.transactions.windows(2) {
406            assert!(
407                w[1].sequence >= w[0].sequence,
408                "sequences should be monotonic: {} >= {}",
409                w[1].sequence,
410                w[0].sequence
411            );
412        }
413    }
414
415    // ---------------------------------------------------------------
416    // inode_history tests
417    // ---------------------------------------------------------------
418
419    #[test]
420    fn inode_history_for_known_inode() {
421        let mut reader = if let Some(r) = open_forensic() {
422            r
423        } else {
424            eprintln!("skip: forensic.img not found");
425            return;
426        };
427        let journal = parse_journal(&mut reader).unwrap();
428        // Inode 12 is hello.txt — should appear in journal if inode table
429        // block was written
430        let versions = inode_history(&mut reader, &journal, 12).unwrap();
431        // May or may not have versions depending on journal contents
432        for v in &versions {
433            assert!(v.sequence > 0);
434            assert!(v.commit_seconds >= 0);
435        }
436        // If there are multiple versions, they should be sorted by sequence
437        for w in versions.windows(2) {
438            assert!(w[0].sequence <= w[1].sequence);
439        }
440    }
441
442    #[test]
443    fn inode_history_for_deleted_inode() {
444        let mut reader = if let Some(r) = open_forensic() {
445            r
446        } else {
447            eprintln!("skip: forensic.img not found");
448            return;
449        };
450        let journal = parse_journal(&mut reader).unwrap();
451        // Inode 21 was deleted — may have historical versions showing
452        // the pre-deletion state
453        let versions = inode_history(&mut reader, &journal, 21).unwrap();
454        // Check that if versions exist, they have valid sequence numbers
455        for v in &versions {
456            assert!(v.sequence > 0);
457        }
458    }
459
460    #[test]
461    fn inode_history_for_nonexistent_inode() {
462        let mut reader = if let Some(r) = open_forensic() {
463            r
464        } else {
465            eprintln!("skip: forensic.img not found");
466            return;
467        };
468        let journal = parse_journal(&mut reader).unwrap();
469        // Use a high inode number that likely has no journal history
470        // but is within range (use inode count from superblock)
471        let max_ino = reader.block_reader().superblock().inodes_count;
472        if max_ino > 100 {
473            let versions = inode_history(&mut reader, &journal, u64::from(max_ino - 1)).unwrap();
474            // Should return empty or minimal versions
475            // (just checking it doesn't crash)
476            let _ = versions;
477        }
478    }
479
480    // ---------------------------------------------------------------
481    // JournalCorrupt error paths
482    // ---------------------------------------------------------------
483
484    #[test]
485    fn journal_corrupt_truncated_data() {
486        let mut data = if let Some(d) = forensic_raw() {
487            d
488        } else {
489            eprintln!("skip: forensic.img not found");
490            return;
491        };
492        // Find the journal inode data and corrupt it by zeroing the journal
493        // superblock area. The journal inode is typically inode 8.
494        // We'll read the image, find the journal, then corrupt it.
495        let reader = open_forensic().unwrap();
496        let sb = reader.block_reader().superblock();
497        let journal_ino = u64::from(sb.journal_inum);
498        assert!(journal_ino > 0);
499
500        // Read journal inode to find its data blocks
501        let reader2 = open_forensic().unwrap();
502        let mappings = reader2.inode_block_map(journal_ino).unwrap();
503        if mappings.is_empty() {
504            eprintln!("skip: journal inode has no block mappings");
505            return;
506        }
507
508        // Zero out the journal superblock (first block of journal data)
509        // to make it unparseable, causing JournalCorrupt or similar error
510        let first_phys = mappings[0].physical_block;
511        let block_size = sb.block_size as usize;
512        let offset = first_phys as usize * block_size;
513        if offset + block_size <= data.len() {
514            // Zero just the journal superblock magic to make parse fail
515            for b in &mut data[offset..offset + 12] {
516                *b = 0;
517            }
518        }
519
520        let br = BlockReader::open(Cursor::new(data)).unwrap();
521        let mut reader3 = InodeReader::new(br);
522        let result = parse_journal(&mut reader3);
523        // Should fail with some error (corrupt journal superblock)
524        assert!(result.is_err(), "corrupted journal should fail to parse");
525    }
526
527    #[test]
528    fn journal_corrupt_zero_block_size() {
529        let mut data = if let Some(d) = forensic_raw() {
530            d
531        } else {
532            eprintln!("skip: forensic.img not found");
533            return;
534        };
535        let reader = open_forensic().unwrap();
536        let sb = reader.block_reader().superblock();
537        let journal_ino = u64::from(sb.journal_inum);
538
539        let reader2 = open_forensic().unwrap();
540        let mappings = reader2.inode_block_map(journal_ino).unwrap();
541        if mappings.is_empty() {
542            eprintln!("skip: journal inode has no block mappings");
543            return;
544        }
545
546        let first_phys = mappings[0].physical_block;
547        let block_size = sb.block_size as usize;
548        let offset = first_phys as usize * block_size;
549
550        // The journal superblock has blocksize at offset 12 (big-endian u32).
551        // Set it to 0 to trigger the "journal block size is 0" error.
552        // But we need to keep the magic valid so JournalSuperblock::parse succeeds.
553        // Journal magic is at offset 0: 0xC03B3998 (big-endian)
554        if offset + 16 <= data.len() {
555            // Zero the block_size field (offset 12-15 in journal superblock)
556            data[offset + 12] = 0;
557            data[offset + 13] = 0;
558            data[offset + 14] = 0;
559            data[offset + 15] = 0;
560        }
561
562        let br = BlockReader::open(Cursor::new(data)).unwrap();
563        let mut reader3 = InodeReader::new(br);
564        let result = parse_journal(&mut reader3);
565        match result {
566            Err(Ext4Error::JournalCorrupt(msg)) => {
567                assert!(
568                    msg.contains("block size is 0"),
569                    "expected 'block size is 0' in: {msg}"
570                );
571            }
572            // If the journal superblock parse itself fails due to the zero
573            // block size affecting other validation, that's also acceptable
574            Err(_) => {}
575            Ok(_) => panic!("should have failed with zero journal block size"),
576        }
577    }
578
579    #[test]
580    fn journal_corrupt_too_small() {
581        let data = if let Some(d) = forensic_raw() {
582            d
583        } else {
584            eprintln!("skip: forensic.img not found");
585            return;
586        };
587        let reader = open_forensic().unwrap();
588        let sb = reader.block_reader().superblock();
589        let journal_ino = u64::from(sb.journal_inum);
590
591        // Patch the journal inode's size to be very small (< 1024)
592        // so we trigger the "journal too small" path
593        let mut patched = data.clone();
594
595        // Find inode position for journal inode
596        let ipg = u64::from(sb.inodes_per_group);
597        let inode_size = u64::from(sb.inode_size);
598        let bs = u64::from(sb.block_size);
599        let group = ((journal_ino - 1) / ipg) as u32;
600        let index = (journal_ino - 1) % ipg;
601        let inode_table = reader.block_reader().inode_table_block(group).unwrap();
602        let ino_offset = (inode_table * bs + index * inode_size) as usize;
603
604        // Set i_size_lo (offset 0x04) to 512 and i_size_hi (offset 0x6C) to 0
605        // Also zero the block count so read_inode_data returns minimal data
606        patched[ino_offset + 0x04] = 0x00;
607        patched[ino_offset + 0x05] = 0x02; // 512 in little-endian
608        patched[ino_offset + 0x06] = 0x00;
609        patched[ino_offset + 0x07] = 0x00;
610        patched[ino_offset + 0x6C] = 0x00; // size_hi = 0
611        patched[ino_offset + 0x6D] = 0x00;
612        patched[ino_offset + 0x6E] = 0x00;
613        patched[ino_offset + 0x6F] = 0x00;
614
615        let br = BlockReader::open(Cursor::new(patched)).unwrap();
616        let mut reader3 = InodeReader::new(br);
617        let result = parse_journal(&mut reader3);
618        // read_inode_data may truncate differently or still parse — either way
619        // the truncated path was exercised; only the corrupt case asserts a msg.
620        if let Err(Ext4Error::JournalCorrupt(msg)) = result {
621            assert!(msg.contains("too small"), "expected 'too small' in: {msg}");
622        }
623    }
624}