Skip to main content

oxihuman_export/
pack.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Asset pack builder: scan a targets directory → generate a verified manifest.
5
6use 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/// Entry for a single verified target file.
14#[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/// Statistics for the built pack.
24#[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        // Each delta is (u32 vid + f32 dx + f32 dy + f32 dz) = 16 bytes
39        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
50/// Configuration for the pack builder.
51pub struct PackBuilderConfig {
52    /// Root directory to scan for .target files (recursive).
53    pub targets_dir: PathBuf,
54    /// Policy to apply for filtering.
55    pub policy: Policy,
56    /// Maximum files to process (None = all).
57    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/// Result of building a pack.
71#[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    /// Serialize the manifest to TOML.
80    pub fn to_toml(&self) -> Result<String> {
81        Ok(toml::to_string_pretty(self)?)
82    }
83
84    /// Write the manifest to a file.
85    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    /// Load a manifest from a TOML file.
92    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
98/// Scan a directory for .target files and build a verified pack manifest.
99pub 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// ── Validation ────────────────────────────────────────────────────────────────
175
176/// Result of validating a single entry in a manifest.
177#[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    /// File exists and hash matches.
187    Ok,
188    /// File does not exist.
189    Missing,
190    /// File exists but hash does not match the manifest.
191    HashMismatch { actual: String },
192    /// Target is not allowed by policy (should not be in manifest).
193    PolicyViolation,
194}
195
196/// Full validation report for a manifest.
197#[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
224/// Validate all entries in a `PackManifest` against the filesystem.
225///
226/// For each entry: check file exists, re-compute SHA-256, compare with stored hash.
227pub 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        // All non-explicit targets should be allowed under Standard policy
310        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); // only allowed
341        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        // Write a temp file
426        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        // All files we just hashed should validate correctly
474        assert_eq!(
475            report.hash_mismatches, 0,
476            "freshly built manifest should have no hash mismatches"
477        );
478    }
479}