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 fn makehuman_data_dir() -> std::path::PathBuf {
286 std::env::var("MAKEHUMAN_DATA_DIR")
287 .map(std::path::PathBuf::from)
288 .unwrap_or_else(|_| std::path::PathBuf::from("/tmp/oxihuman_nonexistent_data"))
289 }
290 fn targets_dir() -> std::path::PathBuf {
291 makehuman_data_dir().join("targets")
292 }
293
294 #[test]
295 fn build_pack_small_sample() {
296 let dir = targets_dir().join("bodyshapes");
297 if !dir.exists() {
298 return;
299 }
300 let config = PackBuilderConfig {
301 targets_dir: dir,
302 policy: Policy::new(PolicyProfile::Standard),
303 max_files: Some(5),
304 };
305 let manifest = build_pack(config).expect("should succeed");
306 assert!(!manifest.entries.is_empty());
307 assert!(manifest.stats.total_files <= 5);
308 assert!(manifest.stats.total_deltas > 0);
309 for e in &manifest.entries {
311 if e.allowed {
312 assert!(!e.sha256.is_empty());
313 assert!(e.delta_count > 0);
314 }
315 }
316 }
317
318 #[test]
319 fn pack_stats_estimated_memory() {
320 let entries = vec![
321 TargetEntry {
322 name: "height".to_string(),
323 path: "height.target".to_string(),
324 sha256: "abc".to_string(),
325 delta_count: 100,
326 allowed: true,
327 },
328 TargetEntry {
329 name: "explicit-content".to_string(),
330 path: "explicit.target".to_string(),
331 sha256: "def".to_string(),
332 delta_count: 50,
333 allowed: false,
334 },
335 ];
336 let stats = PackStats::from_entries(&entries);
337 assert_eq!(stats.total_files, 2);
338 assert_eq!(stats.allowed_files, 1);
339 assert_eq!(stats.blocked_files, 1);
340 assert_eq!(stats.total_deltas, 100); assert_eq!(stats.estimated_memory_bytes, 100 * 16);
342 }
343
344 #[test]
345 fn manifest_to_toml_round_trip() {
346 let manifest = PackManifest {
347 version: "0.1.0".to_string(),
348 entries: vec![],
349 stats: PackStats {
350 total_files: 0,
351 allowed_files: 0,
352 blocked_files: 0,
353 total_deltas: 0,
354 estimated_memory_bytes: 0,
355 },
356 };
357 let toml_str = manifest.to_toml().expect("should succeed");
358 assert!(toml_str.contains("version"));
359 }
360
361 #[test]
362 fn build_pack_writes_manifest() {
363 let dir = targets_dir().join("armslegs");
364 if !dir.exists() {
365 return;
366 }
367 let config = PackBuilderConfig {
368 targets_dir: dir,
369 policy: Policy::new(PolicyProfile::Standard),
370 max_files: Some(3),
371 };
372 let manifest = build_pack(config).expect("should succeed");
373 let out = std::path::PathBuf::from("/tmp/test_pack_manifest.toml");
374 manifest.write_to(&out).expect("should succeed");
375 assert!(out.exists());
376 std::fs::remove_file(&out).ok();
377 }
378
379 #[test]
380 fn validate_empty_manifest_is_valid() {
381 let manifest = PackManifest {
382 version: "0.1.0".to_string(),
383 entries: vec![],
384 stats: PackStats {
385 total_files: 0,
386 allowed_files: 0,
387 blocked_files: 0,
388 total_deltas: 0,
389 estimated_memory_bytes: 0,
390 },
391 };
392 let policy = Policy::new(PolicyProfile::Standard);
393 let report = validate_manifest(&manifest, std::path::Path::new("/tmp"), &policy);
394 assert!(report.is_valid());
395 assert_eq!(report.total, 0);
396 }
397
398 #[test]
399 fn validate_missing_file_detected() {
400 let manifest = PackManifest {
401 version: "0.1.0".to_string(),
402 entries: vec![TargetEntry {
403 name: "height".to_string(),
404 path: "nonexistent.target".to_string(),
405 sha256: "abc123".to_string(),
406 delta_count: 10,
407 allowed: true,
408 }],
409 stats: PackStats {
410 total_files: 1,
411 allowed_files: 1,
412 blocked_files: 0,
413 total_deltas: 10,
414 estimated_memory_bytes: 160,
415 },
416 };
417 let policy = Policy::new(PolicyProfile::Standard);
418 let report = validate_manifest(&manifest, std::path::Path::new("/tmp"), &policy);
419 assert!(!report.is_valid());
420 assert_eq!(report.missing, 1);
421 }
422
423 #[test]
424 fn validate_hash_mismatch_detected() {
425 let dir = std::path::PathBuf::from("/tmp");
427 let filename = "oxihuman_test_target.target";
428 std::fs::write(dir.join(filename), b"# test\n1 0.1 0.2 0.3\n").expect("should succeed");
429
430 let manifest = PackManifest {
431 version: "0.1.0".to_string(),
432 entries: vec![TargetEntry {
433 name: "test".to_string(),
434 path: filename.to_string(),
435 sha256: "wronghash000000000000000000000000000000000000000000000000000000"
436 .to_string(),
437 delta_count: 1,
438 allowed: true,
439 }],
440 stats: PackStats {
441 total_files: 1,
442 allowed_files: 1,
443 blocked_files: 0,
444 total_deltas: 1,
445 estimated_memory_bytes: 16,
446 },
447 };
448 let policy = Policy::new(PolicyProfile::Standard);
449 let report = validate_manifest(&manifest, &dir, &policy);
450 assert!(!report.is_valid());
451 assert_eq!(report.hash_mismatches, 1);
452 assert!(matches!(
453 &report.results[0].status,
454 EntryStatus::HashMismatch { .. }
455 ));
456 std::fs::remove_file(dir.join(filename)).ok();
457 }
458
459 #[test]
460 fn validate_real_pack() {
461 let dir = targets_dir().join("bodyshapes");
462 if !dir.exists() {
463 return;
464 }
465 let config = PackBuilderConfig {
466 targets_dir: dir.clone(),
467 policy: Policy::new(PolicyProfile::Standard),
468 max_files: Some(3),
469 };
470 let manifest = build_pack(config).expect("should succeed");
471 let policy = Policy::new(PolicyProfile::Standard);
472 let report = validate_manifest(&manifest, &dir, &policy);
473 assert_eq!(
475 report.hash_mismatches, 0,
476 "freshly built manifest should have no hash mismatches"
477 );
478 }
479}