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 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 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 block_idx += 1 + pending_tags.len();
141 continue;
142 }
143 JournalBlockType::Commit => {
144 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 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
184pub fn inode_history<R: Read + Seek>(
190 reader: &mut InodeReader<R>,
191 journal: &Journal,
192 ino: u64,
193) -> Result<Vec<InodeVersion>> {
194 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 #[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 #[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 assert!(!reader.block_reader().superblock().has_journal());
324 match parse_journal(&mut reader) {
325 Err(Ext4Error::NoJournal) => {} other => panic!("expected NoJournal, got: {other:?}"),
327 }
328 }
329
330 #[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 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 assert!(
367 txn.commit_seconds >= 1_577_836_800, "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 let has_mappings = journal.transactions.iter().any(|t| !t.mappings.is_empty());
385 assert!(has_mappings, "some transactions should have block mappings");
386
387 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 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 #[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 let versions = inode_history(&mut reader, &journal, 12).unwrap();
431 for v in &versions {
433 assert!(v.sequence > 0);
434 assert!(v.commit_seconds >= 0);
435 }
436 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 let versions = inode_history(&mut reader, &journal, 21).unwrap();
454 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 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 let _ = versions;
477 }
478 }
479
480 #[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 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 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 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 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 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 if offset + 16 <= data.len() {
555 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 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 let mut patched = data.clone();
594
595 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 patched[ino_offset + 0x04] = 0x00;
607 patched[ino_offset + 0x05] = 0x02; patched[ino_offset + 0x06] = 0x00;
609 patched[ino_offset + 0x07] = 0x00;
610 patched[ino_offset + 0x6C] = 0x00; 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 if let Err(Ext4Error::JournalCorrupt(msg)) = result {
621 assert!(msg.contains("too small"), "expected 'too small' in: {msg}");
622 }
623 }
624}