Skip to main content

ic_backup/model/inventory/
mod.rs

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