Skip to main content

oxihuman_core/
pack_verify.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{Context, Result};
5use std::path::Path;
6
7use crate::integrity::hash_bytes;
8use crate::manifest::AssetManifest;
9
10/// A checksum record for a single file inside a pack.
11pub struct FileRecord {
12    pub relative_path: String,
13    pub sha256: String,
14    pub size_bytes: u64,
15}
16
17/// The result of verifying a pack directory against a set of `FileRecord`s.
18pub struct PackVerifyReport {
19    pub total_files: usize,
20    pub ok_files: usize,
21    /// Files that existed but had a wrong hash or size.
22    pub failed_files: Vec<String>,
23    /// Files listed in records but not found on disk.
24    pub missing_files: Vec<String>,
25    pub is_valid: bool,
26}
27
28impl PackVerifyReport {
29    /// Human-readable one-liner summary.
30    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
48/// Scan all files in `pack_dir` recursively and build a `Vec<FileRecord>`.
49pub 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            // Relative path uses forward slashes.
71            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
87/// Verify all files in `pack_dir` against the expected `records`.
88pub 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
123/// Check that `pack_dir` contains `oxihuman_assets.toml` and that it is a
124/// valid `AssetManifest`.
125pub 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// ---------------------------------------------------------------------------
136// Tests
137// ---------------------------------------------------------------------------
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use std::fs;
143    use std::io::Write;
144    use std::path::PathBuf;
145
146    // ------------------------------------------------------------------
147    // Helpers
148    // ------------------------------------------------------------------
149
150    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    // ------------------------------------------------------------------
167    // scan_pack
168    // ------------------------------------------------------------------
169
170    #[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"; // 10 bytes
197        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    // ------------------------------------------------------------------
204    // verify_pack — happy path
205    // ------------------------------------------------------------------
206
207    #[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    // ------------------------------------------------------------------
224    // verify_pack — modified file
225    // ------------------------------------------------------------------
226
227    #[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        // Tamper with the file after scanning.
236        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    // ------------------------------------------------------------------
247    // verify_pack — missing file
248    // ------------------------------------------------------------------
249
250    #[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        // Remove the file after scanning.
259        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    // ------------------------------------------------------------------
270    // verify_pack — empty records
271    // ------------------------------------------------------------------
272
273    #[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    // ------------------------------------------------------------------
283    // summary
284    // ------------------------------------------------------------------
285
286    #[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    // ------------------------------------------------------------------
308    // verify_manifest_present
309    // ------------------------------------------------------------------
310
311    #[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}