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;
17pub const MAX_INVENTORY_BYTES: u64 = 1024 * 1024;
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(try_from = "InventoryFields")]
27pub struct InventoryRecord {
28 version: u16,
29 targets: Vec<InventoryTargetRecord>,
30}
31
32#[derive(Deserialize)]
33#[serde(deny_unknown_fields)]
34struct InventoryFields {
35 version: u16,
36 #[serde(deserialize_with = "bounded_targets")]
37 targets: Vec<InventoryTargetRecord>,
38}
39
40impl TryFrom<InventoryFields> for InventoryRecord {
41 type Error = InventoryRecordError;
42 fn try_from(fields: InventoryFields) -> Result<Self, Self::Error> {
43 if fields.version != 1 {
44 return Err(InventoryRecordError::UnsupportedVersion(fields.version));
45 }
46 Self::new(fields.targets)
47 }
48}
49
50impl InventoryRecord {
51 pub fn new(mut targets: Vec<InventoryTargetRecord>) -> Result<Self, InventoryRecordError> {
56 check_count(targets.len())?;
57 targets.sort_by(|a, b| a.canister_id().cmp(b.canister_id()));
58 for pair in targets.windows(2) {
59 if pair[0].canister_id() == pair[1].canister_id() {
60 return Err(InventoryRecordError::DuplicateTarget(
61 pair[0].canister_id().into(),
62 ));
63 }
64 }
65 let parents: BTreeMap<_, _> = targets
66 .iter()
67 .map(|target| (target.canister_id(), target.parent_canister_id()))
68 .collect();
69 for target in &targets {
70 if let Some(parent) = target.parent_canister_id()
71 && !parents.contains_key(parent)
72 {
73 return Err(InventoryRecordError::MissingParent {
74 canister_id: target.canister_id().into(),
75 parent: parent.into(),
76 });
77 }
78 }
79 for target in &targets {
80 let mut current = Some(target.canister_id());
81 let mut seen = BTreeSet::new();
82 while let Some(id) = current {
83 if !seen.insert(id) {
84 return Err(InventoryRecordError::Cycle(id.into()));
85 }
86 current = parents[id];
87 }
88 }
89 Ok(Self {
90 version: 1,
91 targets,
92 })
93 }
94
95 #[must_use]
97 pub fn targets(&self) -> &[InventoryTargetRecord] {
98 &self.targets
99 }
100
101 pub fn target(
106 &self,
107 canister_id: &str,
108 ) -> Result<&InventoryTargetRecord, InventoryRecordError> {
109 let id = super::principal::canonical_text(canister_id)
110 .ok_or(InventoryRecordError::InvalidPrincipal("canister_id"))?;
111 self.targets
112 .binary_search_by(|target| target.canister_id().cmp(&id))
113 .map(|index| &self.targets[index])
114 .map_err(|_| InventoryRecordError::UnknownTarget(id))
115 }
116
117 #[must_use]
123 pub fn digest(&self) -> ArtifactChecksumRecord {
124 let mut bytes = EvidenceDigest::new(b"ic-backup/inventory/v1\0");
125 #[expect(
126 clippy::cast_possible_truncation,
127 reason = "validated target count is at most 1024"
128 )]
129 let count = self.targets.len() as u32;
130 bytes.append(&count.to_be_bytes());
131 for target in &self.targets {
132 append_text(&mut bytes, target.canister_id());
133 for field in [
134 target.parent_canister_id(),
135 target.role(),
136 target.module_hash(),
137 ] {
138 match field {
139 None => bytes.byte(0),
140 Some(value) => {
141 bytes.byte(1);
142 append_text(&mut bytes, value);
143 }
144 }
145 }
146 }
147 bytes.finish()
148 }
149}
150
151#[expect(
152 clippy::cast_possible_truncation,
153 reason = "all admitted text is bounded to at most 256 UTF-8 bytes"
154)]
155fn append_text(bytes: &mut EvidenceDigest, value: &str) {
156 bytes.append(&(value.len() as u32).to_be_bytes());
157 bytes.append(value.as_bytes());
158}
159
160pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
161 if count == 0 {
162 return Err(InventoryRecordError::EmptyInventory);
163 }
164 if count > MAX_INVENTORY_TARGETS {
165 return Err(InventoryRecordError::TooManyTargets);
166 }
167 Ok(())
168}
169
170fn bounded_targets<'de, D: Deserializer<'de>>(
171 deserializer: D,
172) -> Result<Vec<InventoryTargetRecord>, D::Error> {
173 bounded_sequence::deserialize::<_, _, MAX_INVENTORY_TARGETS>(
174 deserializer,
175 "a bounded physical target list",
176 InventoryRecordError::TooManyTargets,
177 )
178}
179
180#[derive(Debug, Error)]
182pub enum InventoryRecordError {
183 #[error("unsupported inventory version {0}")]
185 UnsupportedVersion(u16),
186 #[error("inventory contains no targets")]
188 EmptyInventory,
189 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
191 TooManyTargets,
192 #[error("invalid inventory principal in {0}")]
194 InvalidPrincipal(&'static str),
195 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
197 RoleTooLarge,
198 #[error(transparent)]
200 Checksum(#[from] ChecksumError),
201 #[error("duplicate inventory target {0}")]
203 DuplicateTarget(String),
204 #[error("unknown inventory target {0}")]
206 UnknownTarget(String),
207 #[error("inventory target {canister_id} has absent parent {parent}")]
209 MissingParent {
210 canister_id: String,
212 parent: String,
214 },
215 #[error("inventory cycle at {0}")]
217 Cycle(String),
218}
219
220#[cfg(test)]
221mod tests;