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