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    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        // All non-explicit targets should be allowed under Standard policy
303        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); // only allowed
334        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        // Write a temp file
419        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        // All files we just hashed should validate correctly
469        assert_eq!(
470            report.hash_mismatches, 0,
471            "freshly built manifest should have no hash mismatches"
472        );
473    }
474}