Skip to main content

phoxal_bundle/
asset.rs

1//! Participant-facing asset capability.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use phoxal_model::AssetId;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    ASSETS_DIR, BundleError, BundlePath, BundleRoot, DocumentError, Sha256Digest, open_bundle_file,
10    read_and_verify,
11};
12
13/// The integrity index for every participant-readable asset.
14#[derive(phoxal_macros::DescribeWire, Clone, Debug, Default, Deserialize, Serialize)]
15#[serde(deny_unknown_fields)]
16pub struct AssetIndex {
17    pub(crate) entries: Vec<AssetRecord>,
18}
19
20impl AssetIndex {
21    /// Build an index for compiled logical assets. The writer places each
22    /// logical id below `assets/` and records its byte length and digest.
23    pub fn from_bytes(assets: &BTreeMap<AssetId, Vec<u8>>) -> Result<Self, DocumentError> {
24        let entries = assets
25            .iter()
26            .map(|(id, bytes)| {
27                Ok(AssetRecord {
28                    id: id.clone(),
29                    path: BundlePath::new(format!("{ASSETS_DIR}/{}", id.as_str()))?,
30                    size_bytes: bytes.len() as u64,
31                    digest: Sha256Digest::of(bytes),
32                })
33            })
34            .collect::<Result<Vec<_>, DocumentError>>()?;
35        let index = Self { entries };
36        index.validate()?;
37        Ok(index)
38    }
39
40    /// Every indexed participant-readable asset, in deterministic order.
41    #[must_use]
42    pub fn entries(&self) -> &[AssetRecord] {
43        &self.entries
44    }
45
46    pub(crate) fn validate(&self) -> Result<(), DocumentError> {
47        let mut ids = BTreeSet::new();
48        let mut paths = BTreeSet::new();
49        for entry in &self.entries {
50            if !entry.path.starts_with_directory(ASSETS_DIR) {
51                return Err(DocumentError::AssetOutsideAssets {
52                    path: entry.path.clone(),
53                });
54            }
55            let expected = format!("{ASSETS_DIR}/{}", entry.id.as_str());
56            if entry.path.as_str() != expected {
57                return Err(DocumentError::AssetPathMismatch {
58                    id: entry.id.clone(),
59                    path: entry.path.clone(),
60                });
61            }
62            if !ids.insert(entry.id.clone()) {
63                return Err(DocumentError::DuplicateAssetId {
64                    id: entry.id.clone(),
65                });
66            }
67            if !paths.insert(entry.path.clone()) {
68                return Err(DocumentError::DuplicateAssetPath {
69                    path: entry.path.clone(),
70                });
71            }
72        }
73        Ok(())
74    }
75}
76
77/// One indexed asset and its expected bytes.
78#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, PartialEq, Serialize)]
79#[serde(deny_unknown_fields)]
80pub struct AssetRecord {
81    pub(crate) id: AssetId,
82    pub(crate) path: BundlePath,
83    pub(crate) size_bytes: u64,
84    pub(crate) digest: Sha256Digest,
85}
86
87impl AssetRecord {
88    #[must_use]
89    pub const fn id(&self) -> &AssetId {
90        &self.id
91    }
92
93    #[must_use]
94    pub const fn path(&self) -> &BundlePath {
95        &self.path
96    }
97
98    #[must_use]
99    pub const fn size_bytes(&self) -> u64 {
100        self.size_bytes
101    }
102
103    #[must_use]
104    pub const fn digest(&self) -> Sha256Digest {
105        self.digest
106    }
107}
108
109/// Participant-readable, digest-checked asset access.
110#[derive(Clone, Debug)]
111pub struct ParticipantAssets {
112    root: BundleRoot,
113    entries: BTreeMap<AssetId, AssetRecord>,
114}
115
116impl ParticipantAssets {
117    pub(crate) fn new(root: BundleRoot, index: &AssetIndex) -> Self {
118        Self {
119            root,
120            entries: index
121                .entries
122                .iter()
123                .map(|entry| (entry.id.clone(), entry.clone()))
124                .collect(),
125        }
126    }
127
128    pub(crate) fn relocate(&mut self, path: std::path::PathBuf) {
129        self.root.relocate(path);
130    }
131
132    /// Every logical asset declared by this runtime bundle.
133    pub fn ids(&self) -> impl ExactSizeIterator<Item = &AssetId> {
134        self.entries.keys()
135    }
136
137    /// Read a declared asset and verify the bytes consumed against the size
138    /// and digest the index recorded.
139    pub fn read(&self, id: &AssetId) -> Result<Vec<u8>, BundleError> {
140        let entry = self
141            .entries
142            .get(id)
143            .ok_or_else(|| BundleError::UndeclaredAsset { id: id.clone() })?;
144        let path = entry.path.filesystem_path(self.root.path());
145        let mut file = open_bundle_file(&self.root, &entry.path)?;
146        read_and_verify(&mut file, &path, entry.digest, Some(entry.size_bytes))
147    }
148}