ic_backup/model/inventory/
mod.rs1mod target;
4pub use target::{InventoryTargetRecord, InventoryTargetRequest, MAX_INVENTORY_ROLE_BYTES};
5
6use crate::model::{
7 artifacts::{ArtifactChecksumRecord, ChecksumError},
8 bounded_sequence,
9};
10use serde::{Deserialize, Deserializer, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
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 bounded_sequence::deserialize::<_, _, MAX_INVENTORY_TARGETS>(
173 deserializer,
174 "a bounded physical target list",
175 InventoryRecordError::TooManyTargets,
176 )
177}
178
179#[derive(Debug, Error)]
181pub enum InventoryRecordError {
182 #[error("unsupported inventory version {0}")]
184 UnsupportedVersion(u16),
185 #[error("inventory contains no targets")]
187 EmptyInventory,
188 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
190 TooManyTargets,
191 #[error("invalid inventory principal in {0}")]
193 InvalidPrincipal(&'static str),
194 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
196 RoleTooLarge,
197 #[error(transparent)]
199 Checksum(#[from] ChecksumError),
200 #[error("duplicate inventory target {0}")]
202 DuplicateTarget(String),
203 #[error("unknown inventory target {0}")]
205 UnknownTarget(String),
206 #[error("inventory target {canister_id} has absent parent {parent}")]
208 MissingParent {
209 canister_id: String,
211 parent: String,
213 },
214 #[error("inventory cycle at {0}")]
216 Cycle(String),
217}
218
219#[cfg(test)]
220mod tests;