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 bytes.bytes_u32(target.canister_id().as_bytes());
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 bytes.bytes_u32(value.as_bytes());
165 }
166 }
167 }
168 }
169 bytes.finish()
170 }
171}
172
173pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
174 if count == 0 {
175 return Err(InventoryRecordError::EmptyInventory);
176 }
177 if count > MAX_INVENTORY_TARGETS {
178 return Err(InventoryRecordError::TooManyTargets);
179 }
180 Ok(())
181}
182
183fn bounded_targets<'de, D: Deserializer<'de>>(
184 deserializer: D,
185) -> Result<Vec<InventoryTargetRecord>, D::Error> {
186 bounded_sequence::deserialize::<_, _, MAX_INVENTORY_TARGETS>(
187 deserializer,
188 "a bounded physical target list",
189 InventoryRecordError::TooManyTargets,
190 )
191}
192
193#[derive(Debug, Error)]
195pub enum InventoryRecordError {
196 #[error("unsupported inventory version {0}")]
198 UnsupportedVersion(u16),
199 #[error("inventory contains no targets")]
201 EmptyInventory,
202 #[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
204 TooManyTargets,
205 #[error("invalid inventory principal in {0}")]
207 InvalidPrincipal(&'static str),
208 #[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
210 RoleTooLarge,
211 #[error(transparent)]
213 Checksum(#[from] ChecksumError),
214 #[error("duplicate inventory target {0}")]
216 DuplicateTarget(String),
217 #[error("unknown inventory target {0}")]
219 UnknownTarget(String),
220 #[error("inventory target {canister_id} has absent parent {parent}")]
222 MissingParent {
223 canister_id: String,
225 parent: String,
227 },
228 #[error("inventory cycle at {0}")]
230 Cycle(String),
231}
232
233#[cfg(test)]
234mod tests;