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::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 record = Self {
64 version: 1,
65 targets,
66 };
67 for target in &record.targets {
68 if let Some(parent) = target.parent_canister_id()
69 && record.canonical_target(parent).is_none()
70 {
71 return Err(InventoryRecordError::MissingParent {
72 canister_id: target.canister_id().into(),
73 parent: parent.into(),
74 });
75 }
76 }
77 for target in &record.targets {
78 let mut current = Some(target);
79 let mut seen = BTreeSet::new();
80 while let Some(target) = current {
81 let id = target.canister_id();
82 if !seen.insert(id) {
83 return Err(InventoryRecordError::Cycle(id.into()));
84 }
85 current = target
86 .parent_canister_id()
87 .and_then(|id| record.canonical_target(id));
88 }
89 }
90 Ok(record)
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.canonical_target(&id)
110 .ok_or(InventoryRecordError::UnknownTarget(id))
111 }
112
113 pub(super) fn canonical_target(&self, id: &str) -> Option<&InventoryTargetRecord> {
115 self.targets
116 .binary_search_by(|target| target.canister_id().cmp(id))
117 .ok()
118 .map(|index| &self.targets[index])
119 }
120
121 #[must_use]
127 pub fn digest(&self) -> ArtifactChecksumRecord {
128 let mut bytes = EvidenceDigest::new(b"ic-backup/inventory/v1\0");
129 #[expect(
130 clippy::cast_possible_truncation,
131 reason = "validated target count is at most 1024"
132 )]
133 let count = self.targets.len() as u32;
134 bytes.append(&count.to_be_bytes());
135 for target in &self.targets {
136 append_text(&mut bytes, target.canister_id());
137 for field in [
138 target.parent_canister_id(),
139 target.role(),
140 target.module_hash(),
141 ] {
142 match field {
143 None => bytes.byte(0),
144 Some(value) => {
145 bytes.byte(1);
146 append_text(&mut bytes, value);
147 }
148 }
149 }
150 }
151 bytes.finish()
152 }
153}
154
155#[expect(
156 clippy::cast_possible_truncation,
157 reason = "all admitted text is bounded to at most 256 UTF-8 bytes"
158)]
159fn append_text(bytes: &mut EvidenceDigest, value: &str) {
160 bytes.append(&(value.len() as u32).to_be_bytes());
161 bytes.append(value.as_bytes());
162}
163
164pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
165 if count == 0 {
166 return Err(InventoryRecordError::EmptyInventory);
167 }
168 if count > MAX_INVENTORY_TARGETS {
169 return Err(InventoryRecordError::TooManyTargets);
170 }
171 Ok(())
172}
173
174fn bounded_targets<'de, D: Deserializer<'de>>(
175 deserializer: D,
176) -> Result<Vec<InventoryTargetRecord>, D::Error> {
177 bounded_sequence::deserialize::<_, _, MAX_INVENTORY_TARGETS>(
178 deserializer,
179 "a bounded physical target list",
180 InventoryRecordError::TooManyTargets,
181 )
182}
183
184#[derive(Debug, Error)]
186pub enum InventoryRecordError {
187 #[error("unsupported inventory version {0}")]
189 UnsupportedVersion(u16),
190 #[error("inventory contains no targets")]
192 EmptyInventory,
193 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
195 TooManyTargets,
196 #[error("invalid inventory principal in {0}")]
198 InvalidPrincipal(&'static str),
199 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
201 RoleTooLarge,
202 #[error(transparent)]
204 Checksum(#[from] ChecksumError),
205 #[error("duplicate inventory target {0}")]
207 DuplicateTarget(String),
208 #[error("unknown inventory target {0}")]
210 UnknownTarget(String),
211 #[error("inventory target {canister_id} has absent parent {parent}")]
213 MissingParent {
214 canister_id: String,
216 parent: String,
218 },
219 #[error("inventory cycle at {0}")]
221 Cycle(String),
222}
223
224#[cfg(test)]
225mod tests;