CI / Format and clippy (push) Successful in 1m57s
CI / Dependency audit (push) Successful in 13s
CI / Check desktop app (Linux) (push) Successful in 3m54s
CI / Tests (Linux) (push) Failing after 7m25s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / CLI (linux-x86_64) (push) Successful in 2m39s
Release / Desktop app (linux-aarch64) (push) Successful in 10m9s
Release / Desktop app (linux-x86_64) (push) Successful in 12m20s
Release / Desktop app (windows) (push) Successful in 7m32s
Release / Build latest.json (push) Successful in 5s
Completes the English-only standardization for code, comments, scripts and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en, untouched). Covers the remaining crates/, apps/gui/ui/ and apps/gui/src-tauri/ comments, .gitea/ workflow and template comments, .githooks/ hooks, and the scripts/ source comments for the files already renamed in a prior commit (check.sh, generate-logo.py, install-hooks.sh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
271 lines
7.8 KiB
Rust
271 lines
7.8 KiB
Rust
//! ext2/3/4: deleted file recovery (with original filenames) on images built with `mke2fs`/`debugfs`.
|
|
|
|
mod common;
|
|
use common::*;
|
|
use guara_core::*;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
fn have(tool: &str) -> bool {
|
|
Command::new("sh")
|
|
.args(["-c", &format!("command -v {tool}")])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn debugfs(img: &Path, cmds: &[&str]) -> String {
|
|
let mut out = String::new();
|
|
for c in cmds {
|
|
let o = Command::new("debugfs")
|
|
.arg("-w")
|
|
.arg("-R")
|
|
.arg(c)
|
|
.arg(img)
|
|
.output()
|
|
.unwrap();
|
|
out.push_str(&String::from_utf8_lossy(&o.stdout));
|
|
out.push_str(&String::from_utf8_lossy(&o.stderr));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn inode_of(img: &Path, path: &str) -> u64 {
|
|
let o = debugfs(img, &[&format!("stat {path}")]);
|
|
let l = o
|
|
.lines()
|
|
.find(|l| l.starts_with("Inode:"))
|
|
.unwrap_or_else(|| panic!("{o}"));
|
|
l.split_whitespace().nth(1).unwrap().parse().unwrap()
|
|
}
|
|
|
|
fn content(seed: u32, len: usize) -> Vec<u8> {
|
|
let mut x = 88172645463325252u64 ^ seed as u64;
|
|
(0..len)
|
|
.map(|_| {
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
(x >> 24) as u8
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct Img {
|
|
_dir: tempfile::TempDir,
|
|
img: PathBuf,
|
|
files: Vec<(&'static str, Vec<u8>)>,
|
|
}
|
|
|
|
fn build(opts: &[&str]) -> Option<Img> {
|
|
if !have("mke2fs") || !have("debugfs") {
|
|
eprintln!("SKIP: e2fsprogs ausente");
|
|
return None;
|
|
}
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let root = dir.path().join("root");
|
|
std::fs::create_dir_all(root.join("docs/sub")).unwrap();
|
|
std::fs::create_dir_all(root.join("fotos")).unwrap();
|
|
let files = vec![
|
|
("docs/relatorio.dat", content(1, 150_000)),
|
|
("docs/sub/contrato.dat", content(2, 9_000)),
|
|
("fotos/viagem.dat", content(3, 700_000)),
|
|
("fotos/fica.dat", content(4, 30_000)),
|
|
];
|
|
for (p, c) in &files {
|
|
std::fs::write(root.join(p), c).unwrap();
|
|
}
|
|
let img = dir.path().join("e.img");
|
|
let f = std::fs::File::create(&img).unwrap();
|
|
f.set_len(24 << 20).unwrap();
|
|
let st = Command::new("mke2fs")
|
|
.args(["-q", "-F", "-d"])
|
|
.arg(&root)
|
|
.args(opts)
|
|
.arg(&img)
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
st.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&st.stderr)
|
|
);
|
|
Some(Img {
|
|
_dir: dir,
|
|
img,
|
|
files,
|
|
})
|
|
}
|
|
|
|
fn deep(img: &Path) -> common::Collect {
|
|
init();
|
|
let opts = ScanOptions {
|
|
mode: ScanMode::Deep,
|
|
..ScanOptions::default()
|
|
};
|
|
scan_path(img, &opts)
|
|
}
|
|
|
|
fn recovered(img: &Path, f: &Found) -> Vec<u8> {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let rep = recover_all(img, std::slice::from_ref(f), d.path());
|
|
assert!(rep.failed.is_empty(), "{:?}", rep.failed);
|
|
let p = &rep.recovered[0].path;
|
|
let p = if Path::new(p).is_absolute() {
|
|
PathBuf::from(p)
|
|
} else {
|
|
d.path().join(p)
|
|
};
|
|
std::fs::read(p).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_by_debugfs_keeps_extents_and_name() {
|
|
let Some(i) = build(&["-t", "ext4"]) else {
|
|
return;
|
|
};
|
|
debugfs(&i.img, &["rm /docs/relatorio.dat", "rm /fotos/viagem.dat"]);
|
|
let c = deep(&i.img);
|
|
for (p, data) in i
|
|
.files
|
|
.iter()
|
|
.filter(|f| f.0 == "docs/relatorio.dat" || f.0 == "fotos/viagem.dat")
|
|
{
|
|
let name = p.rsplit('/').next().unwrap();
|
|
let f = c
|
|
.found
|
|
.iter()
|
|
.find(|f| f.name == name && f.origin == Origin::Ext)
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"{name} não encontrado: {:?}",
|
|
c.found
|
|
.iter()
|
|
.map(|f| (&f.name, f.origin))
|
|
.collect::<Vec<_>>()
|
|
)
|
|
});
|
|
assert_eq!(f.size, data.len() as u64);
|
|
assert_eq!(
|
|
f.dir.as_deref(),
|
|
Some(&format!("/{}", p.rsplit_once('/').unwrap().0)[..])
|
|
);
|
|
assert!(recovered(&i.img, f) == *data, "{name}");
|
|
}
|
|
// Live files that were not deleted must not be listed.
|
|
assert!(!c
|
|
.found
|
|
.iter()
|
|
.any(|f| f.name == "fica.dat" && f.origin == Origin::Ext));
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_inode_recovered_by_remnant_extents() {
|
|
// Simulate Linux kernel `ext4_evict_inode`: zero `i_size` and `eh_entries` while leaving extent records.
|
|
let Some(i) = build(&["-t", "ext4"]) else {
|
|
return;
|
|
};
|
|
let ino = inode_of(&i.img, "/docs/sub/contrato.dat");
|
|
debugfs(
|
|
&i.img,
|
|
&[
|
|
"rm /docs/sub/contrato.dat",
|
|
&format!("sif <{ino}> size 0"),
|
|
&format!("sif <{ino}> block[0] 0xF30A"),
|
|
],
|
|
);
|
|
let c = deep(&i.img);
|
|
let f = c
|
|
.found
|
|
.iter()
|
|
.find(|f| f.name == "contrato.dat")
|
|
.expect("contrato.dat");
|
|
assert_eq!(f.origin, Origin::Ext);
|
|
// Exact byte size was zeroed on deletion: recovers all 3 allocated 4 KiB blocks (12,288 bytes).
|
|
assert_eq!(f.size, 12288);
|
|
let got = recovered(&i.img, f);
|
|
assert_eq!(&got[..9000], &i.files[1].1[..]);
|
|
}
|
|
|
|
#[test]
|
|
fn journal_copy_restores_exact_size() {
|
|
if !have("debugfs") {
|
|
return;
|
|
}
|
|
let Some(i) = build(&["-t", "ext4", "-O", "has_journal"]) else {
|
|
return;
|
|
};
|
|
// Locate the inode table block and byte offset for ` target ` via `debugfs imap`.
|
|
let ino = inode_of(&i.img, "/fotos/viagem.dat");
|
|
let o = debugfs(&i.img, &["stats"]);
|
|
let _ = o;
|
|
let blk_line = debugfs(&i.img, &[&format!("imap <{ino}>")]);
|
|
// "Inode 12 is part of block group 0\n\tlocated at block 1058, offset 0x0300"
|
|
let blk: u64 = blk_line
|
|
.split("located at block ")
|
|
.nth(1)
|
|
.and_then(|s| s.split(',').next())
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or_else(|| panic!("imap: {blk_line}"));
|
|
let bs = 4096u64;
|
|
let raw = std::fs::read(&i.img).unwrap();
|
|
let old = &raw[(blk * bs) as usize..((blk + 1) * bs) as usize];
|
|
let bin = i.img.with_extension("blk");
|
|
std::fs::write(&bin, old).unwrap();
|
|
let script = i.img.with_extension("cmds");
|
|
std::fs::write(
|
|
&script,
|
|
format!("jo\njw -b {blk} {}\njc\njc\n", bin.display()),
|
|
)
|
|
.unwrap();
|
|
let o = Command::new("debugfs")
|
|
.arg("-w")
|
|
.arg("-f")
|
|
.arg(&script)
|
|
.arg(&i.img)
|
|
.output()
|
|
.unwrap();
|
|
let j = format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&o.stdout),
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
if j.contains("Unknown request") || j.contains("not supported") {
|
|
eprintln!("SKIP: debugfs sem suporte a escrita no diário: {j}");
|
|
return;
|
|
}
|
|
// Delete the file and wipe its inode table slot (leaving only the journal copy).
|
|
debugfs(
|
|
&i.img,
|
|
&[
|
|
"rm /fotos/viagem.dat",
|
|
&format!("sif <{ino}> size 0"),
|
|
&format!("sif <{ino}> block[0] 0xF30A"),
|
|
],
|
|
);
|
|
let c = deep(&i.img);
|
|
let f = c
|
|
.found
|
|
.iter()
|
|
.find(|f| f.name == "viagem.dat")
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"sem viagem.dat: {:?}",
|
|
c.found.iter().map(|f| &f.name).collect::<Vec<_>>()
|
|
)
|
|
});
|
|
assert_eq!(f.size, 700_000, "{:?}", f.note);
|
|
assert!(recovered(&i.img, f) == i.files[2].1);
|
|
}
|
|
|
|
#[test]
|
|
fn ext2_blockmap_deleted_is_not_invented() {
|
|
// Linux ext2 zeroes block pointers on unlink; without a journal, metadata recovery finds nothing,
|
|
// but the scan must succeed cleanly without reporting live files as deleted.
|
|
let Some(i) = build(&["-t", "ext2"]) else {
|
|
return;
|
|
};
|
|
let c = deep(&i.img);
|
|
assert!(!c.found.iter().any(|f| f.origin == Origin::Ext));
|
|
}
|