ic_backup/model/inventory/
mod.rs1use 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
15pub const MAX_INVENTORY_TARGETS: usize = 1024;
17
18#[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 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 #[must_use]
95 pub fn targets(&self) -> &[InventoryTargetRecord] {
96 &self.targets
97 }
98
99 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 #[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#[derive(Debug, Error)]
180pub enum InventoryRecordError {
181 #[error("unsupported inventory version {0}")]
183 UnsupportedVersion(u16),
184 #[error("inventory contains no targets")]
186 EmptyInventory,
187 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
189 TooManyTargets,
190 #[error("invalid inventory principal in {0}")]
192 InvalidPrincipal(&'static str),
193 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
195 RoleTooLarge,
196 #[error(transparent)]
198 Checksum(#[from] ChecksumError),
199 #[error("duplicate inventory target {0}")]
201 DuplicateTarget(String),
202 #[error("unknown inventory target {0}")]
204 UnknownTarget(String),
205 #[error("inventory target {canister_id} has absent parent {parent}")]
207 MissingParent {
208 canister_id: String,
210 parent: String,
212 },
213 #[error("inventory cycle at {0}")]
215 Cycle(String),
216}
217
218#[cfg(test)]
219mod tests;