ic_backup/model/inventory/
mod.rs1mod target;
4pub use target::{InventoryTargetRecord, InventoryTargetRequest, MAX_INVENTORY_ROLE_BYTES};
5
6use crate::model::artifacts::{ArtifactChecksumRecord, ChecksumError};
7use serde::{Deserialize, Deserializer, Serialize, de};
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 fmt,
11};
12use thiserror::Error;
13
14pub const MAX_INVENTORY_TARGETS: usize = 1024;
16pub const MAX_INVENTORY_BYTES: u64 = 1024 * 1024;
18
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(try_from = "InventoryFields")]
26pub struct InventoryRecord {
27 version: u16,
28 targets: Vec<InventoryTargetRecord>,
29}
30
31#[derive(Deserialize)]
32#[serde(deny_unknown_fields)]
33struct InventoryFields {
34 version: u16,
35 #[serde(deserialize_with = "bounded_targets")]
36 targets: Vec<InventoryTargetRecord>,
37}
38
39impl TryFrom<InventoryFields> for InventoryRecord {
40 type Error = InventoryRecordError;
41 fn try_from(fields: InventoryFields) -> Result<Self, Self::Error> {
42 if fields.version != 1 {
43 return Err(InventoryRecordError::UnsupportedVersion(fields.version));
44 }
45 Self::new(fields.targets)
46 }
47}
48
49impl InventoryRecord {
50 pub fn new(mut targets: Vec<InventoryTargetRecord>) -> Result<Self, InventoryRecordError> {
55 check_count(targets.len())?;
56 targets.sort_by(|a, b| a.canister_id().cmp(b.canister_id()));
57 for pair in targets.windows(2) {
58 if pair[0].canister_id() == pair[1].canister_id() {
59 return Err(InventoryRecordError::DuplicateTarget(
60 pair[0].canister_id().into(),
61 ));
62 }
63 }
64 let parents: BTreeMap<_, _> = targets
65 .iter()
66 .map(|target| (target.canister_id(), target.parent_canister_id()))
67 .collect();
68 for target in &targets {
69 if let Some(parent) = target.parent_canister_id()
70 && !parents.contains_key(parent)
71 {
72 return Err(InventoryRecordError::MissingParent {
73 canister_id: target.canister_id().into(),
74 parent: parent.into(),
75 });
76 }
77 }
78 for target in &targets {
79 let mut current = Some(target.canister_id());
80 let mut seen = BTreeSet::new();
81 while let Some(id) = current {
82 if !seen.insert(id) {
83 return Err(InventoryRecordError::Cycle(id.into()));
84 }
85 current = parents[id];
86 }
87 }
88 Ok(Self {
89 version: 1,
90 targets,
91 })
92 }
93
94 #[must_use]
96 pub fn targets(&self) -> &[InventoryTargetRecord] {
97 &self.targets
98 }
99
100 pub fn target(
105 &self,
106 canister_id: &str,
107 ) -> Result<&InventoryTargetRecord, InventoryRecordError> {
108 let id = super::principal::canonical_text(canister_id)
109 .ok_or(InventoryRecordError::InvalidPrincipal("canister_id"))?;
110 self.targets
111 .binary_search_by(|target| target.canister_id().cmp(&id))
112 .map(|index| &self.targets[index])
113 .map_err(|_| InventoryRecordError::UnknownTarget(id))
114 }
115
116 #[must_use]
122 pub fn digest(&self) -> ArtifactChecksumRecord {
123 let mut bytes = b"ic-backup/inventory/v1\0".to_vec();
124 #[expect(
125 clippy::cast_possible_truncation,
126 reason = "validated target count is at most 1024"
127 )]
128 let count = self.targets.len() as u32;
129 bytes.extend_from_slice(&count.to_be_bytes());
130 for target in &self.targets {
131 append_text(&mut bytes, target.canister_id());
132 for field in [
133 target.parent_canister_id(),
134 target.role(),
135 target.module_hash(),
136 ] {
137 match field {
138 None => bytes.push(0),
139 Some(value) => {
140 bytes.push(1);
141 append_text(&mut bytes, value);
142 }
143 }
144 }
145 }
146 ArtifactChecksumRecord::from_bytes(&bytes)
147 }
148}
149
150#[expect(
151 clippy::cast_possible_truncation,
152 reason = "all admitted text is bounded to at most 256 UTF-8 bytes"
153)]
154fn append_text(bytes: &mut Vec<u8>, value: &str) {
155 bytes.extend_from_slice(&(value.len() as u32).to_be_bytes());
156 bytes.extend_from_slice(value.as_bytes());
157}
158
159pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
160 if count == 0 {
161 return Err(InventoryRecordError::EmptyInventory);
162 }
163 if count > MAX_INVENTORY_TARGETS {
164 return Err(InventoryRecordError::TooManyTargets);
165 }
166 Ok(())
167}
168
169fn bounded_targets<'de, D: Deserializer<'de>>(
170 deserializer: D,
171) -> Result<Vec<InventoryTargetRecord>, D::Error> {
172 struct TargetsVisitor;
173 impl<'de> de::Visitor<'de> for TargetsVisitor {
174 type Value = Vec<InventoryTargetRecord>;
175 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 f.write_str("a bounded physical target list")
177 }
178 fn visit_seq<A: de::SeqAccess<'de>>(
179 self,
180 mut sequence: A,
181 ) -> Result<Self::Value, A::Error> {
182 let mut targets = Vec::new();
183 while targets.len() < MAX_INVENTORY_TARGETS {
184 match sequence.next_element()? {
185 Some(target) => targets.push(target),
186 None => return Ok(targets),
187 }
188 }
189 if sequence.next_element::<de::IgnoredAny>()?.is_some() {
190 return Err(de::Error::custom(InventoryRecordError::TooManyTargets));
191 }
192 Ok(targets)
193 }
194 }
195 deserializer.deserialize_seq(TargetsVisitor)
196}
197
198#[derive(Debug, Error)]
200pub enum InventoryRecordError {
201 #[error("unsupported inventory version {0}")]
203 UnsupportedVersion(u16),
204 #[error("inventory contains no targets")]
206 EmptyInventory,
207 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
209 TooManyTargets,
210 #[error("invalid inventory principal in {0}")]
212 InvalidPrincipal(&'static str),
213 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
215 RoleTooLarge,
216 #[error(transparent)]
218 Checksum(#[from] ChecksumError),
219 #[error("duplicate inventory target {0}")]
221 DuplicateTarget(String),
222 #[error("unknown inventory target {0}")]
224 UnknownTarget(String),
225 #[error("inventory target {canister_id} has absent parent {parent}")]
227 MissingParent {
228 canister_id: String,
230 parent: String,
232 },
233 #[error("inventory cycle at {0}")]
235 Cycle(String),
236}
237
238#[cfg(test)]
239mod tests;