diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6c8d5..5183b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); - Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar. - Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida. - Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo. +- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root. ## [0.3.0] - 2026-10-18 diff --git a/README.md b/README.md index 6401606..3d414cb 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --relea > Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256. -Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). +Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu. ## Uso (terminal) diff --git a/crates/guara-core/src/image.rs b/crates/guara-core/src/image.rs index c6eb6e1..1cea459 100644 --- a/crates/guara-core/src/image.rs +++ b/crates/guara-core/src/image.rs @@ -40,6 +40,7 @@ pub fn create_image( dest.display() )) })?; + crate::owner::give_to_user(dest); let size = src.size(); let mut rep = ImageReport::default(); let mut hasher = MultiHasher::new(); @@ -81,7 +82,7 @@ pub fn create_image( rep.md5 = h.md5; rep.sha1 = h.sha1; let map = dest.with_extension("map.json"); - std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?; + crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?; Ok(rep) } diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 7702937..f77020b 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod fs; pub mod hashing; pub mod image; pub mod meta; +pub mod owner; pub mod partition; pub mod recover; pub mod report; diff --git a/crates/guara-core/src/owner.rs b/crates/guara-core/src/owner.rs new file mode 100644 index 0000000..ef11ef6 --- /dev/null +++ b/crates/guara-core/src/owner.rs @@ -0,0 +1,142 @@ +//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para +//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que +//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário. + +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct User { + pub uid: u32, + pub gid: u32, + pub home: PathBuf, +} + +/// O usuário real por trás de um processo root iniciado por sudo/pkexec. +#[cfg(unix)] +pub fn invoking_user() -> Option { + // SAFETY: geteuid não tem pré-condições. + if unsafe { libc::geteuid() } != 0 { + return None; + } + let uid = std::env::var("SUDO_UID") + .ok() + .or_else(|| std::env::var("PKEXEC_UID").ok()) + .and_then(|v| v.parse::().ok()) + .filter(|&u| u != 0); + let name = std::env::var("SUDO_USER").ok().filter(|u| u != "root"); + let passwd = std::fs::read_to_string("/etc/passwd").ok()?; + lookup_user(&passwd, uid, name.as_deref()) +} + +/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd. +pub fn lookup_user(passwd: &str, uid: Option, name: Option<&str>) -> Option { + for l in passwd.lines() { + let f: Vec<&str> = l.split(':').collect(); + if f.len() < 6 { + continue; + } + let (Ok(fuid), Ok(fgid)) = (f[2].parse::(), f[3].parse::()) else { + continue; + }; + if uid == Some(fuid) || (uid.is_none() && name == Some(f[0])) { + return Some(User { + uid: fuid, + gid: fgid, + home: PathBuf::from(f[5]), + }); + } + } + None +} + +#[cfg(not(unix))] +pub fn invoking_user() -> Option { + None +} + +/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root). +pub fn user_home() -> PathBuf { + if let Some(u) = invoking_user() { + return u.home; + } + PathBuf::from( + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_default(), + ) +} + +/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root). +pub fn give_to_user(path: &Path) { + #[cfg(unix)] + if let Some(u) = invoking_user() { + let _ = std::os::unix::fs::lchown(path, Some(u.uid), Some(u.gid)); + } + #[cfg(not(unix))] + let _ = path; +} + +/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo. +pub fn create_dir_all_owned(path: &Path) -> io::Result<()> { + let mut missing = vec![]; + let mut cur = Some(path); + while let Some(p) = cur { + if p.as_os_str().is_empty() || p.exists() { + break; + } + missing.push(p.to_path_buf()); + cur = p.parent(); + } + std::fs::create_dir_all(path)?; + for p in missing.iter().rev() { + give_to_user(p); + } + Ok(()) +} + +/// Como `fs::write`, mas o arquivo fica com o dono certo. +pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> { + if let Some(parent) = path.parent() { + create_dir_all_owned(parent)?; + } + std::fs::write(path, bytes)?; + give_to_user(path); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\nlucas:x:1000:1000:Lucas:/home/lucas:/bin/bash\nmaria:x:1001:1002::/home/maria:/bin/zsh\nlinha-quebrada\n"; + + #[test] + fn acha_o_usuario_por_uid_ou_nome() { + let u = lookup_user(PASSWD, Some(1000), None).unwrap(); + assert_eq!( + (u.uid, u.gid, u.home.to_str().unwrap()), + (1000, 1000, "/home/lucas") + ); + let m = lookup_user(PASSWD, None, Some("maria")).unwrap(); + assert_eq!( + (m.uid, m.gid, m.home.to_str().unwrap()), + (1001, 1002, "/home/maria") + ); + assert!(lookup_user(PASSWD, Some(4242), None).is_none()); + // o uid manda sobre o nome + assert_eq!( + lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid, + 1001 + ); + } + + #[test] + fn fora_do_modo_root_nao_muda_dono_e_grava_normalmente() { + let d = tempfile::tempdir().unwrap(); + let f = d.path().join("a/b/sessao.json"); + write_owned(&f, b"{}").unwrap(); + assert_eq!(std::fs::read(&f).unwrap(), b"{}"); + assert!(create_dir_all_owned(&d.path().join("x/y")).is_ok()); + } +} diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index b9c2174..3a0ff19 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -37,7 +37,7 @@ pub struct RecoverReport { /// Recusa destinos que ficam no mesmo dispositivo da origem. pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> { - std::fs::create_dir_all(dest)?; + crate::owner::create_dir_all_owned(dest)?; let dest = dest.canonicalize()?; let Some(sp) = source_path else { return Ok(()) }; let sp_str = sp.to_string_lossy().to_string(); @@ -79,7 +79,7 @@ pub fn recover( cancelled: &(dyn Fn() -> bool + Sync), progress: &mut dyn FnMut(usize, usize, u64), ) -> Result { - std::fs::create_dir_all(dest)?; + crate::owner::create_dir_all_owned(dest)?; let mut report = RecoverReport { destination: dest.display().to_string(), ..Default::default() @@ -94,7 +94,7 @@ pub fn recover( let rel = target_rel(f); let mut path = dest.join(&rel); if let Some(parent) = path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { + if let Err(e) = crate::owner::create_dir_all_owned(parent) { report.failed.push((f.id, e.to_string())); continue; } @@ -144,8 +144,8 @@ pub fn recover( "algoritmos_hash": ["MD5", "SHA-1", "SHA-256"], "relatorio": &report, }); - std::fs::write( - dest.join("guara-manifesto.json"), + crate::owner::write_owned( + &dest.join("guara-manifesto.json"), serde_json::to_vec_pretty(&manifest)?, )?; Ok(report) @@ -176,6 +176,7 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result { .write(true) .create_new(true) .open(path)?; + crate::owner::give_to_user(path); let mut hasher = MultiHasher::new(); match &f.data { DataLoc::Resident { bytes } => { diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 43f7d5f..c41eef6 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -450,7 +450,7 @@ impl Engine { Some(q) => q.select(&s.found), None => s.found.iter().collect(), }; - std::fs::write(path, crate::report::csv(&items))?; + crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?; Ok(items.len()) } @@ -479,7 +479,7 @@ impl Engine { recoveries: &s.recoveries, source_hashes: hashes.as_ref(), }); - std::fs::write(path, html)?; + crate::owner::write_owned(Path::new(path), html)?; Ok(()) } @@ -607,7 +607,7 @@ impl Engine { recoveries: s.recoveries.clone(), started_unix: s.started_unix, }; - std::fs::write(path, serde_json::to_vec(&data)?)?; + crate::owner::write_owned(Path::new(path), serde_json::to_vec(&data)?)?; Ok(()) } @@ -816,9 +816,7 @@ impl Engine { } "session_load" => json!({ "job": self.load_session(&s("path")?)? }), "defaults" => { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_default(); + let home = crate::owner::user_home().to_string_lossy().to_string(); json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() }) } "version" => { diff --git a/crates/guara-core/src/settings.rs b/crates/guara-core/src/settings.rs index 7f86780..52edf4e 100644 --- a/crates/guara-core/src/settings.rs +++ b/crates/guara-core/src/settings.rs @@ -29,8 +29,8 @@ pub fn config_dir() -> PathBuf { } #[cfg(unix)] { - if let Some(home) = invoking_user_home() { - return default_dir(home); + if let Some(u) = crate::owner::invoking_user() { + return default_dir(u.home); } } default_dir(PathBuf::from( @@ -69,25 +69,6 @@ fn default_dir(home: PathBuf) -> PathBuf { } } -/// Se somos root por sudo/pkexec, a home de quem invocou. -#[cfg(unix)] -fn invoking_user_home() -> Option { - // SAFETY: geteuid não tem pré-condições. - if unsafe { libc::geteuid() } != 0 { - return None; - } - let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root"); - let uid = std::env::var("PKEXEC_UID").ok(); - let passwd = std::fs::read_to_string("/etc/passwd").ok()?; - for l in passwd.lines() { - let f: Vec<&str> = l.split(':').collect(); - if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) { - return Some(PathBuf::from(f[5])); - } - } - None -} - fn file() -> PathBuf { config_dir().join("settings.json") } @@ -100,19 +81,6 @@ pub fn load() -> Settings { } pub fn save(s: &Settings) -> Result<()> { - let dir = config_dir(); - std::fs::create_dir_all(&dir)?; - let path = file(); - std::fs::write(&path, serde_json::to_vec_pretty(s)?)?; - #[cfg(unix)] - { - // Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar. - use std::os::unix::fs::MetadataExt; - if unsafe { libc::geteuid() } == 0 { - if let Ok(m) = std::fs::metadata(&dir) { - let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid())); - } - } - } + crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?; Ok(()) }