Files
Lucas RayanandClaude Sonnet 5 6328220c60
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
chore(i18n): finish translating code comments and scripts to English
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
2026-10-30 20:43:02 -03:00

605 lines
21 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Forensic features: multi-hash, metadata extraction (EXIF/PDF/Office/MP4), thumbnails,
//! full-disk text search, CSV/HTML exports, and explorer facets/deduplication.
use guara_core::explorer::{self, Query};
use guara_core::hashing::hash_bytes;
use guara_core::source::MemSource;
use guara_core::*;
use std::io::Write;
fn resident(name: &str, bytes: Vec<u8>) -> Found {
let ext = name.rsplit('.').next().unwrap_or("").to_string();
Found {
id: 1,
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,
}
}
// ------------------------------------------------------------------ hashes
#[test]
fn hashes_conferem_com_vetores_conhecidos() {
let h = hash_bytes(b"abc");
assert_eq!(h.md5, "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(h.sha1, "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(
h.sha256,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
// ------------------------------------------------------------------ EXIF
fn ifd(entries: &[(u16, u16, u32, Vec<u8>)], start: usize) -> Vec<u8> {
let n = entries.len();
let mut table = (n as u16).to_le_bytes().to_vec();
let mut data: Vec<u8> = vec![];
let data_off = start + 2 + 12 * n + 4;
for (tag, typ, cnt, val) in entries {
table.extend(tag.to_le_bytes());
table.extend(typ.to_le_bytes());
table.extend(cnt.to_le_bytes());
if val.len() <= 4 {
let mut v = val.clone();
v.resize(4, 0);
table.extend(v);
} else {
if data.len() % 2 == 1 {
data.push(0);
}
table.extend(((data_off + data.len()) as u32).to_le_bytes());
data.extend(val);
}
}
table.extend([0, 0, 0, 0]);
table.extend(data);
table
}
fn rat(p: &[(u32, u32)]) -> Vec<u8> {
p.iter()
.flat_map(|(a, b)| a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
.collect()
}
fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec<u8> {
// gradient pixel pattern
let mut px = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
px.extend([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]);
}
}
let mut jpg = vec![];
jpeg_encoder::Encoder::new(&mut jpg, 85)
.encode(&px, w as u16, h as u16, jpeg_encoder::ColorType::Rgb)
.unwrap();
let asc = |s: &str| {
let mut v = s.as_bytes().to_vec();
v.push(0);
v
};
let make = asc("Canon");
let model = asc("Canon EOS 80D");
let dt = asc("2023:07:14 18:22:05");
let e0 = |exif: u32, gps: u32| {
ifd(
&[
(0x010F, 2, make.len() as u32, make.clone()),
(0x0110, 2, model.len() as u32, model.clone()),
(0x0112, 3, 1, orientation.to_le_bytes().to_vec()),
(0x8769, 4, 1, exif.to_le_bytes().to_vec()),
(0x8825, 4, 1, gps.to_le_bytes().to_vec()),
],
8,
)
};
let first = e0(0, 0);
let exif_off = 8 + first.len();
let exif_ifd = ifd(&[(0x9003, 2, dt.len() as u32, dt.clone())], exif_off);
let gps_off = exif_off + exif_ifd.len();
let gps_ifd = ifd(
&[
(1, 2, 2, b"S\0".to_vec()),
(2, 5, 3, rat(&[(23, 1), (33, 1), (1800, 100)])),
(3, 2, 2, b"W\0".to_vec()),
(4, 5, 3, rat(&[(46, 1), (38, 1), (0, 1)])),
],
gps_off,
);
let mut tiff = b"II*\0".to_vec();
tiff.extend(8u32.to_le_bytes());
tiff.extend(e0(exif_off as u32, gps_off as u32));
tiff.extend(exif_ifd);
tiff.extend(gps_ifd);
let mut app1 = b"Exif\0\0".to_vec();
app1.extend(tiff);
let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1];
out.extend(((app1.len() + 2) as u16).to_be_bytes());
out.extend(app1);
out.extend(&jpg[2..]);
out
}
#[test]
fn exif_camera_data_e_gps() {
let f = resident("foto.jpg", jpeg_with_exif(1, 64, 48));
let m = meta::extract(&MemSource(vec![]), &f);
assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D"));
assert_eq!(m.px, Some((64, 48)));
// `2023:07:14 18:22:05` interpreted as UTC
assert_eq!(m.taken, Some(1_689_358_925));
let (lat, lon) = m.gps.unwrap();
assert!((lat + 23.5550).abs() < 0.001, "{lat}");
assert!((lon + 46.6333).abs() < 0.001, "{lon}");
assert!(m
.fields
.iter()
.any(|(k, v)| k == "Foto tirada em" && v == "2023-07-14T18:22:05"));
}
#[test]
fn miniatura_respeita_orientacao_e_tamanho() {
let src = MemSource(vec![]);
// 64x48 with EXIF orientation 6 (90° CW) -> portrait 48x64, scaled down to fit within 32px
let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48));
let t = thumb::thumbnail(&src, &f, 32).expect("miniatura");
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
dec.decode().unwrap();
let i = dec.info().unwrap();
assert_eq!((i.width, i.height), (24, 32));
// PNG thumbnail generation
let mut img = ::image::RgbImage::new(100, 50);
for p in img.pixels_mut() {
*p = ::image::Rgb([200, 30, 30]);
}
let mut png = std::io::Cursor::new(vec![]);
img.write_to(&mut png, ::image::ImageFormat::Png).unwrap();
let f = resident("x.png", png.into_inner());
let t = thumb::thumbnail(&src, &f, 40).expect("miniatura png");
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
dec.decode().unwrap();
assert_eq!(
(dec.info().unwrap().width, dec.info().unwrap().height),
(40, 20)
);
// random bytes return `None`
assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none());
}
// ------------------------------------------------------------------ PDF, Office, MP4
#[test]
fn metadados_de_pdf() {
let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count 12 /Kids [] >>\nendobj\n3 0 obj\n<< /Title (Relat\\363rio Final) /Author (Maria \\(Perita\\) Silva) /Creator (LibreOffice Writer) /Producer <FEFF00500044004600310032> /CreationDate (D:20230714182205-03'00') >>\nendobj\ntrailer\n%%EOF\n".to_vec();
let m = meta::extract(&MemSource(vec![]), &resident("a.pdf", pdf));
let get = |k: &str| {
m.fields
.iter()
.find(|(a, _)| a == k)
.map(|(_, v)| v.clone())
};
assert_eq!(get("Título").as_deref(), Some("Relatório Final"));
assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva"));
assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer"));
assert_eq!(get("Gerado por").as_deref(), Some("PDF12"));
assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05"));
assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12"));
}
fn zip_store(entries: &[(&str, &str)], deflate: bool) -> Vec<u8> {
let mut out = vec![];
let mut cd = vec![];
for (name, body) in entries {
let (method, data): (u16, Vec<u8>) = if deflate {
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
e.write_all(body.as_bytes()).unwrap();
(8, e.finish().unwrap())
} else {
(0, body.as_bytes().to_vec())
};
let off = out.len() as u32;
let crc = signatures::crc32(body.as_bytes());
let mut lh = b"PK\x03\x04".to_vec();
lh.extend([20, 0, 0, 0]);
lh.extend(method.to_le_bytes());
lh.extend([0, 0, 0, 0]);
lh.extend(crc.to_le_bytes());
lh.extend((data.len() as u32).to_le_bytes());
lh.extend((body.len() as u32).to_le_bytes());
lh.extend((name.len() as u16).to_le_bytes());
lh.extend([0, 0]);
lh.extend(name.as_bytes());
out.extend(&lh);
out.extend(&data);
let mut c = b"PK\x01\x02".to_vec();
c.extend([20, 0, 20, 0, 0, 0]);
c.extend(method.to_le_bytes());
c.extend([0, 0, 0, 0]);
c.extend(crc.to_le_bytes());
c.extend((data.len() as u32).to_le_bytes());
c.extend((body.len() as u32).to_le_bytes());
c.extend((name.len() as u16).to_le_bytes());
c.extend([0; 12]);
c.extend(off.to_le_bytes());
c.extend(name.as_bytes());
cd.extend(c);
}
let cd_off = out.len() as u32;
out.extend(&cd);
out.extend(b"PK\x05\x06\0\0\0\0");
out.extend((entries.len() as u16).to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
out.extend((cd.len() as u32).to_le_bytes());
out.extend(cd_off.to_le_bytes());
out.extend([0, 0]);
out
}
#[test]
fn metadados_de_documentos_office() {
let core = r#"<?xml version="1.0"?><cp:coreProperties xmlns:cp="x" xmlns:dc="y" xmlns:dcterms="z"><dc:title>Tese &amp; Defesa</dc:title><dc:creator>Ana Souza</dc:creator><cp:lastModifiedBy>Bruno Lima</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">2023-07-14T18:22:05Z</dcterms:created></cp:coreProperties>"#;
let app = "<Properties><Application>Microsoft Office Word</Application><Company>ACME</Company></Properties>";
for deflate in [false, true] {
let z = zip_store(
&[
("[Content_Types].xml", "<Types/>"),
("docProps/core.xml", core),
("docProps/app.xml", app),
],
deflate,
);
let m = meta::extract(&MemSource(vec![]), &resident("t.docx", z));
let get = |k: &str| {
m.fields
.iter()
.find(|(a, _)| a == k)
.map(|(_, v)| v.clone())
};
assert_eq!(
get("Título").as_deref(),
Some("Tese & Defesa"),
"deflate={deflate}"
);
assert_eq!(get("Autor").as_deref(), Some("Ana Souza"));
assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima"));
assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05Z"));
assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word"));
assert_eq!(get("Empresa").as_deref(), Some("ACME"));
}
}
#[test]
fn metadados_de_mp4() {
fn bx(ty: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
v.extend(ty);
v.extend(body);
v
}
let created_1904: u32 = 1_689_358_925 + 2_082_844_800;
let mut mvhd = vec![0, 0, 0, 0];
mvhd.extend(created_1904.to_be_bytes());
mvhd.extend(created_1904.to_be_bytes());
mvhd.extend(1000u32.to_be_bytes()); // timescale
mvhd.extend(125_000u32.to_be_bytes()); // 125 s
mvhd.extend(vec![0u8; 80]);
let mut tkhd = vec![0u8; 84];
tkhd[76..80].copy_from_slice(&(1920u32 << 16).to_be_bytes());
tkhd[80..84].copy_from_slice(&(1080u32 << 16).to_be_bytes());
let moov = bx(
b"moov",
&[bx(b"mvhd", &mvhd), bx(b"trak", &bx(b"tkhd", &tkhd))].concat(),
);
let mut file = bx(b"ftyp", b"isom\0\0\0\0isom");
file.extend(moov);
file.extend(bx(b"mdat", &[0u8; 32]));
let m = meta::extract(&MemSource(vec![]), &resident("v.mp4", file));
let get = |k: &str| {
m.fields
.iter()
.find(|(a, _)| a == k)
.map(|(_, v)| v.clone())
};
assert_eq!(get("Duração").as_deref(), Some("0:02:05"));
assert_eq!(
get("Gravado em (metadado)").as_deref(),
Some("2023-07-14T18:22:05Z")
);
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
}
// ------------------------------------------------------------------ text search
#[test]
fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
let mb = 1024 * 1024;
let mut disk = vec![0u8; 20 * mb];
let put = |d: &mut Vec<u8>, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b);
put(&mut disk, 1000, b"... Segredo-123 ...");
let u16s: Vec<u8> = "segredo-123"
.encode_utf16()
.flat_map(|c| c.to_le_bytes())
.collect();
put(&mut disk, 3 * mb + 6, &u16s);
// string placed right across the 8 MiB chunk boundary
put(&mut disk, 8 * mb - 4, b"SEGREDO-123");
let opts = search::SearchOpts {
terms: vec!["segredo-123".into()],
ignore_case: true,
utf16: true,
max_hits: 100,
};
let mut hits = vec![];
let n = search::search(
&MemSource(disk.clone()),
&opts,
&|| false,
&mut |_, _| {},
&mut |h| {
hits.push(h);
true
},
);
assert_eq!(n, 3, "{hits:?}");
assert_eq!(hits[0].offset, 1004);
assert_eq!(hits[0].encoding, "UTF-8");
assert!(hits[0].context.contains("Segredo-123"));
assert_eq!(hits[1].offset, 3 * mb as u64 + 6);
assert_eq!(hits[1].encoding, "UTF-16");
assert_eq!(hits[2].offset, 8 * mb as u64 - 4);
// case-sensitive mode matches only exact casing
let opts = search::SearchOpts {
terms: vec!["Segredo-123".into()],
ignore_case: false,
utf16: false,
max_hits: 100,
};
let n = search::search(
&MemSource(disk),
&opts,
&|| false,
&mut |_, _| {},
&mut |_| true,
);
assert_eq!(n, 1);
}
// ------------------------------------------------------------------ exports
#[test]
fn csv_escapa_e_neutraliza_formulas() {
let mut f = resident("=SOMA(A1).xlsx", vec![1]);
f.note = Some("diz \"oi\"; ok".into());
f.camera = Some("Cam;X".into());
let csv = report::csv(&[&f]);
assert!(csv.starts_with('\u{feff}'));
assert!(csv.contains("\"'=SOMA(A1).xlsx\""), "{csv}");
assert!(csv.contains("\"diz \"\"oi\"\"; ok\""));
assert_eq!(csv.lines().count(), 2);
}
#[test]
fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
let f = resident("<script>alert(1)</script>.txt", vec![1, 2, 3]);
let case = report::CaseInfo {
case_id: "2026-001".into(),
examiner: "Perita <Ana>".into(),
organization: "Lab".into(),
notes: "obs".into(),
};
let opts = ScanOptions::default();
let hashes = hash_bytes(b"disco");
let html = report::html(&report::ReportInput {
case: &case,
target: "/dev/sdb",
options: &opts,
layout: None,
found: &[f],
warnings: &["aviso <b>".to_string()],
started_unix: Some(1_689_358_925),
elapsed: 1.5,
recoveries: &[],
source_hashes: Some(&hashes),
audit: Some(report::AuditNote {
entries: 3,
ok: true,
head: "ab".repeat(32),
}),
});
assert!(!html.contains("<script>alert"), "HTML não escapado");
assert!(html.contains("Trilha de auditoria") && html.contains(&"ab".repeat(32)));
assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;.txt"));
assert!(html.contains("2026-001") && html.contains("Perita &lt;Ana&gt;"));
assert!(html.contains(&hashes.sha256));
assert!(html.contains("somente para leitura"));
}
// ------------------------------------------------------------------ explorer facets & sorting
#[test]
fn facetas_filtros_e_duplicadas() {
let mk = |id: u64,
name: &str,
dir: Option<&str>,
taken: Option<i64>,
cam: Option<&str>,
sig: Option<u64>| {
let mut f = resident(name, vec![0; 10]);
f.id = id;
f.dir = dir.map(String::from);
f.origin = if dir.is_some() {
Origin::Fat
} else {
Origin::Carved
};
f.taken = taken;
f.camera = cam.map(String::from);
f.sig = sig;
f
};
let jul23 = 1_689_358_925; // 2023-07-14
let jan24 = 1_705_000_000; // 2024-01-11
let found = vec![
mk(
1,
"a.jpg",
Some("DCIM/100"),
Some(jul23),
Some("Canon"),
Some(7),
),
mk(
2,
"b.jpg",
Some("DCIM/100"),
Some(jan24),
Some("Canon"),
Some(8),
),
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // exact content duplicate of `a.jpg`
mk(4, "d.jpg", None, None, None, None),
];
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
let f = explorer::facets(&found);
assert_eq!(f["total"], 4);
assert_eq!(f["years"][0]["year"], 2024);
assert_eq!(f["years"][1]["year"], 2023);
assert_eq!(f["years"][1]["count"], 2);
assert_eq!(f["undated"], 1);
assert_eq!(f["carved"], 1);
assert_eq!(f["duplicates"], 1);
assert_eq!(f["small"], 4); // all three files are 10 bytes long
assert_eq!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
assert_eq!(f["cameras"][0]["name"], "Canon");
let dcim = f["folders"]
.as_array()
.unwrap()
.iter()
.find(|d| d["path"] == "DCIM")
.unwrap();
assert_eq!(dcim["count"], 2);
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
assert_eq!(q(r#"{"folder":"DCIM"}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"folder":"@carved"}"#).select(&found).len(), 1);
assert_eq!(q(r#"{"camera":"Canon"}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"undated":true}"#).select(&found).len(), 1);
assert_eq!(q(r#"{"dedupe":true}"#).select(&found).len(), 3);
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
let mut items = q("{}").select(&found);
explorer::sort_items(&mut items, Some("date"));
assert_eq!(items[0].id, 2); // newest first
assert_eq!(items.last().unwrap().id, 4); // undated files sort last
}
// ------------------------------------------------------------------ carving
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut c = (data.len() as u32).to_be_bytes().to_vec();
c.extend(ty);
c.extend(data);
c.extend([0, 0, 0, 0]);
c
}
/// Regression test: ASCII text immediately following a truncated PNG looks like a ~1.9 GB chunk
/// length; the carver must reject the bogus chunk and never emit a multi-GB PNG.
#[test]
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
let mut d = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
d.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
d.extend(png_chunk(b"IDAT", &vec![7u8; 5000]));
let real_end = d.len();
d.extend(b"ualiBase64 texto qualquer que segue no disco, como parte de outro arquivo html ");
d.resize(4 * 1024 * 1024, 0x41);
let src = MemSource(d.clone());
let hit =
signatures::detect(&src, 0, &d[..64], 8 << 30).expect("PNG truncado ainda é reconhecido");
assert_eq!(
hit.len as usize, real_end,
"deve parar no fim do último bloco válido"
);
assert_eq!(hit.confidence, Confidence::Low);
// header-only PNG without any `IDAT` chunk is rejected
let mut d2 = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
d2.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
d2.resize(1 << 20, b'e');
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none());
// complete PNG with `IEND` still carves with `High` confidence and exact byte length
let mut ok = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
ok.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
ok.extend(png_chunk(b"IDAT", &[1; 100]));
ok.extend(png_chunk(b"IEND", &[]));
let n = ok.len();
ok.resize(1 << 20, 0);
let h = signatures::detect(&MemSource(ok.clone()), 0, &ok[..64], 8 << 30).unwrap();
assert_eq!((h.len as usize, h.confidence), (n, Confidence::High));
}
// ------------------------------------------------------------------ open in external system app
/// `open_external` materializes the recovered file into a private temp folder and invokes
/// the platform opener (stubbed here via a fake `xdg-open`/`open` script in `PATH`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn abre_no_player_do_sistema() {
use std::os::unix::fs::PermissionsExt;
let bin = tempfile::tempdir().unwrap();
let marker = bin.path().join("recebido");
let name = if cfg!(target_os = "linux") {
"xdg-open"
} else {
"open"
};
let script = bin.path().join(name);
std::fs::write(
&script,
format!("#!/bin/sh\ncp \"$1\" '{}'\n", marker.display()),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let old = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
let content: Vec<u8> = (0..5000u32).map(|i| (i * 7) as u8).collect();
let mut f = resident("meu video.mp4", content.clone());
f.id = 42;
let path = external::open_found(&MemSource(vec![]), &f).unwrap();
std::env::set_var("PATH", old);
assert!(path
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("42-"));
for _ in 0..100 {
if marker.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(30));
}
assert_eq!(std::fs::read(&marker).unwrap(), content);
assert_eq!(std::fs::read(&path).unwrap(), content);
let _ = std::fs::remove_file(path);
}