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