Skip to main content

ic_backup/model/inventory/
mod.rs

1//! Canonical bounded physical inventories; declarations do not establish live membership.
2
3use crate::model::evidence_digest::EvidenceDigest;
4mod target;
5pub use target::{InventoryTargetRecord, InventoryTargetRequest, MAX_INVENTORY_ROLE_BYTES};
6
7use crate::model::{
8    artifacts::{ArtifactChecksumRecord, ChecksumError},
9    bounded_sequence,
10};
11use serde::{Deserialize, Deserializer, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use thiserror::Error;
14
15/// Maximum exact physical targets in one declared inventory.
16pub const MAX_INVENTORY_TARGETS: usize = 1024;
17/// Maximum encoded input and canonical output bytes admitted by inventory persistence.
18pub const MAX_INVENTORY_BYTES: u64 = 1024 * 1024;
19
20/// Immutable v1 declared forest with canonical unique principals and closed parent links.
21///
22/// No root is assigned a privileged role. A parentless entry may be any selected
23/// physical canister; multiple disconnected roots are allowed. Membership,
24/// permissions, current module state and application consistency stay integration-owned.
25#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(try_from = "InventoryFields")]
27pub struct InventoryRecord {
28    version: u16,
29    targets: Vec<InventoryTargetRecord>,
30}
31
32#[derive(Deserialize)]
33#[serde(deny_unknown_fields)]
34struct InventoryFields {
35    version: u16,
36    #[serde(deserialize_with = "bounded_targets")]
37    targets: Vec<InventoryTargetRecord>,
38}
39
40impl TryFrom<InventoryFields> for InventoryRecord {
41    type Error = InventoryRecordError;
42    fn try_from(fields: InventoryFields) -> Result<Self, Self::Error> {
43        if fields.version != 1 {
44            return Err(InventoryRecordError::UnsupportedVersion(fields.version));
45        }
46        Self::new(fields.targets)
47    }
48}
49
50impl InventoryRecord {
51    /// Validate and sort a nonempty exact declared forest without IO.
52    ///
53    /// # Errors
54    /// Rejects excessive counts, duplicate identities, missing parents and cycles.
55    pub fn new(mut targets: Vec<InventoryTargetRecord>) -> Result<Self, InventoryRecordError> {
56        check_count(targets.len())?;
57        targets.sort_by(|a, b| a.canister_id().cmp(b.canister_id()));
58        for pair in targets.windows(2) {
59            if pair[0].canister_id() == pair[1].canister_id() {
60                return Err(InventoryRecordError::DuplicateTarget(
61                    pair[0].canister_id().into(),
62                ));
63            }
64        }
65        let parents: BTreeMap<_, _> = targets
66            .iter()
67            .map(|target| (target.canister_id(), target.parent_canister_id()))
68            .collect();
69        for target in &targets {
70            if let Some(parent) = target.parent_canister_id()
71                && !parents.contains_key(parent)
72            {
73                return Err(InventoryRecordError::MissingParent {
74                    canister_id: target.canister_id().into(),
75                    parent: parent.into(),
76                });
77            }
78        }
79        for target in &targets {
80            let mut current = Some(target.canister_id());
81            let mut seen = BTreeSet::new();
82            while let Some(id) = current {
83                if !seen.insert(id) {
84                    return Err(InventoryRecordError::Cycle(id.into()));
85                }
86                current = parents[id];
87            }
88        }
89        Ok(Self {
90            version: 1,
91            targets,
92        })
93    }
94
95    /// Read targets in canonical ASCII principal order; this is not an effect order.
96    #[must_use]
97    pub fn targets(&self) -> &[InventoryTargetRecord] {
98        &self.targets
99    }
100
101    /// Resolve an exact principal through canonical inventory identity admission.
102    ///
103    /// # Errors
104    /// Rejects malformed principals or absent exact physical targets.
105    pub fn target(
106        &self,
107        canister_id: &str,
108    ) -> Result<&InventoryTargetRecord, InventoryRecordError> {
109        let id = super::principal::canonical_text(canister_id)
110            .ok_or(InventoryRecordError::InvalidPrincipal("canister_id"))?;
111        self.targets
112            .binary_search_by(|target| target.canister_id().cmp(&id))
113            .map(|index| &self.targets[index])
114            .map_err(|_| InventoryRecordError::UnknownTarget(id))
115    }
116
117    /// Hash exact canonical declared fields using the documented v1 binary encoding.
118    ///
119    /// Equivalent input order/case has one digest. Optional fields have explicit
120    /// tags, so null, empty strings and the text "null" remain distinct. This hash
121    /// proves declared equality only, without fresh membership or authority.
122    #[must_use]
123    pub fn digest(&self) -> ArtifactChecksumRecord {
124        let mut bytes = EvidenceDigest::new(b"ic-backup/inventory/v1\0");
125        #[expect(
126            clippy::cast_possible_truncation,
127            reason = "validated target count is at most 1024"
128        )]
129        let count = self.targets.len() as u32;
130        bytes.append(&count.to_be_bytes());
131        for target in &self.targets {
132            append_text(&mut bytes, target.canister_id());
133            for field in [
134                target.parent_canister_id(),
135                target.role(),
136                target.module_hash(),
137            ] {
138                match field {
139                    None => bytes.byte(0),
140                    Some(value) => {
141                        bytes.byte(1);
142                        append_text(&mut bytes, value);
143                    }
144                }
145            }
146        }
147        bytes.finish()
148    }
149}
150
151#[expect(
152    clippy::cast_possible_truncation,
153    reason = "all admitted text is bounded to at most 256 UTF-8 bytes"
154)]
155fn append_text(bytes: &mut EvidenceDigest, value: &str) {
156    bytes.append(&(value.len() as u32).to_be_bytes());
157    bytes.append(value.as_bytes());
158}
159
160pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
161    if count == 0 {
162        return Err(InventoryRecordError::EmptyInventory);
163    }
164    if count > MAX_INVENTORY_TARGETS {
165        return Err(InventoryRecordError::TooManyTargets);
166    }
167    Ok(())
168}
169
170fn bounded_targets<'de, D: Deserializer<'de>>(
171    deserializer: D,
172) -> Result<Vec<InventoryTargetRecord>, D::Error> {
173    bounded_sequence::deserialize::<_, _, MAX_INVENTORY_TARGETS>(
174        deserializer,
175        "a bounded physical target list",
176        InventoryRecordError::TooManyTargets,
177    )
178}
179
180/// Typed declared identity, graph or resource-bound rejection.
181#[derive(Debug, Error)]
182pub enum InventoryRecordError {
183    /// Only product generation v1 is maintained.
184    #[error("unsupported inventory version {0}")]
185    UnsupportedVersion(u16),
186    /// Exact inventory cannot be empty.
187    #[error("inventory contains no targets")]
188    EmptyInventory,
189    /// Target count exceeds the maintained bound.
190    #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
191    TooManyTargets,
192    /// Principal text fails canonical admission at this named field.
193    #[error("invalid inventory principal in {0}")]
194    InvalidPrincipal(&'static str),
195    /// Role text exceeds its UTF-8 byte bound.
196    #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
197    RoleTooLarge,
198    /// Module digest fails canonical SHA-256 admission.
199    #[error(transparent)]
200    Checksum(#[from] ChecksumError),
201    /// Equivalent physical target appeared more than once.
202    #[error("duplicate inventory target {0}")]
203    DuplicateTarget(String),
204    /// Selected exact principal is absent.
205    #[error("unknown inventory target {0}")]
206    UnknownTarget(String),
207    /// Parent relationship points outside the declared forest.
208    #[error("inventory target {canister_id} has absent parent {parent}")]
209    MissingParent {
210        /// Exact child principal.
211        canister_id: String,
212        /// Exact missing parent principal.
213        parent: String,
214    },
215    /// Parent edges form a cycle, including self-parenting.
216    #[error("inventory cycle at {0}")]
217    Cycle(String),
218}
219
220#[cfg(test)]
221mod tests;