Skip to main content

oxihuman_core/
pack_distribute.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pack distribution pipeline for OxiHuman asset packages (.oxp).
5//!
6//! Handles packaging, signing, verification, and distribution metadata.
7//!
8//! ## Package format (.oxp)
9//!
10//! - Magic: `b"OXP\x01"` (4 bytes)
11//! - Manifest length: u32 LE
12//! - Manifest JSON: manifest_length bytes
13//! - File count: u32 LE
14//! - For each file: path_len(u16 LE), path(path_len bytes), data_len(u32 LE), data(data_len bytes)
15//! - Trailing: integrity hash (32 bytes SHA-256 of everything before it)
16
17#![allow(dead_code)]
18
19use anyhow::{bail, Context, Result};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use crate::pack_sign::double_hash_sign;
24
25/// Magic bytes identifying an OXP package file.
26const OXP_MAGIC: &[u8; 4] = b"OXP\x01";
27
28/// Size of the trailing SHA-256 integrity hash.
29const INTEGRITY_HASH_LEN: usize = 32;
30
31// ── Public data structures ──────────────────────────────────────────────────
32
33/// Distribution package metadata.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PackManifest {
36    pub name: String,
37    pub version: String,
38    pub author: String,
39    pub description: String,
40    pub license: String,
41    pub created_at: u64,
42    pub targets: Vec<PackTargetEntry>,
43    pub dependencies: Vec<PackDependency>,
44    pub integrity: PackIntegrity,
45}
46
47/// An individual asset file entry within the package.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PackTargetEntry {
50    pub name: String,
51    pub category: String,
52    pub file_path: String,
53    pub size_bytes: usize,
54    pub sha256: String,
55}
56
57/// A dependency on another pack.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct PackDependency {
60    pub name: String,
61    pub version_req: String,
62}
63
64/// Integrity metadata for the package.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PackIntegrity {
67    pub algorithm: String,
68    pub manifest_hash: String,
69    pub signature: Option<String>,
70}
71
72/// An installed pack record.
73#[derive(Debug, Clone)]
74pub struct InstalledPack {
75    pub manifest: PackManifest,
76    pub install_path: String,
77    pub installed_at: u64,
78}
79
80// ── PackBuilder ─────────────────────────────────────────────────────────────
81
82/// Builder for creating `.oxp` distribution packages.
83pub struct PackBuilder {
84    manifest: PackManifest,
85    files: Vec<(String, Vec<u8>)>,
86}
87
88impl PackBuilder {
89    /// Create a new builder with required metadata.
90    pub fn new(name: &str, version: &str, author: &str) -> Self {
91        Self {
92            manifest: PackManifest {
93                name: name.to_string(),
94                version: version.to_string(),
95                author: author.to_string(),
96                description: String::new(),
97                license: String::new(),
98                created_at: 0,
99                targets: Vec::new(),
100                dependencies: Vec::new(),
101                integrity: PackIntegrity {
102                    algorithm: "sha256".to_string(),
103                    manifest_hash: String::new(),
104                    signature: None,
105                },
106            },
107            files: Vec::new(),
108        }
109    }
110
111    /// Set the package description.
112    pub fn set_description(&mut self, desc: &str) {
113        self.manifest.description = desc.to_string();
114    }
115
116    /// Set the package license identifier.
117    pub fn set_license(&mut self, license: &str) {
118        self.manifest.license = license.to_string();
119    }
120
121    /// Set the creation timestamp (unix seconds).
122    pub fn set_created_at(&mut self, ts: u64) {
123        self.manifest.created_at = ts;
124    }
125
126    /// Add a target file to the package.
127    pub fn add_target_file(&mut self, name: &str, category: &str, data: &[u8]) -> Result<()> {
128        if name.is_empty() {
129            bail!("target file name must not be empty");
130        }
131        let sha_hex = sha256_hex(data);
132        let file_path = format!("{}/{}", category, name);
133
134        self.manifest.targets.push(PackTargetEntry {
135            name: name.to_string(),
136            category: category.to_string(),
137            file_path: file_path.clone(),
138            size_bytes: data.len(),
139            sha256: sha_hex,
140        });
141        self.files.push((file_path, data.to_vec()));
142        Ok(())
143    }
144
145    /// Declare a dependency on another pack.
146    pub fn add_dependency(&mut self, name: &str, version_req: &str) {
147        self.manifest.dependencies.push(PackDependency {
148            name: name.to_string(),
149            version_req: version_req.to_string(),
150        });
151    }
152
153    /// Build the `.oxp` package bytes (unsigned).
154    pub fn build(&self) -> Result<Vec<u8>> {
155        self.build_internal(None)
156    }
157
158    /// Build and sign the `.oxp` package with the given key.
159    pub fn build_signed(&self, signing_key: &[u8]) -> Result<Vec<u8>> {
160        self.build_internal(Some(signing_key))
161    }
162
163    /// Internal build routine shared by `build` and `build_signed`.
164    fn build_internal(&self, signing_key: Option<&[u8]>) -> Result<Vec<u8>> {
165        // Compute manifest hash over target entries for integrity
166        let manifest_hash_hex = self.compute_manifest_hash();
167
168        // Optionally compute signature
169        let signature_hex = signing_key.map(|key| {
170            let sig_bytes = double_hash_sign(key, manifest_hash_hex.as_bytes());
171            hex::encode(sig_bytes)
172        });
173
174        // Finalize manifest with integrity info
175        let mut manifest = self.manifest.clone();
176        manifest.integrity = PackIntegrity {
177            algorithm: "sha256".to_string(),
178            manifest_hash: manifest_hash_hex,
179            signature: signature_hex,
180        };
181
182        let manifest_json = serde_json::to_vec(&manifest)
183            .with_context(|| "failed to serialize manifest to JSON")?;
184
185        // Assemble the binary package
186        let mut buf: Vec<u8> = Vec::new();
187
188        // Magic
189        buf.extend_from_slice(OXP_MAGIC);
190
191        // Manifest length + manifest JSON
192        let manifest_len = u32::try_from(manifest_json.len())
193            .with_context(|| "manifest JSON too large for u32 length")?;
194        buf.extend_from_slice(&manifest_len.to_le_bytes());
195        buf.extend_from_slice(&manifest_json);
196
197        // File count
198        let file_count =
199            u32::try_from(self.files.len()).with_context(|| "file count too large for u32")?;
200        buf.extend_from_slice(&file_count.to_le_bytes());
201
202        // Each file: path_len(u16 LE), path, data_len(u32 LE), data
203        for (path, data) in &self.files {
204            let path_bytes = path.as_bytes();
205            let path_len = u16::try_from(path_bytes.len())
206                .with_context(|| format!("file path too long: {}", path))?;
207            buf.extend_from_slice(&path_len.to_le_bytes());
208            buf.extend_from_slice(path_bytes);
209
210            let data_len = u32::try_from(data.len())
211                .with_context(|| format!("file data too large: {}", path))?;
212            buf.extend_from_slice(&data_len.to_le_bytes());
213            buf.extend_from_slice(data);
214        }
215
216        // Trailing integrity hash: SHA-256 of everything written so far
217        let trailing_hash = sha256_bytes(&buf);
218        buf.extend_from_slice(&trailing_hash);
219
220        Ok(buf)
221    }
222
223    /// Compute a deterministic hash over all target entries.
224    fn compute_manifest_hash(&self) -> String {
225        let mut hasher = Sha256::new();
226        // Sort targets by file_path for determinism
227        let mut sorted_targets: Vec<&PackTargetEntry> = self.manifest.targets.iter().collect();
228        sorted_targets.sort_by(|a, b| a.file_path.cmp(&b.file_path));
229        for t in sorted_targets {
230            let line = format!("{}:{}:{}\n", t.file_path, t.size_bytes, t.sha256);
231            hasher.update(line.as_bytes());
232        }
233        hex::encode(hasher.finalize())
234    }
235}
236
237// ── PackVerifier ────────────────────────────────────────────────────────────
238
239/// Verifier for validating `.oxp` distribution packages.
240pub struct PackVerifier;
241
242impl PackVerifier {
243    /// Verify the package trailing integrity hash and return the manifest.
244    pub fn verify_integrity(package_data: &[u8]) -> Result<PackManifest> {
245        let min_size = OXP_MAGIC.len() + 4 + INTEGRITY_HASH_LEN;
246        if package_data.len() < min_size {
247            bail!("package data too small ({} bytes)", package_data.len());
248        }
249
250        // Verify trailing hash
251        let payload_len = package_data.len() - INTEGRITY_HASH_LEN;
252        let payload = &package_data[..payload_len];
253        let stored_hash = &package_data[payload_len..];
254        let computed_hash = sha256_bytes(payload);
255        if stored_hash != computed_hash.as_slice() {
256            bail!("integrity hash mismatch: package data is corrupted or tampered");
257        }
258
259        Self::read_manifest(package_data)
260    }
261
262    /// Verify the package signature using a public/shared key.
263    pub fn verify_signature(package_data: &[u8], public_key: &[u8]) -> Result<bool> {
264        let manifest = Self::verify_integrity(package_data)?;
265
266        let stored_signature = match &manifest.integrity.signature {
267            Some(sig) => sig.clone(),
268            None => bail!("package has no signature to verify"),
269        };
270
271        let expected_sig_bytes =
272            double_hash_sign(public_key, manifest.integrity.manifest_hash.as_bytes());
273        let expected_hex = hex::encode(expected_sig_bytes);
274
275        Ok(stored_signature == expected_hex)
276    }
277
278    /// Extract the manifest from a package without verifying integrity.
279    pub fn read_manifest(package_data: &[u8]) -> Result<PackManifest> {
280        let (manifest, _offset) = parse_manifest(package_data)?;
281        Ok(manifest)
282    }
283
284    /// Extract a specific file from the package by its path.
285    pub fn extract_file(package_data: &[u8], file_path: &str) -> Result<Vec<u8>> {
286        let files = parse_files(package_data)?;
287        for (path, data) in files {
288            if path == file_path {
289                return Ok(data);
290            }
291        }
292        bail!("file not found in package: {}", file_path);
293    }
294
295    /// List all files contained in the package.
296    pub fn list_files(package_data: &[u8]) -> Result<Vec<PackTargetEntry>> {
297        let manifest = Self::read_manifest(package_data)?;
298        Ok(manifest.targets)
299    }
300}
301
302// ── PackRegistry ────────────────────────────────────────────────────────────
303
304/// Registry for tracking installed packages.
305pub struct PackRegistry {
306    packages: Vec<InstalledPack>,
307}
308
309impl Default for PackRegistry {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315impl PackRegistry {
316    /// Create a new empty registry.
317    pub fn new() -> Self {
318        Self {
319            packages: Vec::new(),
320        }
321    }
322
323    /// Register an installed pack.
324    pub fn register(&mut self, manifest: PackManifest, install_path: &str) {
325        let now = std::time::SystemTime::now()
326            .duration_since(std::time::UNIX_EPOCH)
327            .map(|d| d.as_secs())
328            .unwrap_or(0);
329        self.packages.push(InstalledPack {
330            manifest,
331            install_path: install_path.to_string(),
332            installed_at: now,
333        });
334    }
335
336    /// Remove a pack by name. Returns error if not found.
337    pub fn unregister(&mut self, name: &str) -> Result<()> {
338        let idx = self
339            .packages
340            .iter()
341            .position(|p| p.manifest.name == name)
342            .with_context(|| format!("package '{}' not found in registry", name))?;
343        self.packages.remove(idx);
344        Ok(())
345    }
346
347    /// Find an installed pack by name.
348    pub fn find(&self, name: &str) -> Option<&InstalledPack> {
349        self.packages.iter().find(|p| p.manifest.name == name)
350    }
351
352    /// Find all packs that contain targets in the given category.
353    pub fn find_by_category(&self, category: &str) -> Vec<&InstalledPack> {
354        self.packages
355            .iter()
356            .filter(|p| p.manifest.targets.iter().any(|t| t.category == category))
357            .collect()
358    }
359
360    /// List all installed packs.
361    pub fn list_all(&self) -> &[InstalledPack] {
362        &self.packages
363    }
364
365    /// Check which dependencies from the given manifest are missing.
366    /// Returns names of missing dependencies.
367    pub fn check_dependencies(&self, manifest: &PackManifest) -> Vec<String> {
368        manifest
369            .dependencies
370            .iter()
371            .filter(|dep| {
372                !self
373                    .packages
374                    .iter()
375                    .any(|installed| installed.manifest.name == dep.name)
376            })
377            .map(|dep| dep.name.clone())
378            .collect()
379    }
380}
381
382// ── Internal helpers ────────────────────────────────────────────────────────
383
384fn sha256_hex(data: &[u8]) -> String {
385    let mut h = Sha256::new();
386    h.update(data);
387    hex::encode(h.finalize())
388}
389
390fn sha256_bytes(data: &[u8]) -> Vec<u8> {
391    let mut h = Sha256::new();
392    h.update(data);
393    h.finalize().to_vec()
394}
395
396/// Parse and validate the magic bytes, then extract the manifest from the package.
397/// Returns the manifest and the byte offset immediately after the manifest JSON.
398fn parse_manifest(data: &[u8]) -> Result<(PackManifest, usize)> {
399    if data.len() < 8 {
400        bail!("package data too small to contain header");
401    }
402    if &data[..4] != OXP_MAGIC {
403        bail!("invalid OXP magic bytes");
404    }
405
406    let manifest_len = u32::from_le_bytes(
407        data[4..8]
408            .try_into()
409            .with_context(|| "reading manifest length")?,
410    ) as usize;
411
412    let manifest_end = 8 + manifest_len;
413    if data.len() < manifest_end {
414        bail!(
415            "package data truncated: need {} bytes for manifest, have {}",
416            manifest_end,
417            data.len()
418        );
419    }
420
421    let manifest: PackManifest = serde_json::from_slice(&data[8..manifest_end])
422        .with_context(|| "failed to deserialize manifest JSON")?;
423
424    Ok((manifest, manifest_end))
425}
426
427/// Parse all file entries from the package data.
428fn parse_files(data: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
429    let (_manifest, mut offset) = parse_manifest(data)?;
430
431    // Strip trailing hash for bounds checking
432    let payload_end = if data.len() >= INTEGRITY_HASH_LEN {
433        data.len() - INTEGRITY_HASH_LEN
434    } else {
435        data.len()
436    };
437
438    if offset + 4 > payload_end {
439        bail!("package data truncated: cannot read file count");
440    }
441
442    let file_count = u32::from_le_bytes(
443        data[offset..offset + 4]
444            .try_into()
445            .with_context(|| "reading file count")?,
446    ) as usize;
447    offset += 4;
448
449    let mut files = Vec::with_capacity(file_count);
450
451    for i in 0..file_count {
452        // path_len: u16 LE
453        if offset + 2 > payload_end {
454            bail!("truncated at file {} path length", i);
455        }
456        let path_len = u16::from_le_bytes(
457            data[offset..offset + 2]
458                .try_into()
459                .with_context(|| format!("reading path length for file {}", i))?,
460        ) as usize;
461        offset += 2;
462
463        // path bytes
464        if offset + path_len > payload_end {
465            bail!("truncated at file {} path data", i);
466        }
467        let path = std::str::from_utf8(&data[offset..offset + path_len])
468            .with_context(|| format!("file {} path is not valid UTF-8", i))?
469            .to_string();
470        offset += path_len;
471
472        // data_len: u32 LE
473        if offset + 4 > payload_end {
474            bail!("truncated at file {} data length", i);
475        }
476        let data_len = u32::from_le_bytes(
477            data[offset..offset + 4]
478                .try_into()
479                .with_context(|| format!("reading data length for file {}", i))?,
480        ) as usize;
481        offset += 4;
482
483        // data bytes
484        if offset + data_len > payload_end {
485            bail!("truncated at file {} data", i);
486        }
487        let file_data = data[offset..offset + data_len].to_vec();
488        offset += data_len;
489
490        files.push((path, file_data));
491    }
492
493    Ok(files)
494}
495
496// ── Tests ───────────────────────────────────────────────────────────────────
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    fn make_basic_builder() -> PackBuilder {
503        let mut b = PackBuilder::new("test-pack", "1.0.0", "tester");
504        b.set_description("A test package");
505        b.set_license("MIT");
506        b.set_created_at(1700000000);
507        b
508    }
509
510    // 1. Build an empty package (no files)
511    #[test]
512    fn build_empty_package() {
513        let b = make_basic_builder();
514        let data = b.build().expect("should succeed");
515        assert!(data.len() > OXP_MAGIC.len() + INTEGRITY_HASH_LEN);
516        assert_eq!(&data[..4], OXP_MAGIC);
517    }
518
519    // 2. Build with one file and verify integrity
520    #[test]
521    fn build_and_verify_one_file() {
522        let mut b = make_basic_builder();
523        b.add_target_file("model.dat", "meshes", b"triangle-data")
524            .expect("should succeed");
525        let data = b.build().expect("should succeed");
526        let manifest = PackVerifier::verify_integrity(&data).expect("should succeed");
527        assert_eq!(manifest.name, "test-pack");
528        assert_eq!(manifest.targets.len(), 1);
529        assert_eq!(manifest.targets[0].name, "model.dat");
530    }
531
532    // 3. Build with multiple files
533    #[test]
534    fn build_multiple_files() {
535        let mut b = make_basic_builder();
536        b.add_target_file("a.bin", "cat_a", b"alpha")
537            .expect("should succeed");
538        b.add_target_file("b.bin", "cat_b", b"beta")
539            .expect("should succeed");
540        b.add_target_file("c.bin", "cat_a", b"gamma")
541            .expect("should succeed");
542        let data = b.build().expect("should succeed");
543        let manifest = PackVerifier::verify_integrity(&data).expect("should succeed");
544        assert_eq!(manifest.targets.len(), 3);
545    }
546
547    // 4. Integrity check fails on tampered data
548    #[test]
549    fn integrity_fails_on_tampered_data() {
550        let mut b = make_basic_builder();
551        b.add_target_file("x.bin", "cat", b"data")
552            .expect("should succeed");
553        let mut data = b.build().expect("should succeed");
554        // Tamper a byte in the middle
555        let mid = data.len() / 2;
556        data[mid] ^= 0xFF;
557        assert!(PackVerifier::verify_integrity(&data).is_err());
558    }
559
560    // 5. Signed build and verification
561    #[test]
562    fn signed_build_and_verify() {
563        let key = b"my-secret-key";
564        let mut b = make_basic_builder();
565        b.add_target_file("asset.glb", "models", b"glb-content")
566            .expect("should succeed");
567        let data = b.build_signed(key).expect("should succeed");
568        let ok = PackVerifier::verify_signature(&data, key).expect("should succeed");
569        assert!(ok);
570    }
571
572    // 6. Wrong key fails signature verification
573    #[test]
574    fn wrong_key_fails_signature() {
575        let mut b = make_basic_builder();
576        b.add_target_file("f.bin", "cat", b"stuff")
577            .expect("should succeed");
578        let data = b.build_signed(b"correct-key").expect("should succeed");
579        let ok = PackVerifier::verify_signature(&data, b"wrong-key").expect("should succeed");
580        assert!(!ok);
581    }
582
583    // 7. Unsigned package has no signature to verify
584    #[test]
585    fn unsigned_package_signature_check_fails() {
586        let mut b = make_basic_builder();
587        b.add_target_file("f.bin", "cat", b"stuff")
588            .expect("should succeed");
589        let data = b.build().expect("should succeed");
590        assert!(PackVerifier::verify_signature(&data, b"any-key").is_err());
591    }
592
593    // 8. Extract a specific file
594    #[test]
595    fn extract_file_by_path() {
596        let mut b = make_basic_builder();
597        b.add_target_file("mesh.obj", "models", b"obj-content")
598            .expect("should succeed");
599        b.add_target_file("tex.png", "textures", b"png-bytes")
600            .expect("should succeed");
601        let data = b.build().expect("should succeed");
602        let extracted =
603            PackVerifier::extract_file(&data, "textures/tex.png").expect("should succeed");
604        assert_eq!(extracted, b"png-bytes");
605    }
606
607    // 9. Extract non-existent file returns error
608    #[test]
609    fn extract_missing_file() {
610        let b = make_basic_builder();
611        let data = b.build().expect("should succeed");
612        assert!(PackVerifier::extract_file(&data, "no/such/file").is_err());
613    }
614
615    // 10. List files returns target entries
616    #[test]
617    fn list_files_returns_targets() {
618        let mut b = make_basic_builder();
619        b.add_target_file("a.bin", "cat_a", b"aaa")
620            .expect("should succeed");
621        b.add_target_file("b.bin", "cat_b", b"bbb")
622            .expect("should succeed");
623        let data = b.build().expect("should succeed");
624        let files = PackVerifier::list_files(&data).expect("should succeed");
625        assert_eq!(files.len(), 2);
626    }
627
628    // 11. Read manifest extracts metadata correctly
629    #[test]
630    fn read_manifest_metadata() {
631        let mut b = make_basic_builder();
632        b.add_dependency("base-pack", ">=1.0");
633        let data = b.build().expect("should succeed");
634        let manifest = PackVerifier::read_manifest(&data).expect("should succeed");
635        assert_eq!(manifest.version, "1.0.0");
636        assert_eq!(manifest.author, "tester");
637        assert_eq!(manifest.license, "MIT");
638        assert_eq!(manifest.dependencies.len(), 1);
639        assert_eq!(manifest.dependencies[0].name, "base-pack");
640    }
641
642    // 12. PackRegistry basic operations
643    #[test]
644    fn registry_register_find_unregister() {
645        let mut reg = PackRegistry::new();
646        let manifest = PackManifest {
647            name: "my-pack".to_string(),
648            version: "0.1.0".to_string(),
649            author: "author".to_string(),
650            description: String::new(),
651            license: "MIT".to_string(),
652            created_at: 0,
653            targets: vec![PackTargetEntry {
654                name: "f.bin".to_string(),
655                category: "meshes".to_string(),
656                file_path: "meshes/f.bin".to_string(),
657                size_bytes: 100,
658                sha256: "abc123".to_string(),
659            }],
660            dependencies: Vec::new(),
661            integrity: PackIntegrity {
662                algorithm: "sha256".to_string(),
663                manifest_hash: String::new(),
664                signature: None,
665            },
666        };
667        reg.register(manifest, "/tmp/my-pack");
668        assert!(reg.find("my-pack").is_some());
669        assert!(reg.find("nonexistent").is_none());
670        assert_eq!(reg.list_all().len(), 1);
671        reg.unregister("my-pack").expect("should succeed");
672        assert!(reg.find("my-pack").is_none());
673        assert_eq!(reg.list_all().len(), 0);
674    }
675
676    // 13. PackRegistry find_by_category
677    #[test]
678    fn registry_find_by_category() {
679        let mut reg = PackRegistry::new();
680        let make_manifest = |name: &str, cat: &str| PackManifest {
681            name: name.to_string(),
682            version: "1.0.0".to_string(),
683            author: "a".to_string(),
684            description: String::new(),
685            license: String::new(),
686            created_at: 0,
687            targets: vec![PackTargetEntry {
688                name: "f".to_string(),
689                category: cat.to_string(),
690                file_path: format!("{}/f", cat),
691                size_bytes: 0,
692                sha256: String::new(),
693            }],
694            dependencies: Vec::new(),
695            integrity: PackIntegrity {
696                algorithm: "sha256".to_string(),
697                manifest_hash: String::new(),
698                signature: None,
699            },
700        };
701
702        reg.register(make_manifest("pack-a", "meshes"), "/a");
703        reg.register(make_manifest("pack-b", "textures"), "/b");
704        reg.register(make_manifest("pack-c", "meshes"), "/c");
705
706        let meshes = reg.find_by_category("meshes");
707        assert_eq!(meshes.len(), 2);
708        let textures = reg.find_by_category("textures");
709        assert_eq!(textures.len(), 1);
710        let empty = reg.find_by_category("audio");
711        assert!(empty.is_empty());
712    }
713
714    // 14. PackRegistry check_dependencies
715    #[test]
716    fn registry_check_dependencies() {
717        let mut reg = PackRegistry::new();
718        let base_manifest = PackManifest {
719            name: "base-pack".to_string(),
720            version: "1.0.0".to_string(),
721            author: "a".to_string(),
722            description: String::new(),
723            license: String::new(),
724            created_at: 0,
725            targets: Vec::new(),
726            dependencies: Vec::new(),
727            integrity: PackIntegrity {
728                algorithm: "sha256".to_string(),
729                manifest_hash: String::new(),
730                signature: None,
731            },
732        };
733        reg.register(base_manifest, "/base");
734
735        let dependent = PackManifest {
736            name: "top-pack".to_string(),
737            version: "1.0.0".to_string(),
738            author: "a".to_string(),
739            description: String::new(),
740            license: String::new(),
741            created_at: 0,
742            targets: Vec::new(),
743            dependencies: vec![
744                PackDependency {
745                    name: "base-pack".to_string(),
746                    version_req: ">=1.0".to_string(),
747                },
748                PackDependency {
749                    name: "missing-pack".to_string(),
750                    version_req: ">=0.5".to_string(),
751                },
752            ],
753            integrity: PackIntegrity {
754                algorithm: "sha256".to_string(),
755                manifest_hash: String::new(),
756                signature: None,
757            },
758        };
759
760        let missing = reg.check_dependencies(&dependent);
761        assert_eq!(missing, vec!["missing-pack"]);
762    }
763
764    // 15. Empty name target file is rejected
765    #[test]
766    fn reject_empty_target_name() {
767        let mut b = make_basic_builder();
768        assert!(b.add_target_file("", "cat", b"data").is_err());
769    }
770
771    // 16. Package too small for header
772    #[test]
773    fn too_small_package_rejected() {
774        assert!(PackVerifier::verify_integrity(b"OXP").is_err());
775    }
776
777    // 17. Wrong magic rejected
778    #[test]
779    fn wrong_magic_rejected() {
780        let b = make_basic_builder();
781        let mut data = b.build().expect("should succeed");
782        data[0] = b'Z';
783        // This will fail at integrity or magic check
784        assert!(PackVerifier::read_manifest(&data).is_err());
785    }
786
787    // 18. Large file round-trip
788    #[test]
789    fn large_file_round_trip() {
790        let mut b = make_basic_builder();
791        let large_data = vec![0xABu8; 100_000];
792        b.add_target_file("big.bin", "data", &large_data)
793            .expect("should succeed");
794        let pkg = b.build().expect("should succeed");
795        let extracted = PackVerifier::extract_file(&pkg, "data/big.bin").expect("should succeed");
796        assert_eq!(extracted.len(), 100_000);
797        assert!(extracted.iter().all(|&b| b == 0xAB));
798    }
799
800    // 19. Manifest hash is deterministic
801    #[test]
802    fn manifest_hash_deterministic() {
803        let mut b1 = make_basic_builder();
804        b1.add_target_file("a.bin", "cat", b"data")
805            .expect("should succeed");
806        let hash1 = b1.compute_manifest_hash();
807
808        let mut b2 = make_basic_builder();
809        b2.add_target_file("a.bin", "cat", b"data")
810            .expect("should succeed");
811        let hash2 = b2.compute_manifest_hash();
812
813        assert_eq!(hash1, hash2);
814    }
815
816    // 20. Unregister non-existent pack fails
817    #[test]
818    fn unregister_missing_pack_fails() {
819        let mut reg = PackRegistry::new();
820        assert!(reg.unregister("ghost").is_err());
821    }
822
823    // 21. SHA-256 in target entries is correct
824    #[test]
825    fn target_entry_sha256_matches() {
826        let mut b = make_basic_builder();
827        let file_data = b"hello oxihuman";
828        b.add_target_file("hello.txt", "text", file_data)
829            .expect("should succeed");
830        let pkg = b.build().expect("should succeed");
831        let manifest = PackVerifier::read_manifest(&pkg).expect("should succeed");
832        let expected = sha256_hex(file_data);
833        assert_eq!(manifest.targets[0].sha256, expected);
834    }
835
836    // 22. Default registry is empty
837    #[test]
838    fn default_registry_is_empty() {
839        let reg = PackRegistry::default();
840        assert!(reg.list_all().is_empty());
841    }
842
843    // 23. Integrity field populated in built manifest
844    #[test]
845    fn integrity_field_populated() {
846        let mut b = make_basic_builder();
847        b.add_target_file("f.bin", "cat", b"d")
848            .expect("should succeed");
849        let pkg = b.build().expect("should succeed");
850        let manifest = PackVerifier::read_manifest(&pkg).expect("should succeed");
851        assert_eq!(manifest.integrity.algorithm, "sha256");
852        assert!(!manifest.integrity.manifest_hash.is_empty());
853        assert!(manifest.integrity.signature.is_none());
854    }
855
856    // 24. Signed manifest has signature field
857    #[test]
858    fn signed_manifest_has_signature() {
859        let mut b = make_basic_builder();
860        b.add_target_file("f.bin", "cat", b"d")
861            .expect("should succeed");
862        let pkg = b.build_signed(b"key").expect("should succeed");
863        let manifest = PackVerifier::read_manifest(&pkg).expect("should succeed");
864        assert!(manifest.integrity.signature.is_some());
865    }
866
867    // 25. Multiple dependencies tracked
868    #[test]
869    fn multiple_dependencies() {
870        let mut b = make_basic_builder();
871        b.add_dependency("dep-a", ">=1.0");
872        b.add_dependency("dep-b", ">=2.0");
873        b.add_dependency("dep-c", ">=0.1");
874        let pkg = b.build().expect("should succeed");
875        let manifest = PackVerifier::read_manifest(&pkg).expect("should succeed");
876        assert_eq!(manifest.dependencies.len(), 3);
877    }
878}