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 thiserror::Error;
13
14pub const MAX_INVENTORY_TARGETS: usize = 1024;
16
17#[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 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 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 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 #[must_use]
113 pub fn targets(&self) -> &[InventoryTargetRecord] {
114 &self.targets
115 }
116
117 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 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 #[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#[derive(Debug, Error)]
204pub enum InventoryRecordError {
205 #[error("unsupported inventory version {0}")]
207 UnsupportedVersion(u16),
208 #[error("inventory contains no targets")]
210 EmptyInventory,
211 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
213 TooManyTargets,
214 #[error("invalid inventory principal in {0}")]
216 InvalidPrincipal(&'static str),
217 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
219 RoleTooLarge,
220 #[error(transparent)]
222 Checksum(#[from] ChecksumError),
223 #[error("duplicate inventory target {0}")]
225 DuplicateTarget(String),
226 #[error("unknown inventory target {0}")]
228 UnknownTarget(String),
229 #[error("inventory target {canister_id} has absent parent {parent}")]
231 MissingParent {
232 canister_id: String,
234 parent: String,
236 },
237 #[error("inventory cycle at {0}")]
239 Cycle(String),
240}
241
242#[cfg(test)]
243mod tests;