Mescla a main (CI com imagens de referência e o passo do XFS)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ccbaa2d7e6
31 files changed
+1414
-101
No files matched your search
@@ -29,6 +29,8 @@ hmac = "0.13"
|
||||
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
|
||||
ripemd = "0.2"
|
||||
argon2 = "0.6"
|
||||
ed25519-compact = { version = "2", default-features = false, features = ["random", "std"] }
|
||||
lzfse_rust = "0.2.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -44,6 +44,7 @@ pub mod scan;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod settings;
|
||||
pub mod sign;
|
||||
pub mod signatures;
|
||||
pub mod source;
|
||||
pub mod sqlite;
|
||||
|
||||
@@ -286,10 +286,9 @@ pub fn recover_resumable(
|
||||
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
||||
"relatorio": &report,
|
||||
});
|
||||
crate::owner::write_owned(
|
||||
&dest.join("guara-manifesto.json"),
|
||||
serde_json::to_vec_pretty(&manifest)?,
|
||||
)?;
|
||||
let manifest_path = dest.join("guara-manifesto.json");
|
||||
crate::owner::write_owned(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
|
||||
crate::sign::sign_if_configured(&manifest_path);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
|
||||
@@ -706,6 +706,7 @@ impl Engine {
|
||||
crate::report::html(&input).into_bytes()
|
||||
};
|
||||
crate::owner::write_owned(Path::new(path), bytes)?;
|
||||
crate::sign::sign_if_configured(Path::new(path));
|
||||
crate::audit::record(
|
||||
"export.report",
|
||||
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Assinatura digital (Ed25519) de manifestos, relatórios e qualquer outro arquivo, e conferência
|
||||
//! de uma recuperação inteira.
|
||||
//!
|
||||
//! A assinatura fica ao lado do arquivo, em `<arquivo>.assinatura.json`, e cobre o SHA-256 do
|
||||
//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo,
|
||||
//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso
|
||||
//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de
|
||||
//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou
|
||||
//! registrou fora da máquina, passando-a em `trusted_fingerprint`.
|
||||
//!
|
||||
//! A hora é a do relógio da máquina de quem assinou: não é um carimbo de tempo de terceiros
|
||||
//! (RFC 3161) e, sozinha, não prova *quando* a assinatura foi feita.
|
||||
//!
|
||||
//! A chave privada fica em `assinatura.key` na pasta de configuração (ou onde
|
||||
//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem
|
||||
//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const FORMAT: &str = "guara-assinatura-v1";
|
||||
const KEY_HEADER: &str = "guara-ed25519-seed-v1";
|
||||
const PUB_HEADER: &str = "guara-ed25519-public-v1";
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if s.len() % 2 != 0 || !s.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Impressão digital da chave pública: os 10 primeiros bytes do SHA-256, em grupos de 4 hex.
|
||||
pub fn fingerprint(public_key: &[u8]) -> String {
|
||||
let h = Sha256::digest(public_key);
|
||||
let h = hex(&h[..10]);
|
||||
h.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
/// Compara impressões digitais ignorando maiúsculas, `:` e espaços.
|
||||
fn same_fingerprint(a: &str, b: &str) -> bool {
|
||||
let n = |s: &str| {
|
||||
s.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect::<String>()
|
||||
};
|
||||
let (a, b) = (n(a), n(b));
|
||||
!a.is_empty() && a == b
|
||||
}
|
||||
|
||||
/// Par de chaves de assinatura.
|
||||
pub struct SigningKey {
|
||||
kp: KeyPair,
|
||||
}
|
||||
|
||||
impl SigningKey {
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
kp: KeyPair::from_seed(Seed::generate()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_bytes(&self) -> [u8; 32] {
|
||||
*self.kp.pk
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> String {
|
||||
fingerprint(&self.public_bytes())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"não foi possível ler a chave {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
if lines.next() != Some(KEY_HEADER) {
|
||||
return Err(Error::msg(format!(
|
||||
"{} não é uma chave de assinatura do Guará",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let seed = lines
|
||||
.next()
|
||||
.and_then(unhex)
|
||||
.and_then(|b| <[u8; 32]>::try_from(b).ok())
|
||||
.ok_or_else(|| Error::msg("chave de assinatura corrompida"))?;
|
||||
Ok(Self {
|
||||
kp: KeyPair::from_seed(Seed::new(seed)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Grava a chave privada (0600 no Unix) e, ao lado, a pública (`.pub`). Recusa sobrescrever.
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
use std::io::Write;
|
||||
if let Some(dir) = path.parent() {
|
||||
crate::owner::create_dir_all_owned(dir)?;
|
||||
}
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
||||
let mut f = opts.open(path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
Error::msg(format!(
|
||||
"já existe uma chave em {}; apague-a de propósito se quiser trocar (as assinaturas antigas continuam válidas com a chave pública antiga)",
|
||||
path.display()
|
||||
))
|
||||
} else {
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
f.write_all(format!("{KEY_HEADER}\n{}\n", hex(self.kp.sk.seed().as_ref())).as_bytes())?;
|
||||
drop(f);
|
||||
crate::owner::give_to_user(path);
|
||||
let pubp = pub_path(path);
|
||||
crate::owner::write_owned(
|
||||
&pubp,
|
||||
format!(
|
||||
"{PUB_HEADER}\n{}\n# impressão digital: {}\n",
|
||||
hex(&self.public_bytes()),
|
||||
self.fingerprint()
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn pub_path(key: &Path) -> PathBuf {
|
||||
key.with_extension("pub")
|
||||
}
|
||||
|
||||
/// `GUARA_SIGNING_KEY` ou `assinatura.key` na pasta de configuração.
|
||||
pub fn default_key_path() -> PathBuf {
|
||||
match std::env::var("GUARA_SIGNING_KEY") {
|
||||
Ok(p) if !p.is_empty() => PathBuf::from(p),
|
||||
_ => crate::settings::config_dir().join("assinatura.key"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Arquivo que acompanha `path` com a assinatura.
|
||||
pub fn signature_path(path: &Path) -> PathBuf {
|
||||
let mut n = path.as_os_str().to_os_string();
|
||||
n.push(".assinatura.json");
|
||||
PathBuf::from(n)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignatureFile {
|
||||
pub formato: String,
|
||||
pub arquivo: String,
|
||||
pub tamanho: u64,
|
||||
pub sha256: String,
|
||||
pub assinado_em: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub assinante: Option<String>,
|
||||
pub chave_publica: String,
|
||||
pub impressao_digital: String,
|
||||
pub assinatura: String,
|
||||
}
|
||||
|
||||
fn message(f: &SignatureFile) -> Vec<u8> {
|
||||
format!(
|
||||
"{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante={}\n",
|
||||
f.formato,
|
||||
f.arquivo,
|
||||
f.tamanho,
|
||||
f.sha256,
|
||||
f.assinado_em,
|
||||
f.assinante.as_deref().unwrap_or("")
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// SHA-256 e tamanho de um arquivo, lido em blocos.
|
||||
pub fn hash_file(path: &Path) -> Result<(String, u64)> {
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(&buf[..n]);
|
||||
total += n as u64;
|
||||
}
|
||||
Ok((hex(&h.finalize()), total))
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
crate::audit::utc_iso(secs)
|
||||
}
|
||||
|
||||
/// Assina `path` e grava `<path>.assinatura.json`. Devolve o caminho da assinatura.
|
||||
pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
|
||||
let (sha256, size) = hash_file(path)?;
|
||||
let mut sig = SignatureFile {
|
||||
formato: FORMAT.into(),
|
||||
arquivo: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
tamanho: size,
|
||||
sha256,
|
||||
assinado_em: now_iso(),
|
||||
assinante: signer.filter(|s| !s.is_empty()).map(String::from),
|
||||
chave_publica: hex(&key.public_bytes()),
|
||||
impressao_digital: key.fingerprint(),
|
||||
assinatura: String::new(),
|
||||
};
|
||||
sig.assinatura = hex(key
|
||||
.kp
|
||||
.sk
|
||||
.sign(message(&sig), Some(Noise::generate()))
|
||||
.as_ref());
|
||||
let out = signature_path(path);
|
||||
crate::owner::write_owned(&out, serde_json::to_vec_pretty(&sig)?)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Resultado de conferir uma assinatura.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Check {
|
||||
/// O arquivo é o mesmo e a assinatura vale para a chave que vem nela.
|
||||
pub valid: bool,
|
||||
pub file_matches: bool,
|
||||
pub signature_valid: bool,
|
||||
pub fingerprint: String,
|
||||
pub signed_at: String,
|
||||
pub signer: Option<String>,
|
||||
/// `Some(true/false)` quando uma impressão digital de confiança foi informada.
|
||||
pub trusted: Option<bool>,
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn verify_file(
|
||||
path: &Path,
|
||||
sig_path: Option<&Path>,
|
||||
trusted_fingerprint: Option<&str>,
|
||||
) -> Result<Check> {
|
||||
let sp = sig_path
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| signature_path(path));
|
||||
let raw = std::fs::read(&sp).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"não foi possível ler a assinatura {}: {e}",
|
||||
sp.display()
|
||||
))
|
||||
})?;
|
||||
let sig: SignatureFile = serde_json::from_slice(&raw)
|
||||
.map_err(|e| Error::msg(format!("assinatura ilegível: {e}")))?;
|
||||
let mut problems = vec![];
|
||||
if sig.formato != FORMAT {
|
||||
problems.push(format!(
|
||||
"formato de assinatura desconhecido ({})",
|
||||
sig.formato
|
||||
));
|
||||
}
|
||||
let (sha, size) = hash_file(path)?;
|
||||
let file_matches = sha == sig.sha256 && size == sig.tamanho;
|
||||
if !file_matches {
|
||||
problems.push("o arquivo mudou depois de assinado (hash ou tamanho diferem)".into());
|
||||
}
|
||||
let pk = unhex(&sig.chave_publica).and_then(|b| PublicKey::from_slice(&b).ok());
|
||||
let sg = unhex(&sig.assinatura).and_then(|b| Signature::from_slice(&b).ok());
|
||||
let signature_valid = match (&pk, &sg) {
|
||||
(Some(pk), Some(sg)) => pk.verify(message(&sig), sg).is_ok(),
|
||||
_ => false,
|
||||
};
|
||||
if !signature_valid {
|
||||
problems.push("a assinatura não confere (campos alterados ou chave errada)".into());
|
||||
}
|
||||
let real_fp = pk
|
||||
.as_ref()
|
||||
.map(|p| fingerprint(p.as_ref()))
|
||||
.unwrap_or_default();
|
||||
if real_fp != sig.impressao_digital {
|
||||
problems.push("a impressão digital declarada não é a da chave pública".into());
|
||||
}
|
||||
let trusted = trusted_fingerprint.map(|t| same_fingerprint(t, &real_fp));
|
||||
if trusted == Some(false) {
|
||||
problems.push(format!(
|
||||
"a chave que assinou ({real_fp}) não é a esperada ({})",
|
||||
trusted_fingerprint.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
Ok(Check {
|
||||
valid: problems.is_empty(),
|
||||
file_matches,
|
||||
signature_valid,
|
||||
fingerprint: real_fp,
|
||||
signed_at: sig.assinado_em,
|
||||
signer: sig.assinante,
|
||||
trusted,
|
||||
problems,
|
||||
})
|
||||
}
|
||||
|
||||
/// Confere uma pasta de recuperação: a assinatura do manifesto (se houver) e o hash de cada
|
||||
/// arquivo contra o que o manifesto registrou.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecoveryCheck {
|
||||
pub manifest_signature: Option<Check>,
|
||||
pub files: usize,
|
||||
pub ok: usize,
|
||||
pub missing: Vec<String>,
|
||||
pub changed: Vec<String>,
|
||||
/// `true` quando tudo confere (e, havendo assinatura, ela vale).
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<RecoveryCheck> {
|
||||
let manifest = dir.join("guara-manifesto.json");
|
||||
let raw = std::fs::read(&manifest)
|
||||
.map_err(|e| Error::msg(format!("não foi possível ler {}: {e}", manifest.display())))?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&raw)?;
|
||||
let sigp = signature_path(&manifest);
|
||||
let manifest_signature = if sigp.is_file() {
|
||||
Some(verify_file(&manifest, None, trusted_fingerprint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let list = v["relatorio"]["recovered"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (mut ok, mut missing, mut changed) = (0, vec![], vec![]);
|
||||
for f in &list {
|
||||
let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else {
|
||||
continue;
|
||||
};
|
||||
// o manifesto é dado de entrada: não deixa sair da pasta
|
||||
if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() {
|
||||
changed.push(format!("{rel} (caminho fora da pasta)"));
|
||||
continue;
|
||||
}
|
||||
let p = dir.join(rel);
|
||||
match hash_file(&p) {
|
||||
Ok((h, _)) if h == want => ok += 1,
|
||||
Ok(_) => changed.push(rel.to_string()),
|
||||
Err(_) => missing.push(rel.to_string()),
|
||||
}
|
||||
}
|
||||
let signature_ok = match &manifest_signature {
|
||||
Some(c) => c.valid,
|
||||
None => true,
|
||||
};
|
||||
let valid = missing.is_empty() && changed.is_empty() && signature_ok;
|
||||
Ok(RecoveryCheck {
|
||||
manifest_signature,
|
||||
files: list.len(),
|
||||
ok,
|
||||
missing,
|
||||
changed,
|
||||
valid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assina `path` se existir uma chave configurada; nunca interrompe quem chamou. Registra na
|
||||
/// trilha de auditoria.
|
||||
pub fn sign_if_configured(path: &Path) {
|
||||
let kp = default_key_path();
|
||||
if !kp.is_file() {
|
||||
return;
|
||||
}
|
||||
let result = SigningKey::load(&kp)
|
||||
.and_then(|k| sign_file(&k, path, std::env::var("GUARA_EXAMINER").ok().as_deref()));
|
||||
match result {
|
||||
Ok(sig) => crate::audit::record(
|
||||
"sign",
|
||||
serde_json::json!({ "path": path.to_string_lossy(), "signature": sig.to_string_lossy() }),
|
||||
),
|
||||
Err(e) => eprintln!("aviso: não foi possível assinar {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
|
||||
//! zlib e bzip2. Imagens cifradas (`encrcdsa`) e chunks LZFSE (`ULFO`) são recusados com
|
||||
//! mensagem clara.
|
||||
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem
|
||||
//! clara.
|
||||
|
||||
use super::{bad, be32, be64, read_exact, read_vec, Backend};
|
||||
use crate::source::{FileSource, Source};
|
||||
@@ -187,7 +187,8 @@ impl Dmg {
|
||||
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||
}
|
||||
0x8000_0007 => {
|
||||
return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado"));
|
||||
lzfse_rust::decode_bytes(&raw, &mut out)
|
||||
.map_err(|e| bad(format!("chunk LZFSE inválido: {e}")))?;
|
||||
}
|
||||
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
|
||||
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
|
||||
//! diferenciais (VHD e VHDX com pai) seguem a cadeia até o disco base; QCOW2 e VMDK com
|
||||
//! arquivo de apoio são recusados com mensagem clara, porque ler só a camada filha
|
||||
//! devolveria dados errados.
|
||||
//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a
|
||||
//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi
|
||||
//! alterado depois do filho, os dados lidos refletem o pai atual.
|
||||
|
||||
mod dmg;
|
||||
mod qcow2;
|
||||
@@ -130,8 +130,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
||||
};
|
||||
let name = format!("{format} {}", path.display());
|
||||
let inner: Box<dyn Backend> = match format {
|
||||
"QCOW2" => Box::new(qcow2::Qcow2::open(file).map_err(|e| io_err(e, &name))?),
|
||||
"VMDK" => Box::new(vmdk::Vmdk::open(file, path).map_err(|e| io_err(e, &name))?),
|
||||
"QCOW2" => Box::new(qcow2::Qcow2::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VMDK" => Box::new(vmdk::Vmdk::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VHDX" => Box::new(vhdx::Vhdx::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
_ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?),
|
||||
@@ -139,17 +139,23 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
||||
Ok(Some(VDisk { inner, format }))
|
||||
}
|
||||
|
||||
/// Abre o disco pai de um VHD/VHDX diferencial.
|
||||
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o
|
||||
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato.
|
||||
pub(crate) fn open_parent(
|
||||
candidates: &[std::path::PathBuf],
|
||||
depth: u32,
|
||||
raw: bool,
|
||||
) -> io::Result<Box<dyn Source>> {
|
||||
for p in candidates {
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
|
||||
return Ok(Box::new(v));
|
||||
if !raw {
|
||||
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
|
||||
return Ok(Box::new(v));
|
||||
}
|
||||
} else if depth > MAX_DEPTH {
|
||||
return Err(bad("cadeia de discos pai longa demais ou circular"));
|
||||
}
|
||||
let f = FileSource::open(p)?;
|
||||
return Ok(Box::new(f));
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd) e
|
||||
//! clusters zerados. Imagens cifradas, com arquivo de apoio, com arquivo de dados externo ou
|
||||
//! com L2 estendido são recusadas.
|
||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd),
|
||||
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai.
|
||||
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um
|
||||
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas.
|
||||
|
||||
use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend};
|
||||
use super::{
|
||||
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
|
||||
Backend,
|
||||
};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
|
||||
@@ -16,12 +21,13 @@ pub(super) struct Qcow2 {
|
||||
cluster_bits: u32,
|
||||
l1: Vec<u64>,
|
||||
zstd: bool,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
|
||||
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl Qcow2 {
|
||||
pub(super) fn open(file: FileSource) -> io::Result<Self> {
|
||||
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
|
||||
if h.len() < 72 {
|
||||
return Err(bad("cabeçalho QCOW2 truncado"));
|
||||
@@ -30,11 +36,6 @@ impl Qcow2 {
|
||||
if version != 2 && version != 3 {
|
||||
return Err(bad(format!("versão QCOW {version} não suportada")));
|
||||
}
|
||||
if be64(&h, 8) != 0 {
|
||||
return Err(bad(
|
||||
"a imagem QCOW2 usa arquivo de apoio (backing file); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let cluster_bits = be32(&h, 20);
|
||||
if !(9..=21).contains(&cluster_bits) {
|
||||
return Err(bad(format!(
|
||||
@@ -63,6 +64,11 @@ impl Qcow2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
let parent = if be64(&h, 8) != 0 {
|
||||
Some(Self::open_backing(&file, &h, version, path, depth)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let l1_size = be32(&h, 36) as u64;
|
||||
let l1_off = be64(&h, 40);
|
||||
let n = check_table(l1_size, 8, "tabela L1")?;
|
||||
@@ -74,10 +80,65 @@ impl Qcow2 {
|
||||
cluster_bits,
|
||||
l1,
|
||||
zstd,
|
||||
parent,
|
||||
cache: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw`
|
||||
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz.
|
||||
fn open_backing(
|
||||
file: &FileSource,
|
||||
h: &[u8],
|
||||
version: u32,
|
||||
path: &Path,
|
||||
depth: u32,
|
||||
) -> io::Result<Box<dyn Source>> {
|
||||
let (off, len) = (be64(h, 8), be32(h, 16) as usize);
|
||||
if len == 0 || len > 1023 {
|
||||
return Err(bad("nome do arquivo de apoio QCOW2 inválido"));
|
||||
}
|
||||
let name = String::from_utf8_lossy(&read_vec(file, off, len)?).into_owned();
|
||||
if name.starts_with("json:")
|
||||
|| name.contains("://")
|
||||
|| name.split(':').next().is_some_and(|p| {
|
||||
matches!(p, "nbd" | "ssh" | "rbd" | "sheepdog" | "gluster" | "iscsi")
|
||||
})
|
||||
{
|
||||
return Err(bad(format!(
|
||||
"o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados"
|
||||
)));
|
||||
}
|
||||
// extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes
|
||||
let hlen = if version == 3 && h.len() >= 104 {
|
||||
be32(h, 100) as u64
|
||||
} else {
|
||||
72
|
||||
};
|
||||
let mut raw = false;
|
||||
let mut pos = hlen.div_ceil(8) * 8;
|
||||
for _ in 0..64 {
|
||||
let mut eh = [0u8; 8];
|
||||
if read_exact(file, pos, &mut eh).is_err() {
|
||||
break;
|
||||
}
|
||||
let (kind, elen) = (be32(&eh, 0), be32(&eh, 4) as u64);
|
||||
if kind == 0 || elen > 1 << 20 {
|
||||
break;
|
||||
}
|
||||
if kind == 0xE279_2ACA {
|
||||
let fmt = read_vec(file, pos + 8, elen as usize)?;
|
||||
raw = fmt == b"raw";
|
||||
}
|
||||
pos += 8 + elen.div_ceil(8) * 8;
|
||||
}
|
||||
if version == 2 {
|
||||
raw = false;
|
||||
}
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
open_parent(&candidate_paths(dir, &[name]), depth, raw)
|
||||
}
|
||||
|
||||
fn cluster_size(&self) -> u64 {
|
||||
1 << self.cluster_bits
|
||||
}
|
||||
@@ -139,8 +200,10 @@ impl Backend for Qcow2 {
|
||||
return Ok(n);
|
||||
}
|
||||
let off = entry & OFFSET_MASK;
|
||||
if off == 0 || entry & 1 != 0 {
|
||||
buf[..n].fill(0);
|
||||
if entry & 1 != 0 {
|
||||
buf[..n].fill(0); // zerado de propósito: não consulta o pai
|
||||
} else if off == 0 {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
} else {
|
||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ impl Vhd {
|
||||
}
|
||||
}
|
||||
names.push(utf16(&dh[64..64 + 512], true));
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -130,7 +130,7 @@ impl Vhdx {
|
||||
}
|
||||
}
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
|
||||
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
|
||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) e o formato SEsparse
|
||||
//! são recusados.
|
||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos
|
||||
//! que o filho não alocou; o formato SEsparse é recusado.
|
||||
|
||||
use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend};
|
||||
use super::{
|
||||
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
|
||||
read_vec, Backend,
|
||||
};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
@@ -36,20 +39,73 @@ struct Extent {
|
||||
pub(super) struct Vmdk {
|
||||
extents: Vec<Extent>,
|
||||
size: u64,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
}
|
||||
|
||||
fn parent_in(descriptor: &str) -> bool {
|
||||
descriptor.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.strip_prefix("parentCID")
|
||||
/// Valor de uma chave `chave=valor` do descritor, sem aspas.
|
||||
fn desc_value(descriptor: &str, key: &str) -> Option<String> {
|
||||
descriptor.lines().find_map(|l| {
|
||||
l.trim()
|
||||
.strip_prefix(key)
|
||||
.and_then(|r| r.trim_start().strip_prefix('='))
|
||||
.is_some_and(|v| {
|
||||
let v = v.trim().trim_matches('"');
|
||||
!v.is_empty() && !v.eq_ignore_ascii_case("ffffffff")
|
||||
})
|
||||
.map(|v| v.trim().trim_matches('"').to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido
|
||||
/// logo depois do cabeçalho de uma extensão esparsa.
|
||||
fn descriptor_of(path: &Path) -> Option<String> {
|
||||
let f = FileSource::open(path).ok()?;
|
||||
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?;
|
||||
if head.starts_with(b"KDMV") {
|
||||
let off = (le64(&head, 28) * SECTOR) as usize;
|
||||
let len = (le64(&head, 36) * SECTOR) as usize;
|
||||
let d = head.get(off..(off + len).min(head.len()))?;
|
||||
let end = d.iter().position(|&b| b == 0).unwrap_or(d.len());
|
||||
return Some(String::from_utf8_lossy(&d[..end]).into_owned());
|
||||
}
|
||||
head.starts_with(b"# Disk Desc")
|
||||
.then(|| String::from_utf8_lossy(&head).into_owned())
|
||||
}
|
||||
|
||||
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só
|
||||
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter.
|
||||
struct ParentRef {
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
cid: Option<String>,
|
||||
}
|
||||
|
||||
fn parent_ref(descriptor: &str) -> Option<ParentRef> {
|
||||
let cid = desc_value(descriptor, "parentCID")
|
||||
.filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("ffffffff"));
|
||||
let uuid = desc_value(descriptor, "ddb.uuid.parent")
|
||||
.filter(|u| !u.is_empty() && u.chars().any(|c| c != '0' && c != '-'));
|
||||
let name = desc_value(descriptor, "parentFileNameHint").filter(|n| !n.is_empty());
|
||||
(cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid })
|
||||
}
|
||||
|
||||
/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`.
|
||||
fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
|
||||
let mut d = Some(dir);
|
||||
for _ in 0..3 {
|
||||
let cur = d?;
|
||||
for e in std::fs::read_dir(cur).ok()?.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension()
|
||||
.is_some_and(|x| x.eq_ignore_ascii_case("vmdk"))
|
||||
&& descriptor_of(&p)
|
||||
.and_then(|ds| desc_value(&ds, "ddb.uuid.image"))
|
||||
.is_some_and(|u| u.eq_ignore_ascii_case(uuid))
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
d = cur.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Sparse {
|
||||
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
|
||||
let mut h = read_vec(&file, 0, SECTOR as usize)?;
|
||||
@@ -95,11 +151,6 @@ impl Sparse {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if descriptor.as_deref().is_some_and(parent_in) {
|
||||
return Err(bad(
|
||||
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let gde = capacity.div_ceil(gtes * grain);
|
||||
let n = check_table(gde, 4, "diretório de grãos")?;
|
||||
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
|
||||
@@ -118,7 +169,9 @@ impl Sparse {
|
||||
))
|
||||
}
|
||||
|
||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
/// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo
|
||||
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros.
|
||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
|
||||
let g = off / self.grain;
|
||||
let within = off % self.grain;
|
||||
let n = buf.len().min((self.grain - within) as usize);
|
||||
@@ -129,13 +182,16 @@ impl Sparse {
|
||||
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
|
||||
sector = u32::from_le_bytes(b) as u64;
|
||||
}
|
||||
if sector <= 1 {
|
||||
if sector == 0 {
|
||||
return Ok((n, false));
|
||||
}
|
||||
if sector == 1 {
|
||||
buf[..n].fill(0);
|
||||
return Ok(n);
|
||||
return Ok((n, true));
|
||||
}
|
||||
if !self.compressed {
|
||||
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
|
||||
return Ok(n);
|
||||
return Ok((n, true));
|
||||
}
|
||||
let mut cache = self.cache.lock().unwrap();
|
||||
if cache.as_ref().map(|c| c.0) != Some(g) {
|
||||
@@ -153,17 +209,12 @@ impl Sparse {
|
||||
}
|
||||
let data = &cache.as_ref().unwrap().1;
|
||||
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
|
||||
Ok(n)
|
||||
Ok((n, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
|
||||
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
||||
if parent_in(text) {
|
||||
return Err(bad(
|
||||
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut start = 0u64;
|
||||
for line in text.lines() {
|
||||
@@ -216,27 +267,79 @@ fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
||||
}
|
||||
|
||||
impl Vmdk {
|
||||
pub(super) fn open(file: FileSource, path: &Path) -> io::Result<Self> {
|
||||
fn open_parent_of(r: &ParentRef, dir: &Path, depth: u32) -> io::Result<Box<dyn Source>> {
|
||||
let mut found = r
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|n| candidate_paths(dir, std::slice::from_ref(n)))
|
||||
.unwrap_or_default();
|
||||
let by_name = found.iter().any(|p| p.is_file());
|
||||
if !by_name {
|
||||
if let Some(p) = r.uuid.as_deref().and_then(|u| find_by_uuid(dir, u)) {
|
||||
found = vec![p];
|
||||
}
|
||||
}
|
||||
if r.name.is_none() && r.uuid.is_none() {
|
||||
return Err(bad(
|
||||
"VMDK com pai, mas o descritor não diz qual (sem parentFileNameHint nem ddb.uuid.parent)",
|
||||
));
|
||||
}
|
||||
if r.name.is_none() && found.is_empty() {
|
||||
return Err(bad(
|
||||
"VMDK com pai identificado só por UUID (VirtualBox) e o pai não está na pasta do filho nem nas duas acima",
|
||||
));
|
||||
}
|
||||
if let Some(cid) = &r.cid {
|
||||
// o pai precisa ser o mesmo de quando o filho foi criado
|
||||
if let Some(p) = found.iter().find(|p| p.is_file()) {
|
||||
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
|
||||
if have
|
||||
.as_deref()
|
||||
.is_some_and(|h| !h.eq_ignore_ascii_case(cid))
|
||||
{
|
||||
return Err(bad(format!(
|
||||
"o pai {} foi alterado depois do filho (CID {} em vez de {cid}); os dados do filho não valem mais",
|
||||
p.display(),
|
||||
have.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
open_parent(&found, depth, false)
|
||||
}
|
||||
|
||||
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||
let mut head = [0u8; 4];
|
||||
let _ = file.read_at(0, &mut head);
|
||||
let extents = if &head == b"KDMV" {
|
||||
let (sp, size, _) = Sparse::open(file)?;
|
||||
vec![Extent {
|
||||
start: 0,
|
||||
len: size,
|
||||
kind: Kind::Sparse(sp),
|
||||
}]
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
let (extents, descriptor) = if &head == b"KDMV" {
|
||||
let (sp, size, descriptor) = Sparse::open(file)?;
|
||||
(
|
||||
vec![Extent {
|
||||
start: 0,
|
||||
len: size,
|
||||
kind: Kind::Sparse(sp),
|
||||
}],
|
||||
descriptor,
|
||||
)
|
||||
} else {
|
||||
if file.size() > 1 << 20 {
|
||||
return Err(bad("descritor VMDK grande demais"));
|
||||
}
|
||||
let raw = read_vec(&file, 0, file.size() as usize)?;
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
parse_descriptor(&text, dir)?
|
||||
let text = String::from_utf8_lossy(&raw).into_owned();
|
||||
(parse_descriptor(&text, dir)?, Some(text))
|
||||
};
|
||||
let parent = match descriptor.as_deref().and_then(parent_ref) {
|
||||
Some(r) => Some(Self::open_parent_of(&r, dir, depth)?),
|
||||
None => None,
|
||||
};
|
||||
let size = extents.iter().map(|e| e.len).sum();
|
||||
Ok(Self { extents, size })
|
||||
Ok(Self {
|
||||
extents,
|
||||
size,
|
||||
parent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +364,13 @@ impl Backend for Vmdk {
|
||||
read_exact(file, offset + rel, &mut buf[..n])?;
|
||||
Ok(n)
|
||||
}
|
||||
Kind::Sparse(s) => s.read(rel, &mut buf[..n]),
|
||||
Kind::Sparse(s) => {
|
||||
let (got, allocated) = s.read(rel, &mut buf[..n])?;
|
||||
if !allocated {
|
||||
read_parent(&self.parent, voff, &mut buf[..got])?;
|
||||
}
|
||||
Ok(got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,267 @@
|
||||
//! Assinatura digital: chaves, arquivos, adulteração, recuperação inteira e interoperabilidade.
|
||||
|
||||
use guara_core::recover;
|
||||
use guara_core::sign::{self, SigningKey};
|
||||
use guara_core::source::FileSource;
|
||||
use guara_core::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn resident(id: u64, name: &str, bytes: Vec<u8>) -> Found {
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_string();
|
||||
Found {
|
||||
id,
|
||||
name: name.into(),
|
||||
dir: None,
|
||||
category: signatures::category_for_ext(&ext),
|
||||
ext,
|
||||
size: bytes.len() as u64,
|
||||
origin: Origin::Carved,
|
||||
confidence: Confidence::High,
|
||||
data: DataLoc::Resident { bytes },
|
||||
modified: None,
|
||||
created: None,
|
||||
accessed: None,
|
||||
taken: None,
|
||||
camera: None,
|
||||
px: None,
|
||||
gps: false,
|
||||
sig: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `recover` assina sozinho quando `GUARA_SIGNING_KEY` existe; testes que recuperam não se misturam.
|
||||
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn keyed(dir: &Path) -> SigningKey {
|
||||
let k = SigningKey::generate();
|
||||
k.save(&dir.join("k.key")).unwrap();
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_roundtrip_and_no_overwrite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let k = keyed(dir.path());
|
||||
let again = SigningKey::load(&dir.path().join("k.key")).unwrap();
|
||||
assert_eq!(k.public_bytes(), again.public_bytes());
|
||||
assert_eq!(k.fingerprint(), again.fingerprint());
|
||||
assert_eq!(k.fingerprint().len(), 24); // 5 grupos de 4 + 4 separadores
|
||||
let pubfile = std::fs::read_to_string(dir.path().join("k.pub")).unwrap();
|
||||
assert!(pubfile.contains(&k.fingerprint()));
|
||||
let e = SigningKey::generate()
|
||||
.save(&dir.path().join("k.key"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("já existe uma chave"), "{e}");
|
||||
// a chave antiga continua intacta
|
||||
assert_eq!(
|
||||
SigningKey::load(&dir.path().join("k.key"))
|
||||
.unwrap()
|
||||
.fingerprint(),
|
||||
k.fingerprint()
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join("k.key"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"a chave privada precisa ser só do dono"
|
||||
);
|
||||
}
|
||||
std::fs::write(dir.path().join("lixo.key"), "outra coisa").unwrap();
|
||||
assert!(SigningKey::load(&dir.path().join("lixo.key")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_file_verifies_and_tampering_is_caught() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let k = keyed(dir.path());
|
||||
let f = dir.path().join("relatorio.pdf");
|
||||
std::fs::write(&f, b"conteudo do relatorio").unwrap();
|
||||
let sig = sign::sign_file(&k, &f, Some("Perita Ana")).unwrap();
|
||||
assert_eq!(sig, sign::signature_path(&f));
|
||||
let c = sign::verify_file(&f, None, Some(&k.fingerprint())).unwrap();
|
||||
assert!(
|
||||
c.valid && c.trusted == Some(true) && c.signer.as_deref() == Some("Perita Ana"),
|
||||
"{c:?}"
|
||||
);
|
||||
// fingerprint aceito em outro formato (maiúsculas, sem dois pontos)
|
||||
let loose = k.fingerprint().replace(':', " ").to_uppercase();
|
||||
assert_eq!(
|
||||
sign::verify_file(&f, None, Some(&loose)).unwrap().trusted,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// outra chave não é a esperada
|
||||
let other = SigningKey::generate().fingerprint();
|
||||
let c = sign::verify_file(&f, None, Some(&other)).unwrap();
|
||||
assert!(!c.valid && c.trusted == Some(false));
|
||||
|
||||
// arquivo alterado
|
||||
std::fs::write(&f, b"conteudo ALTERADO do relatorio").unwrap();
|
||||
let c = sign::verify_file(&f, None, None).unwrap();
|
||||
assert!(!c.valid && !c.file_matches && c.signature_valid, "{c:?}");
|
||||
std::fs::write(&f, b"conteudo do relatorio").unwrap();
|
||||
assert!(sign::verify_file(&f, None, None).unwrap().valid);
|
||||
|
||||
// campos da assinatura alterados (hora, assinante, hash) invalidam
|
||||
let raw = std::fs::read_to_string(&sig).unwrap();
|
||||
for (from, to) in [
|
||||
("Perita Ana", "Outra Pessoa"),
|
||||
("\"assinado_em\": \"20", "\"assinado_em\": \"19"),
|
||||
] {
|
||||
assert!(raw.contains(from), "{from}");
|
||||
std::fs::write(&sig, raw.replacen(from, to, 1)).unwrap();
|
||||
let c = sign::verify_file(&f, None, None).unwrap();
|
||||
assert!(!c.valid && !c.signature_valid, "{from}: {c:?}");
|
||||
}
|
||||
// trocar a chave pública pela de outra pessoa também não passa
|
||||
let mut j: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
let evil = SigningKey::generate();
|
||||
j["chave_publica"] = evil
|
||||
.public_bytes()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
.into();
|
||||
std::fs::write(&sig, serde_json::to_vec(&j).unwrap()).unwrap();
|
||||
assert!(!sign::verify_file(&f, None, None).unwrap().valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_folder_is_checked_file_by_file() {
|
||||
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let k = keyed(dir.path());
|
||||
let src = dir.path().join("origem.img");
|
||||
std::fs::write(&src, b"x").unwrap();
|
||||
let fs = FileSource::open(&src).unwrap();
|
||||
let a = resident(1, "a.txt", b"primeiro arquivo".to_vec());
|
||||
let b = resident(2, "b.txt", b"segundo arquivo".to_vec());
|
||||
let out = dir.path().join("saida");
|
||||
let rep = recover::recover(&fs, &[&a, &b], &out, &|| false, &mut |_, _, _| {}).unwrap();
|
||||
assert_eq!(rep.recovered.len(), 2);
|
||||
let manifest = out.join("guara-manifesto.json");
|
||||
sign::sign_file(&k, &manifest, None).unwrap();
|
||||
|
||||
let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap();
|
||||
assert!(c.valid && c.ok == 2 && c.files == 2, "{c:?}");
|
||||
|
||||
let first = out.join(&rep.recovered[0].path);
|
||||
let second = out.join(&rep.recovered[1].path);
|
||||
std::fs::write(&first, b"adulterado").unwrap();
|
||||
std::fs::remove_file(&second).unwrap();
|
||||
let c = sign::verify_recovery(&out, None).unwrap();
|
||||
assert!(
|
||||
!c.valid && c.changed.len() == 1 && c.missing.len() == 1,
|
||||
"{c:?}"
|
||||
);
|
||||
|
||||
// manifesto adulterado (hash trocado para acompanhar o arquivo alterado) quebra a assinatura
|
||||
let m = std::fs::read_to_string(&manifest).unwrap();
|
||||
let real = &rep.recovered[0].sha256;
|
||||
let fake = sign::hash_file(&first).unwrap().0;
|
||||
std::fs::write(&manifest, m.replace(real, &fake)).unwrap();
|
||||
let c = sign::verify_recovery(&out, None).unwrap();
|
||||
let sigcheck = c.manifest_signature.as_ref().unwrap();
|
||||
assert!(!sigcheck.valid && !sigcheck.file_matches, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_paths_cannot_escape_the_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("fora.txt"), b"segredo").unwrap();
|
||||
let out = dir.path().join("saida");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let sha = sign::hash_file(&dir.path().join("fora.txt")).unwrap().0;
|
||||
std::fs::write(
|
||||
out.join("guara-manifesto.json"),
|
||||
serde_json::json!({ "relatorio": { "recovered": [ { "path": "../fora.txt", "sha256": sha } ] } }).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let c = sign::verify_recovery(&out, None).unwrap();
|
||||
assert!(!c.valid && c.changed[0].contains("fora da pasta"), "{c:?}");
|
||||
}
|
||||
|
||||
/// O mesmo que o `openssl` faria: a assinatura é Ed25519 padrão sobre a mensagem documentada.
|
||||
#[test]
|
||||
fn signature_verifies_with_openssl() {
|
||||
let Ok(v) = std::process::Command::new("openssl")
|
||||
.arg("version")
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !v.status.success() {
|
||||
return;
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let k = keyed(dir.path());
|
||||
let f = dir.path().join("doc.txt");
|
||||
std::fs::write(&f, b"hello\n").unwrap();
|
||||
let sig = sign::sign_file(&k, &f, Some("Ana")).unwrap();
|
||||
let j: serde_json::Value = serde_json::from_slice(&std::fs::read(sig).unwrap()).unwrap();
|
||||
let unhex = |s: &str| -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
};
|
||||
let msg = format!(
|
||||
"{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante=Ana\n",
|
||||
j["formato"].as_str().unwrap(),
|
||||
j["arquivo"].as_str().unwrap(),
|
||||
j["tamanho"],
|
||||
j["sha256"].as_str().unwrap(),
|
||||
j["assinado_em"].as_str().unwrap()
|
||||
);
|
||||
std::fs::write(dir.path().join("msg"), msg).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("sig"),
|
||||
unhex(j["assinatura"].as_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut der = unhex("302a300506032b6570032100");
|
||||
der.extend(unhex(j["chave_publica"].as_str().unwrap()));
|
||||
std::fs::write(dir.path().join("pub.der"), der).unwrap();
|
||||
let r = std::process::Command::new("openssl")
|
||||
.current_dir(dir.path())
|
||||
.args([
|
||||
"pkeyutl", "-verify", "-pubin", "-inkey", "pub.der", "-keyform", "DER", "-rawin",
|
||||
"-in", "msg", "-sigfile", "sig",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
// OpenSSL 1.1 não tem -rawin para Ed25519; só exige sucesso onde ele existe
|
||||
let text = String::from_utf8_lossy(&r.stdout).to_string() + &String::from_utf8_lossy(&r.stderr);
|
||||
if text.contains("Unknown option") || text.contains("unknown option") {
|
||||
return;
|
||||
}
|
||||
assert!(r.status.success(), "{text}");
|
||||
}
|
||||
|
||||
/// Com chave configurada, a recuperação e o relatório saem assinados sem ninguém pedir.
|
||||
#[test]
|
||||
fn configured_key_signs_manifest_automatically() {
|
||||
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let k = keyed(dir.path());
|
||||
std::env::set_var("GUARA_SIGNING_KEY", dir.path().join("k.key"));
|
||||
std::env::set_var("GUARA_CONFIG_DIR", dir.path().join("cfg"));
|
||||
let src = dir.path().join("origem.img");
|
||||
std::fs::write(&src, b"x").unwrap();
|
||||
let fs = FileSource::open(&src).unwrap();
|
||||
let a = resident(1, "a.txt", b"dados".to_vec());
|
||||
let out = dir.path().join("saida");
|
||||
recover::recover(&fs, &[&a], &out, &|| false, &mut |_, _, _| {}).unwrap();
|
||||
std::env::remove_var("GUARA_SIGNING_KEY");
|
||||
let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap();
|
||||
assert!(c.valid && c.manifest_signature.is_some(), "{c:?}");
|
||||
}
|
||||
@@ -281,22 +281,82 @@ fn qcow2_with_zero_raw_and_compressed_clusters() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qcow2_with_backing_file_or_encryption_is_refused() {
|
||||
fn qcow2_with_encryption_or_a_remote_backing_file_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut img = build_qcow2(&raw());
|
||||
let p = dir.path().join("b.qcow2");
|
||||
be(&mut img, 8, 4096, 8);
|
||||
be(&mut img, 16, 3, 4);
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("arquivo de apoio"), "{e}");
|
||||
|
||||
let mut img = build_qcow2(&raw());
|
||||
be(&mut img, 32, 2, 4);
|
||||
let p = dir.path().join("e.qcow2");
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("LUKS"), "{e}");
|
||||
|
||||
let mut img = build_qcow2(&raw());
|
||||
let name = b"nbd://servidor/disco";
|
||||
img[512..512 + name.len()].copy_from_slice(name);
|
||||
be(&mut img, 8, 512, 8);
|
||||
be(&mut img, 16, name.len() as u64, 4);
|
||||
let p = dir.path().join("r.qcow2");
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("não é um arquivo local"), "{e}");
|
||||
}
|
||||
|
||||
/// Filho QCOW2 que só aloca o cluster 1 (dados), marca o 2 como zerado e deixa o resto para o pai.
|
||||
fn build_qcow2_child(backing: &str, fmt: Option<&str>, own: &[u8]) -> Vec<u8> {
|
||||
const CS: usize = 1 << 16;
|
||||
let mut img = vec![0u8; 3 * CS];
|
||||
img[..4].copy_from_slice(b"QFI\xfb");
|
||||
be(&mut img, 4, 3, 4);
|
||||
be(&mut img, 16, backing.len() as u64, 4);
|
||||
be(&mut img, 8, 1024, 8);
|
||||
img[1024..1024 + backing.len()].copy_from_slice(backing.as_bytes());
|
||||
be(&mut img, 20, 16, 4);
|
||||
be(&mut img, 24, 16 * CS as u64, 8);
|
||||
be(&mut img, 36, 1, 4);
|
||||
be(&mut img, 40, CS as u64, 8);
|
||||
be(&mut img, 100, 112, 4);
|
||||
if let Some(f) = fmt {
|
||||
// extensão 0xE2792ACA com o nome do formato do apoio, depois o fim das extensões
|
||||
be(&mut img, 112, 0xE279_2ACA, 4);
|
||||
be(&mut img, 116, f.len() as u64, 4);
|
||||
img[120..120 + f.len()].copy_from_slice(f.as_bytes());
|
||||
}
|
||||
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8);
|
||||
let host = img.len() as u64;
|
||||
img.extend_from_slice(own);
|
||||
img.resize(img.len().div_ceil(CS) * CS, 0);
|
||||
be(&mut img, 2 * CS + 8, host | (1 << 63), 8); // cluster 1
|
||||
be(&mut img, 2 * CS + 16, 1, 8); // cluster 2: zerado de propósito
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qcow2_backing_file_supplies_what_the_child_did_not_allocate() {
|
||||
const CS: usize = 1 << 16;
|
||||
let base = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("base.raw"), &base).unwrap();
|
||||
let own = vec![0x42u8; CS];
|
||||
let mut expect = base.clone();
|
||||
expect[CS..2 * CS].copy_from_slice(&own);
|
||||
expect[2 * CS..3 * CS].fill(0);
|
||||
for (name, fmt) in [("a.qcow2", Some("raw")), ("b.qcow2", None)] {
|
||||
let p = dir.path().join(name);
|
||||
std::fs::write(&p, build_qcow2_child("base.raw", fmt, &own)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
// com o pai em outro formato: o filho aponta para um QCOW2
|
||||
std::fs::write(dir.path().join("base.qcow2"), build_qcow2(&base)).unwrap();
|
||||
let p = dir.path().join("c.qcow2");
|
||||
std::fs::write(&p, build_qcow2_child("base.qcow2", Some("qcow2"), &own)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
// sem o pai, a abertura diz o que falta
|
||||
std::fs::remove_file(dir.path().join("base.raw")).unwrap();
|
||||
let e = open_path(&dir.path().join("a.qcow2"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("disco pai não encontrado"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VMDK ----------
|
||||
@@ -324,8 +384,114 @@ fn vmdk_descriptor_with_flat_and_zero_extents() {
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
/// VMDK esparso feito à mão: 16 grãos de 64 KiB; `grains[i]` = `None` (não alocado, vem do pai),
|
||||
/// `Some(None)` (zerado de propósito) ou `Some(Some(dados))`.
|
||||
fn build_vmdk_sparse(descriptor: &str, grains: &[Option<Option<Vec<u8>>>]) -> Vec<u8> {
|
||||
const GRAIN: usize = 128; // setores
|
||||
let mut img = vec![0u8; 26 * 512];
|
||||
img[..4].copy_from_slice(b"KDMV");
|
||||
le(&mut img, 4, 1, 4);
|
||||
le(&mut img, 8, 3, 4);
|
||||
le(&mut img, 12, (grains.len() * GRAIN) as u64, 8);
|
||||
le(&mut img, 20, GRAIN as u64, 8);
|
||||
le(&mut img, 28, 1, 8);
|
||||
le(&mut img, 36, 20, 8);
|
||||
le(&mut img, 44, 512, 4);
|
||||
le(&mut img, 56, 21, 8);
|
||||
le(&mut img, 64, 26, 8);
|
||||
img[512..512 + descriptor.len()].copy_from_slice(descriptor.as_bytes());
|
||||
le(&mut img, 21 * 512, 22, 4); // diretório de grãos -> tabela no setor 22
|
||||
for (i, g) in grains.iter().enumerate() {
|
||||
let entry = match g {
|
||||
None => 0,
|
||||
Some(None) => 1,
|
||||
Some(Some(d)) => {
|
||||
let sector = (img.len() / 512) as u64;
|
||||
let mut d = d.clone();
|
||||
d.resize(GRAIN * 512, 0);
|
||||
img.extend_from_slice(&d);
|
||||
sector
|
||||
}
|
||||
};
|
||||
le(&mut img, 22 * 512 + i * 4, entry, 4);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_with_a_parent_is_refused() {
|
||||
fn vmdk_child_reads_the_parent_for_unallocated_grains() {
|
||||
let base = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("base.vmdk"), build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=11111111\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
|
||||
&(0..16)
|
||||
.map(|i| Some(Some(base[i * 65536..(i + 1) * 65536].to_vec())))
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
.unwrap();
|
||||
let own = vec![0x37u8; 65536];
|
||||
let mut grains: Vec<Option<Option<Vec<u8>>>> = vec![None; 16];
|
||||
grains[3] = Some(Some(own.clone()));
|
||||
grains[5] = Some(None); // zerado de propósito: não consulta o pai
|
||||
let child = build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=22222222\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
|
||||
&grains,
|
||||
);
|
||||
let p = dir.path().join("child.vmdk");
|
||||
std::fs::write(&p, child).unwrap();
|
||||
let mut expect = base.clone();
|
||||
expect[3 * 65536..4 * 65536].copy_from_slice(&own);
|
||||
expect[5 * 65536..6 * 65536].fill(0);
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
/// Snapshot real do VirtualBox: o filho só diz o UUID do pai (`ddb.uuid.parent`), sem nome.
|
||||
#[test]
|
||||
fn virtualbox_snapshot_vmdk_finds_its_parent_by_uuid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("Snapshots");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
// o pai fica na pasta de cima, como o VirtualBox organiza a máquina
|
||||
std::fs::copy(fx("disk.vmdk"), dir.path().join("disco-base.vmdk")).unwrap();
|
||||
std::fs::copy(fx("vbox-snapshot.vmdk"), sub.join("filho.vmdk")).unwrap();
|
||||
check_content(&sub.join("filho.vmdk"), &raw());
|
||||
// sem o pai por perto, a abertura explica
|
||||
std::fs::remove_file(dir.path().join("disco-base.vmdk")).unwrap();
|
||||
let e = open_path(&sub.join("filho.vmdk"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("identificado só por UUID"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_child_is_refused_when_the_parent_changed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("base.vmdk"),
|
||||
build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=aaaaaaaa\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
|
||||
&vec![None; 16],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("filho.vmdk"),
|
||||
build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=bbbbbbbb\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
|
||||
&vec![None; 16],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let e = open_path(&dir.path().join("filho.vmdk"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("foi alterado depois do filho"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_with_a_parent_but_no_name_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("c.vmdk");
|
||||
std::fs::write(
|
||||
@@ -334,7 +500,7 @@ fn vmdk_with_a_parent_is_refused() {
|
||||
)
|
||||
.unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("disco pai"), "{e}");
|
||||
assert!(e.contains("não diz qual"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VHD ----------
|
||||
@@ -619,8 +785,36 @@ fn dmg_with_every_chunk_type() {
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
/// Chunks `ULFO` com dados compactados pelo compressor de referência da Apple (`lzfse`):
|
||||
/// um bloco `bvx2` (FSE, 64 KiB) e um pequeno (`bvxn`/LZVN).
|
||||
#[test]
|
||||
fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() {
|
||||
fn dmg_lzfse_chunks_from_the_reference_compressor() {
|
||||
let big = std::fs::read(fx("lzfse-64k.bin")).unwrap();
|
||||
let small = std::fs::read(fx("lzfse-small.bin")).unwrap();
|
||||
let mut small_padded = small.clone();
|
||||
small_padded.resize(small.len().div_ceil(512) * 512, 0);
|
||||
let chunks = [
|
||||
DmgChunk {
|
||||
ty: 0x8000_0007,
|
||||
sectors: (big.len() / 512) as u64,
|
||||
payload: std::fs::read(fx("lzfse-64k.bin.lzfse")).unwrap(),
|
||||
},
|
||||
DmgChunk {
|
||||
ty: 0x8000_0007,
|
||||
sectors: (small_padded.len() / 512) as u64,
|
||||
payload: std::fs::read(fx("lzfse-small.bin.lzfse")).unwrap(),
|
||||
},
|
||||
];
|
||||
let mut expect = big.clone();
|
||||
expect.extend_from_slice(&small_padded);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("l.dmg");
|
||||
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmg_with_a_corrupt_lzfse_chunk_and_encrypted_are_refused_with_a_reason() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("l.dmg");
|
||||
let chunks = [DmgChunk {
|
||||
|
||||
Reference in new issue
Block a user