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#[derive(Debug, Clone)]
13pub struct JournalMapping {
14 pub journal_block: u64,
15 pub filesystem_block: u64,
16}
17
18#[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#[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#[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
48pub fn parse_journal<R: Read + Seek>(reader: &mut InodeReader<R>) -> Result<Journal> {
50 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 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 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 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 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 block_idx += 1 + pending_tags.len();
133 continue;
134 }
135 JournalBlockType::Commit => {
136 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 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
176pub fn inode_history<R: Read + Seek>(
182 reader: &mut InodeReader<R>,
183 journal: &Journal,
184 ino: u64,
185) -> Result<Vec<InodeVersion>> {
186 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 #[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 #[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 assert!(!reader.block_reader().superblock().has_journal());
316 match parse_journal(&mut reader) {
317 Err(Ext4Error::NoJournal) => {} other => panic!("expected NoJournal, got: {other:?}"),
319 }
320 }
321
322 #[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 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 assert!(
359 txn.commit_seconds >= 1_577_836_800, "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 let has_mappings = journal.transactions.iter().any(|t| !t.mappings.is_empty());
377 assert!(has_mappings, "some transactions should have block mappings");
378
379 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 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 #[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 let versions = inode_history(&mut reader, &journal, 12).unwrap();
423 for v in &versions {
425 assert!(v.sequence > 0);
426 assert!(v.commit_seconds >= 0);
427 }
428 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 let versions = inode_history(&mut reader, &journal, 21).unwrap();
446 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 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 let _ = versions;
469 }
470 }
471
472 #[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 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 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 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 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 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 if offset + 16 <= data.len() {
547 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 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 let mut patched = data.clone();
586
587 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 patched[ino_offset + 0x04] = 0x00;
599 patched[ino_offset + 0x05] = 0x02; patched[ino_offset + 0x06] = 0x00;
601 patched[ino_offset + 0x07] = 0x00;
602 patched[ino_offset + 0x6C] = 0x00; 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 if let Err(Ext4Error::JournalCorrupt(msg)) = result {
613 assert!(msg.contains("too small"), "expected 'too small' in: {msg}");
614 }
615 }
616}