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