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::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 record = Self {
64            version: 1,
65            targets,
66        };
67        for target in &record.targets {
68            if let Some(parent) = target.parent_canister_id()
69                && record.canonical_target(parent).is_none()
70            {
71                return Err(InventoryRecordError::MissingParent {
72                    canister_id: target.canister_id().into(),
73                    parent: parent.into(),
74                });
75            }
76        }
77        for target in &record.targets {
78            let mut current = Some(target);
79            let mut seen = BTreeSet::new();
80            while let Some(target) = current {
81                let id = target.canister_id();
82                if !seen.insert(id) {
83                    return Err(InventoryRecordError::Cycle(id.into()));
84                }
85                current = target
86                    .parent_canister_id()
87                    .and_then(|id| record.canonical_target(id));
88            }
89        }
90        Ok(record)
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.canonical_target(&id)
110            .ok_or(InventoryRecordError::UnknownTarget(id))
111    }
112
113    // Internal callers have already admitted canonical text at their owning boundary.
114    pub(super) fn canonical_target(&self, id: &str) -> Option<&InventoryTargetRecord> {
115        self.targets
116            .binary_search_by(|target| target.canister_id().cmp(id))
117            .ok()
118            .map(|index| &self.targets[index])
119    }
120
121    /// Hash exact canonical declared fields using the documented v1 binary encoding.
122    ///
123    /// Equivalent input order/case has one digest. Optional fields have explicit
124    /// tags, so null, empty strings and the text "null" remain distinct. This hash
125    /// proves declared equality only, without fresh membership or authority.
126    #[must_use]
127    pub fn digest(&self) -> ArtifactChecksumRecord {
128        let mut bytes = EvidenceDigest::new(b"ic-backup/inventory/v1\0");
129        #[expect(
130            clippy::cast_possible_truncation,
131            reason = "validated target count is at most 1024"
132        )]
133        let count = self.targets.len() as u32;
134        bytes.u32_be(count);
135        for target in &self.targets {
136            append_text(&mut bytes, target.canister_id());
137            for field in [
138                target.parent_canister_id(),
139                target.role(),
140                target.module_hash(),
141            ] {
142                match field {
143                    None => bytes.byte(0),
144                    Some(value) => {
145                        bytes.byte(1);
146                        append_text(&mut bytes, value);
147                    }
148                }
149            }
150        }
151        bytes.finish()
152    }
153}
154
155#[expect(
156    clippy::cast_possible_truncation,
157    reason = "all admitted text is bounded to at most 256 UTF-8 bytes"
158)]
159fn append_text(bytes: &mut EvidenceDigest, value: &str) {
160    bytes.u32_be(value.len() as u32);
161    bytes.append(value.as_bytes());
162}
163
164pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
165    if count == 0 {
166        return Err(InventoryRecordError::EmptyInventory);
167    }
168    if count > MAX_INVENTORY_TARGETS {
169        return Err(InventoryRecordError::TooManyTargets);
170    }
171    Ok(())
172}
173
174fn bounded_targets<'de, D: Deserializer<'de>>(
175    deserializer: D,
176) -> Result<Vec<InventoryTargetRecord>, D::Error> {
177    bounded_sequence::deserialize::<_, _, MAX_INVENTORY_TARGETS>(
178        deserializer,
179        "a bounded physical target list",
180        InventoryRecordError::TooManyTargets,
181    )
182}
183
184/// Typed declared identity, graph or resource-bound rejection.
185#[derive(Debug, Error)]
186pub enum InventoryRecordError {
187    /// Only product generation v1 is maintained.
188    #[error("unsupported inventory version {0}")]
189    UnsupportedVersion(u16),
190    /// Exact inventory cannot be empty.
191    #[error("inventory contains no targets")]
192    EmptyInventory,
193    /// Target count exceeds the maintained bound.
194    #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
195    TooManyTargets,
196    /// Principal text fails canonical admission at this named field.
197    #[error("invalid inventory principal in {0}")]
198    InvalidPrincipal(&'static str),
199    /// Role text exceeds its UTF-8 byte bound.
200    #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
201    RoleTooLarge,
202    /// Module digest fails canonical SHA-256 admission.
203    #[error(transparent)]
204    Checksum(#[from] ChecksumError),
205    /// Equivalent physical target appeared more than once.
206    #[error("duplicate inventory target {0}")]
207    DuplicateTarget(String),
208    /// Selected exact principal is absent.
209    #[error("unknown inventory target {0}")]
210    UnknownTarget(String),
211    /// Parent relationship points outside the declared forest.
212    #[error("inventory target {canister_id} has absent parent {parent}")]
213    MissingParent {
214        /// Exact child principal.
215        canister_id: String,
216        /// Exact missing parent principal.
217        parent: String,
218    },
219    /// Parent edges form a cycle, including self-parenting.
220    #[error("inventory cycle at {0}")]
221    Cycle(String),
222}
223
224#[cfg(test)]
225mod tests;