Skip to main content

rig_ecs/agent/content/
binary.rs

1//! Binary payloads shared by content hash, with source spelling retained per use.
2//!
3//! ```
4//! use rig_ecs::agent::content::binary::BinaryAssets;
5//! let mut assets = BinaryAssets::default();
6//! let id = assets.insert(vec![1, 2, 3])?;
7//! assert_eq!(assets.get(id)?, &[1, 2, 3]);
8//! # Ok::<(), rig_ecs::agent::content::binary::BinaryError>(())
9//! ```
10
11use bevy_reflect::Reflect;
12use std::collections::{BTreeMap, BTreeSet};
13
14use base64::{
15    Engine, alphabet,
16    engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
17    prelude::BASE64_STANDARD,
18};
19use bevy_ecs::prelude::*;
20use rig_core::message::DocumentSourceKind;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24/// SHA-256 of the decoded payload bytes, independent of source and media type.
25#[derive(
26    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Reflect,
27)]
28#[serde(try_from = "String", into = "String")]
29pub struct BinaryId(pub [u8; 32]);
30
31impl BinaryId {
32    /// Compute the content identity without interpreting its media type.
33    pub fn of(bytes: &[u8]) -> Self {
34        Self(Sha256::digest(bytes).into())
35    }
36}
37
38/// Limits checked before decoding or retaining an additional binary payload.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct BinaryLimits {
41    /// Maximum decoded bytes in one asset.
42    pub per_asset: usize,
43    /// Maximum retained decoded bytes in the store.
44    pub total: usize,
45    /// Maximum distinct assets.
46    pub count: usize,
47}
48
49impl Default for BinaryLimits {
50    fn default() -> Self {
51        Self {
52            per_asset: 64 * 1024 * 1024,
53            total: 256 * 1024 * 1024,
54            count: 65_536,
55        }
56    }
57}
58
59/// Why a binary source or persisted asset cannot be used. Diagnostics contain no payload.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
61pub enum BinaryError {
62    /// A source exceeds a configured allocation bound.
63    #[error("binary content exceeds its allocation limit")]
64    Limit,
65    /// The source is not standard-alphabet base64.
66    #[error("invalid base64 binary source")]
67    Base64,
68    /// A handle has no payload in the store.
69    #[error("unresolved binary asset")]
70    Missing,
71    /// Persisted bytes do not match their claimed SHA-256 identity.
72    #[error("binary asset hash mismatch")]
73    Corrupt,
74    /// Persisted spelling metadata cannot describe these bytes.
75    #[error("invalid base64 spelling metadata")]
76    Spelling,
77}
78
79/// Representation at a particular use of a shared binary payload.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Reflect)]
81pub enum BinaryEncoding {
82    /// A raw byte vector in the transport DTO.
83    Raw,
84    /// Standard-alphabet base64. Preserve omitted padding and nonzero trailing
85    /// pad bits without retaining a second copy of the entire encoded payload.
86    Base64 {
87        /// The original number of `=` characters (0, 1 or 2).
88        padding: u8,
89        /// The original last non-padding symbol, when it differs from canonical.
90        last_symbol: Option<u8>,
91    },
92}
93
94/// A part's source. URLs, file IDs, string data and unknown sources remain data;
95/// only raw and base64 binary payloads refer to the store.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)]
97pub enum PartSource {
98    /// External URL or URI; never fetched by scene loading.
99    Url(String),
100    /// Provider file identifier.
101    FileId(String),
102    /// Literal string source, not interpreted as binary.
103    String(String),
104    /// Explicit unknown source.
105    Unknown,
106    /// Shared binary bytes and this occurrence's transport representation.
107    Binary {
108        id: BinaryId,
109        encoding: BinaryEncoding,
110    },
111}
112
113/// One world's binary payload store. Explicit collection keeps only caller-supplied
114/// reachable handles; graph ownership, not a run's lifetime, determines reachability.
115/// Host pins must be included along with graph references when collecting.
116#[derive(Resource, Debug, Default)]
117pub struct BinaryAssets {
118    payloads: BTreeMap<BinaryId, Vec<u8>>,
119    // Hash the encoded spelling to avoid decoding repeated sources. This index
120    // retains only hashes, never a duplicate base64 payload, and is not persisted.
121    spellings: BTreeMap<[u8; 32], BinaryId>,
122    bytes: usize,
123    limits: BinaryLimits,
124}
125
126impl BinaryAssets {
127    /// Empty store with host-selected allocation limits.
128    pub fn with_limits(limits: BinaryLimits) -> Self {
129        Self {
130            limits,
131            ..Self::default()
132        }
133    }
134
135    /// Retained decoded bytes, excluding map and index overhead.
136    pub fn byte_len(&self) -> usize {
137        self.bytes
138    }
139
140    /// Distinct retained payloads.
141    pub fn len(&self) -> usize {
142        self.payloads.len()
143    }
144
145    /// Whether no payload is retained.
146    pub fn is_empty(&self) -> bool {
147        self.payloads.is_empty()
148    }
149
150    /// Borrow an asset's bytes, or return [`BinaryError::Missing`].
151    pub fn get(&self, id: BinaryId) -> Result<&[u8], BinaryError> {
152        self.payloads
153            .get(&id)
154            .map(Vec::as_slice)
155            .ok_or(BinaryError::Missing)
156    }
157
158    /// Retain bytes once, deduplicated by SHA-256. Return a limit error when
159    /// allocation bounds are exceeded or a corruption error on a hash collision.
160    pub fn insert(&mut self, bytes: Vec<u8>) -> Result<BinaryId, BinaryError> {
161        if bytes.len() > self.limits.per_asset {
162            return Err(BinaryError::Limit);
163        }
164        let id = BinaryId::of(&bytes);
165        if let Some(existing) = self.payloads.get(&id) {
166            return if *existing == bytes {
167                Ok(id)
168            } else {
169                Err(BinaryError::Corrupt)
170            };
171        }
172        let total = self
173            .bytes
174            .checked_add(bytes.len())
175            .ok_or(BinaryError::Limit)?;
176        if total > self.limits.total || self.payloads.len() >= self.limits.count {
177            return Err(BinaryError::Limit);
178        }
179        self.bytes = total;
180        self.payloads.insert(id, bytes);
181        Ok(id)
182    }
183
184    /// Convert a transport source to graph data. Repeated encoded strings are
185    /// decoded once while their cached spelling and asset remain retained.
186    /// Returns errors for invalid base64, allocation limits, or inconsistent assets.
187    pub fn intern(&mut self, source: DocumentSourceKind) -> Result<PartSource, BinaryError> {
188        Ok(match source {
189            DocumentSourceKind::Url(value) => PartSource::Url(value),
190            DocumentSourceKind::FileId(value) => PartSource::FileId(value),
191            DocumentSourceKind::String(value) => PartSource::String(value),
192            DocumentSourceKind::Unknown => PartSource::Unknown,
193            DocumentSourceKind::Raw(bytes) => PartSource::Binary {
194                id: self.insert(bytes)?,
195                encoding: BinaryEncoding::Raw,
196            },
197            DocumentSourceKind::Base64(value) => {
198                let max_encoded = self
199                    .limits
200                    .per_asset
201                    .checked_add(2)
202                    .and_then(|n| n.checked_div(3))
203                    .and_then(|n| n.checked_mul(4))
204                    .ok_or(BinaryError::Limit)?;
205                if value.len() > max_encoded {
206                    return Err(BinaryError::Limit);
207                }
208                let key: [u8; 32] = Sha256::digest(value.as_bytes()).into();
209                let id = if let Some(id) = self.spellings.get(&key) {
210                    *id
211                } else {
212                    let decoder = GeneralPurpose::new(
213                        &alphabet::STANDARD,
214                        GeneralPurposeConfig::new()
215                            .with_decode_padding_mode(DecodePaddingMode::Indifferent)
216                            .with_decode_allow_trailing_bits(true),
217                    );
218                    let bytes = decoder.decode(&value).map_err(|_| BinaryError::Base64)?;
219                    let id = self.insert(bytes)?;
220                    // Bound alternative spellings as well as assets. Discarding an
221                    // index entry costs a future decode, never changes semantics.
222                    if self.spellings.len() < self.limits.count {
223                        self.spellings.insert(key, id);
224                    }
225                    id
226                };
227                let canonical = BASE64_STANDARD.encode(self.get(id)?);
228                let bare = value.trim_end_matches('=');
229                let padding =
230                    u8::try_from(value.len() - bare.len()).map_err(|_| BinaryError::Base64)?;
231                let last_symbol = (bare.as_bytes().last()
232                    != canonical.trim_end_matches('=').as_bytes().last())
233                .then(|| bare.as_bytes().last().copied())
234                .flatten();
235                PartSource::Binary {
236                    id,
237                    encoding: BinaryEncoding::Base64 {
238                        padding,
239                        last_symbol,
240                    },
241                }
242            }
243        })
244    }
245
246    /// Validate a reference and calculate its transport size without allocating.
247    pub(crate) fn resolved_len(&self, source: &PartSource) -> Result<usize, BinaryError> {
248        let PartSource::Binary { id, encoding } = source else {
249            return Ok(match source {
250                PartSource::Url(value) | PartSource::FileId(value) | PartSource::String(value) => {
251                    value.len()
252                }
253                _ => 0,
254            });
255        };
256        let bytes = self.get(*id)?;
257        let BinaryEncoding::Base64 {
258            padding,
259            last_symbol,
260        } = encoding
261        else {
262            return Ok(bytes.len());
263        };
264        let remainder = bytes.len() % 3;
265        let required_padding = (3 - remainder) % 3;
266        if usize::from(*padding) > required_padding {
267            return Err(BinaryError::Spelling);
268        }
269        if let Some(symbol) = last_symbol {
270            let last = bytes.last().ok_or(BinaryError::Spelling)?;
271            let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
272            let actual = alphabet
273                .iter()
274                .position(|value| value == symbol)
275                .ok_or(BinaryError::Spelling)?;
276            let (canonical, unused) = match remainder {
277                1 => (usize::from(last & 3) << 4, 4),
278                2 => (usize::from(last & 15) << 2, 2),
279                _ => (usize::from(last & 63), 0),
280            };
281            if actual >> unused != canonical >> unused {
282                return Err(BinaryError::Spelling);
283            }
284        }
285        (bytes.len() / 3)
286            .checked_mul(4)
287            .and_then(|size| size.checked_add(if remainder == 0 { 0 } else { remainder + 1 }))
288            .and_then(|size| size.checked_add(usize::from(*padding)))
289            .ok_or(BinaryError::Limit)
290    }
291
292    /// Rebuild the transport source with its saved spelling, returning an error
293    /// for missing bytes, invalid spelling metadata, or size overflow.
294    pub fn resolve(&self, source: &PartSource) -> Result<DocumentSourceKind, BinaryError> {
295        self.resolved_len(source)?;
296        Ok(match source {
297            PartSource::Url(value) => DocumentSourceKind::Url(value.clone()),
298            PartSource::FileId(value) => DocumentSourceKind::FileId(value.clone()),
299            PartSource::String(value) => DocumentSourceKind::String(value.clone()),
300            PartSource::Unknown => DocumentSourceKind::Unknown,
301            PartSource::Binary { id, encoding } => {
302                let bytes = self.get(*id)?;
303                match encoding {
304                    BinaryEncoding::Raw => DocumentSourceKind::Raw(bytes.to_vec()),
305                    BinaryEncoding::Base64 {
306                        padding,
307                        last_symbol,
308                    } => {
309                        let canonical = BASE64_STANDARD.encode(bytes);
310                        let required_padding =
311                            canonical.len() - canonical.trim_end_matches('=').len();
312                        if usize::from(*padding) > required_padding {
313                            return Err(BinaryError::Spelling);
314                        }
315                        let mut value = canonical.trim_end_matches('=').as_bytes().to_vec();
316                        if let Some(symbol) = last_symbol {
317                            let last = value.last_mut().ok_or(BinaryError::Spelling)?;
318                            // Only unused trailing bits may differ. Compare alphabet
319                            // indices, not ASCII code points.
320                            let alphabet =
321                                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
322                            let before = alphabet
323                                .iter()
324                                .position(|b| b == last)
325                                .ok_or(BinaryError::Spelling)?;
326                            let after = alphabet
327                                .iter()
328                                .position(|b| b == symbol)
329                                .ok_or(BinaryError::Spelling)?;
330                            let unused = match bytes.len() % 3 {
331                                1 => 4,
332                                2 => 2,
333                                _ => 0,
334                            };
335                            if (before >> unused) != (after >> unused) {
336                                return Err(BinaryError::Spelling);
337                            }
338                            *last = *symbol;
339                        }
340                        value.extend(std::iter::repeat_n(b'=', usize::from(*padding)));
341                        DocumentSourceKind::Base64(
342                            String::from_utf8(value).map_err(|_| BinaryError::Spelling)?,
343                        )
344                    }
345                }
346            }
347        })
348    }
349
350    /// Drop assets unreachable from all graph references and explicit host pins.
351    /// Missing roots are an error and leave the store unchanged.
352    pub fn retain(&mut self, roots: impl IntoIterator<Item = BinaryId>) -> Result<(), BinaryError> {
353        let roots: BTreeSet<_> = roots.into_iter().collect();
354        if roots.iter().any(|id| !self.payloads.contains_key(id)) {
355            return Err(BinaryError::Missing);
356        }
357        self.payloads.retain(|id, _| roots.contains(id));
358        self.spellings.retain(|_, id| roots.contains(id));
359        self.bytes = self.payloads.values().map(Vec::len).sum();
360        Ok(())
361    }
362
363    /// Assets in deterministic content-hash order for scene construction.
364    pub fn iter(&self) -> impl Iterator<Item = (BinaryId, &[u8])> {
365        self.payloads
366            .iter()
367            .map(|(id, bytes)| (*id, bytes.as_slice()))
368    }
369
370    /// Validate all saved identities and limits in an isolated store. The caller
371    /// installs it only after graph handles and the rest of the scene validate.
372    /// Returns an error for incorrect hashes, duplicate IDs, or exceeded limits.
373    pub fn from_payloads(
374        payloads: impl IntoIterator<Item = (BinaryId, Vec<u8>)>,
375        limits: BinaryLimits,
376    ) -> Result<Self, BinaryError> {
377        let mut assets = Self::with_limits(limits);
378        for (id, bytes) in payloads {
379            if bytes.len() > limits.per_asset {
380                return Err(BinaryError::Limit);
381            }
382            if BinaryId::of(&bytes) != id {
383                return Err(BinaryError::Corrupt);
384            }
385            if assets.payloads.contains_key(&id) {
386                return Err(BinaryError::Corrupt);
387            }
388            assets.insert(bytes)?;
389        }
390        Ok(assets)
391    }
392}
393
394/// A single scene payload. Canonical base64 stores the bytes once; graph uses
395/// retain their own raw/base64 representation in BinaryEncoding.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct BinaryRecord {
398    /// SHA-256 of decoded bytes.
399    pub id: BinaryId,
400    /// Canonical standard-alphabet base64.
401    pub data: String,
402}
403
404impl BinaryAssets {
405    /// Snapshot the retained store in content-hash order.
406    pub fn snapshot(&self) -> Vec<BinaryRecord> {
407        self.iter()
408            .map(|(id, bytes)| BinaryRecord {
409                id,
410                data: BASE64_STANDARD.encode(bytes),
411            })
412            .collect()
413    }
414
415    /// Build an isolated merged store using the destination's limits. Invalid
416    /// hashes, duplicate saved IDs, invalid base64, or allocation excess return an
417    /// error without mutating the destination.
418    pub fn merged(&self, records: &[BinaryRecord]) -> Result<Self, BinaryError> {
419        if records.len() > self.limits.count {
420            return Err(BinaryError::Limit);
421        }
422        let mut merged = Self::from_payloads(
423            self.iter().map(|(id, bytes)| (id, bytes.to_vec())),
424            self.limits,
425        )?;
426        let mut seen = BTreeSet::new();
427        for record in records {
428            let max_encoded = self
429                .limits
430                .per_asset
431                .checked_add(2)
432                .and_then(|n| n.checked_div(3))
433                .and_then(|n| n.checked_mul(4))
434                .ok_or(BinaryError::Limit)?;
435            if record.data.len() > max_encoded {
436                return Err(BinaryError::Limit);
437            }
438            if !seen.insert(record.id) {
439                return Err(BinaryError::Corrupt);
440            }
441            let source = merged.intern(DocumentSourceKind::Base64(record.data.clone()))?;
442            if !matches!(source, PartSource::Binary { id, .. } if id == record.id) {
443                return Err(BinaryError::Corrupt);
444            }
445        }
446        Ok(merged)
447    }
448}
449
450impl From<BinaryId> for String {
451    fn from(id: BinaryId) -> Self {
452        let mut value = String::with_capacity(64);
453        for byte in id.0 {
454            for half in [byte >> 4, byte & 15] {
455                value.push(char::from(if half < 10 {
456                    b'0' + half
457                } else {
458                    b'a' + half - 10
459                }));
460            }
461        }
462        value
463    }
464}
465
466impl TryFrom<String> for BinaryId {
467    type Error = BinaryError;
468    fn try_from(value: String) -> Result<Self, Self::Error> {
469        if value.len() != 64
470            || !value
471                .bytes()
472                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
473        {
474            return Err(BinaryError::Corrupt);
475        }
476        let mut id = [0; 32];
477        for (position, byte) in id.iter_mut().enumerate() {
478            let digits = value
479                .get(position * 2..position * 2 + 2)
480                .ok_or(BinaryError::Corrupt)?;
481            *byte = u8::from_str_radix(digits, 16).map_err(|_| BinaryError::Corrupt)?;
482        }
483        Ok(Self(id))
484    }
485}