1use anyhow::{Context, Result};
7use oxihuman_core::integrity::hash_bytes;
8use oxihuman_core::parser::target::parse_target;
9use oxihuman_core::policy::{Policy, PolicyProfile};
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct TargetEntry {
16 pub name: String,
17 pub path: String,
18 pub sha256: String,
19 pub delta_count: usize,
20 pub allowed: bool,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PackStats {
26 pub total_files: usize,
27 pub allowed_files: usize,
28 pub blocked_files: usize,
29 pub total_deltas: usize,
30 pub estimated_memory_bytes: usize,
31}
32
33impl PackStats {
34 fn from_entries(entries: &[TargetEntry]) -> Self {
35 let allowed: Vec<_> = entries.iter().filter(|e| e.allowed).collect();
36 let blocked = entries.len() - allowed.len();
37 let total_deltas: usize = allowed.iter().map(|e| e.delta_count).sum();
38 let estimated_memory_bytes = total_deltas * 16;
40 PackStats {
41 total_files: entries.len(),
42 allowed_files: allowed.len(),
43 blocked_files: blocked,
44 total_deltas,
45 estimated_memory_bytes,
46 }
47 }
48}
49
50pub struct PackBuilderConfig {
52 pub targets_dir: PathBuf,
54 pub policy: Policy,
56 pub max_files: Option<usize>,
58}
59
60impl PackBuilderConfig {
61 pub fn new(targets_dir: impl Into<PathBuf>) -> Self {
62 PackBuilderConfig {
63 targets_dir: targets_dir.into(),
64 policy: Policy::new(PolicyProfile::Standard),
65 max_files: None,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PackManifest {
73 pub version: String,
74 pub entries: Vec<TargetEntry>,
75 pub stats: PackStats,
76}
77
78impl PackManifest {
79 pub fn to_toml(&self) -> Result<String> {
81 Ok(toml::to_string_pretty(self)?)
82 }
83
84 pub fn write_to(&self, path: &Path) -> Result<()> {
86 let content = self.to_toml()?;
87 std::fs::write(path, content)?;
88 Ok(())
89 }
90
91 pub fn load(path: &Path) -> anyhow::Result<Self> {
93 let content = std::fs::read_to_string(path)?;
94 Ok(toml::from_str(&content)?)
95 }
96}
97
98pub fn build_pack(config: PackBuilderConfig) -> Result<PackManifest> {
100 let mut entries = Vec::new();
101 let max = config.max_files.unwrap_or(usize::MAX);
102
103 scan_dir(
104 &config.targets_dir,
105 &config.targets_dir,
106 &config.policy,
107 &mut entries,
108 max,
109 )
110 .with_context(|| format!("scanning {}", config.targets_dir.display()))?;
111
112 let stats = PackStats::from_entries(&entries);
113 Ok(PackManifest {
114 version: "0.1.0".to_string(),
115 entries,
116 stats,
117 })
118}
119
120fn scan_dir(
121 base: &Path,
122 dir: &Path,
123 policy: &Policy,
124 entries: &mut Vec<TargetEntry>,
125 max: usize,
126) -> Result<()> {
127 if entries.len() >= max {
128 return Ok(());
129 }
130 let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
131 .filter_map(|e| e.ok().map(|e| e.path()))
132 .collect();
133 paths.sort();
134
135 for path in paths {
136 if entries.len() >= max {
137 break;
138 }
139 if path.is_dir() {
140 scan_dir(base, &path, policy, entries, max)?;
141 } else if path.extension().map(|e| e == "target").unwrap_or(false) {
142 if let Some(entry) = process_target(&path, base, policy) {
143 entries.push(entry);
144 }
145 }
146 }
147 Ok(())
148}
149
150fn process_target(path: &Path, base: &Path, policy: &Policy) -> Option<TargetEntry> {
151 let data = std::fs::read(path).ok()?;
152 let src = std::str::from_utf8(&data).ok()?;
153 let name = path.file_stem()?.to_str()?.to_string();
154 let sha256 = hash_bytes(&data);
155
156 let parsed = parse_target(&name, src).ok()?;
157 let delta_count = parsed.deltas.len();
158 let allowed = policy.is_target_allowed(&name, &[]);
159
160 let rel_path = path
161 .strip_prefix(base)
162 .map(|p| p.to_string_lossy().into_owned())
163 .unwrap_or_else(|_| path.to_string_lossy().into_owned());
164
165 Some(TargetEntry {
166 name,
167 path: rel_path,
168 sha256,
169 delta_count,
170 allowed,
171 })
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct EntryValidationResult {
179 pub name: String,
180 pub path: String,
181 pub status: EntryStatus,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub enum EntryStatus {
186 Ok,
188 Missing,
190 HashMismatch { actual: String },
192 PolicyViolation,
194}
195
196#[derive(Debug, Clone)]
198pub struct ValidationReport {
199 pub total: usize,
200 pub ok: usize,
201 pub missing: usize,
202 pub hash_mismatches: usize,
203 pub policy_violations: usize,
204 pub results: Vec<EntryValidationResult>,
205}
206
207impl ValidationReport {
208 pub fn is_valid(&self) -> bool {
209 self.missing == 0 && self.hash_mismatches == 0 && self.policy_violations == 0
210 }
211
212 pub fn summary(&self) -> String {
213 if self.is_valid() {
214 format!("OK: {}/{} entries valid", self.ok, self.total)
215 } else {
216 format!(
217 "INVALID: {} missing, {} hash mismatches, {} policy violations (of {} total)",
218 self.missing, self.hash_mismatches, self.policy_violations, self.total
219 )
220 }
221 }
222}
223
224pub fn validate_manifest(
228 manifest: &PackManifest,
229 base_dir: &Path,
230 policy: &Policy,
231) -> ValidationReport {
232 let mut results = Vec::new();
233 let mut ok = 0;
234 let mut missing = 0;
235 let mut hash_mismatches = 0;
236 let mut policy_violations = 0;
237
238 for entry in &manifest.entries {
239 let full_path = base_dir.join(&entry.path);
240 let status = if !policy.is_target_allowed(&entry.name, &[]) {
241 policy_violations += 1;
242 EntryStatus::PolicyViolation
243 } else if !full_path.exists() {
244 missing += 1;
245 EntryStatus::Missing
246 } else {
247 match std::fs::read(&full_path) {
248 Ok(data) => {
249 let actual = hash_bytes(&data);
250 if actual == entry.sha256 {
251 ok += 1;
252 EntryStatus::Ok
253 } else {
254 hash_mismatches += 1;
255 EntryStatus::HashMismatch { actual }
256 }
257 }
258 Err(_) => {
259 missing += 1;
260 EntryStatus::Missing
261 }
262 }
263 };
264 results.push(EntryValidationResult {
265 name: entry.name.clone(),
266 path: entry.path.clone(),
267 status,
268 });
269 }
270
271 ValidationReport {
272 total: manifest.entries.len(),
273 ok,
274 missing,
275 hash_mismatches,
276 policy_violations,
277 results,
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 const TARGETS_DIR: &str = "/media/kitasan/Backup/resource/makehuman/makehuman/data/targets";
286
287 #[test]
288 fn build_pack_small_sample() {
289 let dir = Path::new(TARGETS_DIR).join("bodyshapes");
290 if !dir.exists() {
291 return;
292 }
293 let config = PackBuilderConfig {
294 targets_dir: dir,
295 policy: Policy::new(PolicyProfile::Standard),
296 max_files: Some(5),
297 };
298 let manifest = build_pack(config).expect("should succeed");
299 assert!(!manifest.entries.is_empty());
300 assert!(manifest.stats.total_files <= 5);
301 assert!(manifest.stats.total_deltas > 0);
302 for e in &manifest.entries {
304 if e.allowed {
305 assert!(!e.sha256.is_empty());
306 assert!(e.delta_count > 0);
307 }
308 }
309 }
310
311 #[test]
312 fn pack_stats_estimated_memory() {
313 let entries = vec![
314 TargetEntry {
315 name: "height".to_string(),
316 path: "height.target".to_string(),
317 sha256: "abc".to_string(),
318 delta_count: 100,
319 allowed: true,
320 },
321 TargetEntry {
322 name: "explicit-content".to_string(),
323 path: "explicit.target".to_string(),
324 sha256: "def".to_string(),
325 delta_count: 50,
326 allowed: false,
327 },
328 ];
329 let stats = PackStats::from_entries(&entries);
330 assert_eq!(stats.total_files, 2);
331 assert_eq!(stats.allowed_files, 1);
332 assert_eq!(stats.blocked_files, 1);
333 assert_eq!(stats.total_deltas, 100); assert_eq!(stats.estimated_memory_bytes, 100 * 16);
335 }
336
337 #[test]
338 fn manifest_to_toml_round_trip() {
339 let manifest = PackManifest {
340 version: "0.1.0".to_string(),
341 entries: vec![],
342 stats: PackStats {
343 total_files: 0,
344 allowed_files: 0,
345 blocked_files: 0,
346 total_deltas: 0,
347 estimated_memory_bytes: 0,
348 },
349 };
350 let toml_str = manifest.to_toml().expect("should succeed");
351 assert!(toml_str.contains("version"));
352 }
353
354 #[test]
355 fn build_pack_writes_manifest() {
356 let dir = Path::new(TARGETS_DIR).join("armslegs");
357 if !dir.exists() {
358 return;
359 }
360 let config = PackBuilderConfig {
361 targets_dir: dir,
362 policy: Policy::new(PolicyProfile::Standard),
363 max_files: Some(3),
364 };
365 let manifest = build_pack(config).expect("should succeed");
366 let out = std::path::PathBuf::from("/tmp/test_pack_manifest.toml");
367 manifest.write_to(&out).expect("should succeed");
368 assert!(out.exists());
369 std::fs::remove_file(&out).ok();
370 }
371
372 #[test]
373 fn validate_empty_manifest_is_valid() {
374 let manifest = PackManifest {
375 version: "0.1.0".to_string(),
376 entries: vec![],
377 stats: PackStats {
378 total_files: 0,
379 allowed_files: 0,
380 blocked_files: 0,
381 total_deltas: 0,
382 estimated_memory_bytes: 0,
383 },
384 };
385 let policy = Policy::new(PolicyProfile::Standard);
386 let report = validate_manifest(&manifest, std::path::Path::new("/tmp"), &policy);
387 assert!(report.is_valid());
388 assert_eq!(report.total, 0);
389 }
390
391 #[test]
392 fn validate_missing_file_detected() {
393 let manifest = PackManifest {
394 version: "0.1.0".to_string(),
395 entries: vec![TargetEntry {
396 name: "height".to_string(),
397 path: "nonexistent.target".to_string(),
398 sha256: "abc123".to_string(),
399 delta_count: 10,
400 allowed: true,
401 }],
402 stats: PackStats {
403 total_files: 1,
404 allowed_files: 1,
405 blocked_files: 0,
406 total_deltas: 10,
407 estimated_memory_bytes: 160,
408 },
409 };
410 let policy = Policy::new(PolicyProfile::Standard);
411 let report = validate_manifest(&manifest, std::path::Path::new("/tmp"), &policy);
412 assert!(!report.is_valid());
413 assert_eq!(report.missing, 1);
414 }
415
416 #[test]
417 fn validate_hash_mismatch_detected() {
418 let dir = std::path::PathBuf::from("/tmp");
420 let filename = "oxihuman_test_target.target";
421 std::fs::write(dir.join(filename), b"# test\n1 0.1 0.2 0.3\n").expect("should succeed");
422
423 let manifest = PackManifest {
424 version: "0.1.0".to_string(),
425 entries: vec![TargetEntry {
426 name: "test".to_string(),
427 path: filename.to_string(),
428 sha256: "wronghash000000000000000000000000000000000000000000000000000000"
429 .to_string(),
430 delta_count: 1,
431 allowed: true,
432 }],
433 stats: PackStats {
434 total_files: 1,
435 allowed_files: 1,
436 blocked_files: 0,
437 total_deltas: 1,
438 estimated_memory_bytes: 16,
439 },
440 };
441 let policy = Policy::new(PolicyProfile::Standard);
442 let report = validate_manifest(&manifest, &dir, &policy);
443 assert!(!report.is_valid());
444 assert_eq!(report.hash_mismatches, 1);
445 assert!(matches!(
446 &report.results[0].status,
447 EntryStatus::HashMismatch { .. }
448 ));
449 std::fs::remove_file(dir.join(filename)).ok();
450 }
451
452 #[test]
453 fn validate_real_pack() {
454 use std::path::Path;
455 let dir =
456 Path::new("/media/kitasan/Backup/resource/makehuman/makehuman/data/targets/bodyshapes");
457 if !dir.exists() {
458 return;
459 }
460 let config = PackBuilderConfig {
461 targets_dir: dir.to_path_buf(),
462 policy: Policy::new(PolicyProfile::Standard),
463 max_files: Some(3),
464 };
465 let manifest = build_pack(config).expect("should succeed");
466 let policy = Policy::new(PolicyProfile::Standard);
467 let report = validate_manifest(&manifest, dir, &policy);
468 assert_eq!(
470 report.hash_mismatches, 0,
471 "freshly built manifest should have no hash mismatches"
472 );
473 }
474}