oxihuman_core/
pack_verify.rs1use anyhow::{Context, Result};
5use std::path::Path;
6
7use crate::integrity::hash_bytes;
8use crate::manifest::AssetManifest;
9
10pub struct FileRecord {
12 pub relative_path: String,
13 pub sha256: String,
14 pub size_bytes: u64,
15}
16
17pub struct PackVerifyReport {
19 pub total_files: usize,
20 pub ok_files: usize,
21 pub failed_files: Vec<String>,
23 pub missing_files: Vec<String>,
25 pub is_valid: bool,
26}
27
28impl PackVerifyReport {
29 pub fn summary(&self) -> String {
31 if self.is_valid {
32 format!(
33 "Pack OK: {}/{} files verified",
34 self.ok_files, self.total_files
35 )
36 } else {
37 format!(
38 "Pack INVALID: {}/{} ok, {} failed, {} missing",
39 self.ok_files,
40 self.total_files,
41 self.failed_files.len(),
42 self.missing_files.len()
43 )
44 }
45 }
46}
47
48pub fn scan_pack(pack_dir: &Path) -> Result<Vec<FileRecord>> {
50 let mut records = Vec::new();
51 collect_files(pack_dir, pack_dir, &mut records)?;
52 Ok(records)
53}
54
55fn collect_files(root: &Path, current: &Path, records: &mut Vec<FileRecord>) -> Result<()> {
56 for entry in std::fs::read_dir(current)
57 .with_context(|| format!("reading directory {}", current.display()))?
58 {
59 let entry = entry.with_context(|| format!("dir entry in {}", current.display()))?;
60 let path = entry.path();
61
62 if path.is_dir() {
63 collect_files(root, &path, records)?;
64 } else {
65 let data =
66 std::fs::read(&path).with_context(|| format!("reading file {}", path.display()))?;
67 let sha256 = hash_bytes(&data);
68 let size_bytes = data.len() as u64;
69
70 let relative_path = path
72 .strip_prefix(root)
73 .with_context(|| "stripping root prefix")?
74 .to_string_lossy()
75 .replace('\\', "/");
76
77 records.push(FileRecord {
78 relative_path,
79 sha256,
80 size_bytes,
81 });
82 }
83 }
84 Ok(())
85}
86
87pub fn verify_pack(pack_dir: &Path, records: &[FileRecord]) -> PackVerifyReport {
89 let total_files = records.len();
90 let mut ok_files = 0usize;
91 let mut failed_files = Vec::new();
92 let mut missing_files = Vec::new();
93
94 for rec in records {
95 let full_path = pack_dir.join(&rec.relative_path);
96 match std::fs::read(&full_path) {
97 Err(_) => {
98 missing_files.push(rec.relative_path.clone());
99 }
100 Ok(data) => {
101 let actual_hash = hash_bytes(&data);
102 let actual_size = data.len() as u64;
103 if actual_hash == rec.sha256 && actual_size == rec.size_bytes {
104 ok_files += 1;
105 } else {
106 failed_files.push(rec.relative_path.clone());
107 }
108 }
109 }
110 }
111
112 let is_valid = failed_files.is_empty() && missing_files.is_empty();
113
114 PackVerifyReport {
115 total_files,
116 ok_files,
117 failed_files,
118 missing_files,
119 is_valid,
120 }
121}
122
123pub fn verify_manifest_present(pack_dir: &Path) -> Result<()> {
126 let manifest_path = pack_dir.join("oxihuman_assets.toml");
127 if !manifest_path.exists() {
128 anyhow::bail!("manifest not found: {}", manifest_path.display());
129 }
130 AssetManifest::load(&manifest_path)
131 .with_context(|| format!("parsing manifest {}", manifest_path.display()))?;
132 Ok(())
133}
134
135#[cfg(test)]
140mod tests {
141 use super::*;
142 use std::fs;
143 use std::io::Write;
144 use std::path::PathBuf;
145
146 fn tempdir() -> PathBuf {
151 use std::time::{SystemTime, UNIX_EPOCH};
152 let nanos = SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .expect("should succeed")
155 .subsec_nanos();
156 let path = PathBuf::from(format!("/tmp/oxihuman_pack_verify_test_{}", nanos));
157 fs::create_dir_all(&path).expect("should succeed");
158 path
159 }
160
161 fn write_file(path: &Path, content: &[u8]) {
162 let mut f = fs::File::create(path).expect("should succeed");
163 f.write_all(content).expect("should succeed");
164 }
165
166 #[test]
171 fn scan_pack_finds_three_files() {
172 let tmp = tempdir();
173 write_file(&tmp.join("a.bin"), b"hello");
174 write_file(&tmp.join("b.bin"), b"world");
175 write_file(&tmp.join("c.bin"), b"rust");
176
177 let records = scan_pack(&tmp).expect("should succeed");
178 assert_eq!(records.len(), 3);
179 }
180
181 #[test]
182 fn scan_pack_records_correct_sha256() {
183 let tmp = tempdir();
184 let content = b"oxihuman test data";
185 write_file(&tmp.join("data.bin"), content);
186
187 let records = scan_pack(&tmp).expect("should succeed");
188 assert_eq!(records.len(), 1);
189 let expected = hash_bytes(content);
190 assert_eq!(records[0].sha256, expected);
191 }
192
193 #[test]
194 fn scan_pack_records_correct_size() {
195 let tmp = tempdir();
196 let content = b"1234567890"; write_file(&tmp.join("size_test.bin"), content);
198
199 let records = scan_pack(&tmp).expect("should succeed");
200 assert_eq!(records[0].size_bytes, 10);
201 }
202
203 #[test]
208 fn verify_pack_all_ok() {
209 let tmp = tempdir();
210 write_file(&tmp.join("a.bin"), b"alpha");
211 write_file(&tmp.join("b.bin"), b"beta");
212 write_file(&tmp.join("c.bin"), b"gamma");
213
214 let records = scan_pack(&tmp).expect("should succeed");
215 let report = verify_pack(&tmp, &records);
216
217 assert!(report.is_valid);
218 assert_eq!(report.ok_files, 3);
219 assert!(report.failed_files.is_empty());
220 assert!(report.missing_files.is_empty());
221 }
222
223 #[test]
228 fn verify_pack_modified_file_appears_in_failed() {
229 let tmp = tempdir();
230 write_file(&tmp.join("good.bin"), b"good content");
231 write_file(&tmp.join("bad.bin"), b"original");
232
233 let records = scan_pack(&tmp).expect("should succeed");
234
235 write_file(&tmp.join("bad.bin"), b"tampered!");
237
238 let report = verify_pack(&tmp, &records);
239
240 assert!(!report.is_valid);
241 assert_eq!(report.failed_files.len(), 1);
242 assert!(report.failed_files[0].contains("bad.bin"));
243 assert!(report.missing_files.is_empty());
244 }
245
246 #[test]
251 fn verify_pack_missing_file_appears_in_missing() {
252 let tmp = tempdir();
253 write_file(&tmp.join("present.bin"), b"here");
254 write_file(&tmp.join("gone.bin"), b"temporary");
255
256 let records = scan_pack(&tmp).expect("should succeed");
257
258 fs::remove_file(tmp.join("gone.bin")).expect("should succeed");
260
261 let report = verify_pack(&tmp, &records);
262
263 assert!(!report.is_valid);
264 assert_eq!(report.missing_files.len(), 1);
265 assert!(report.missing_files[0].contains("gone.bin"));
266 assert!(report.failed_files.is_empty());
267 }
268
269 #[test]
274 fn verify_pack_empty_records_trivially_valid() {
275 let tmp = tempdir();
276 let report = verify_pack(&tmp, &[]);
277 assert!(report.is_valid);
278 assert_eq!(report.ok_files, 0);
279 assert_eq!(report.total_files, 0);
280 }
281
282 #[test]
287 fn summary_is_non_empty_when_valid() {
288 let tmp = tempdir();
289 write_file(&tmp.join("x.bin"), b"data");
290 let records = scan_pack(&tmp).expect("should succeed");
291 let report = verify_pack(&tmp, &records);
292 assert!(!report.summary().is_empty());
293 assert!(report.summary().contains("OK"));
294 }
295
296 #[test]
297 fn summary_is_non_empty_when_invalid() {
298 let tmp = tempdir();
299 write_file(&tmp.join("x.bin"), b"original");
300 let records = scan_pack(&tmp).expect("should succeed");
301 write_file(&tmp.join("x.bin"), b"changed");
302 let report = verify_pack(&tmp, &records);
303 assert!(!report.summary().is_empty());
304 assert!(report.summary().contains("INVALID"));
305 }
306
307 #[test]
312 fn verify_manifest_missing_returns_err() {
313 let tmp = tempdir();
314 let result = verify_manifest_present(&tmp);
315 assert!(result.is_err());
316 }
317
318 #[test]
319 fn verify_manifest_invalid_toml_returns_err() {
320 let tmp = tempdir();
321 write_file(&tmp.join("oxihuman_assets.toml"), b"not valid toml ][");
322 let result = verify_manifest_present(&tmp);
323 assert!(result.is_err());
324 }
325
326 #[test]
327 fn verify_manifest_valid_toml_returns_ok() {
328 let tmp = tempdir();
329 let toml = r#"
330version = "0.1.0"
331base_mesh_path = "data/3dobjs/base.obj"
332allowed_targets = ["height-up", "height-down"]
333policy_profile = "Standard"
334"#;
335 write_file(&tmp.join("oxihuman_assets.toml"), toml.as_bytes());
336 let result = verify_manifest_present(&tmp);
337 assert!(result.is_ok());
338 }
339}