mod target;
pub use target::{InventoryTargetRecord, InventoryTargetRequest, MAX_INVENTORY_ROLE_BYTES};
use crate::model::artifacts::{ArtifactChecksumRecord, ChecksumError};
use serde::{Deserialize, Deserializer, Serialize, de};
use std::{
collections::{BTreeMap, BTreeSet},
fmt,
};
use thiserror::Error;
pub const MAX_INVENTORY_TARGETS: usize = 1024;
pub const MAX_INVENTORY_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(try_from = "InventoryFields")]
pub struct InventoryRecord {
version: u16,
targets: Vec<InventoryTargetRecord>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InventoryFields {
version: u16,
#[serde(deserialize_with = "bounded_targets")]
targets: Vec<InventoryTargetRecord>,
}
impl TryFrom<InventoryFields> for InventoryRecord {
type Error = InventoryRecordError;
fn try_from(fields: InventoryFields) -> Result<Self, Self::Error> {
if fields.version != 1 {
return Err(InventoryRecordError::UnsupportedVersion(fields.version));
}
Self::new(fields.targets)
}
}
impl InventoryRecord {
pub fn new(mut targets: Vec<InventoryTargetRecord>) -> Result<Self, InventoryRecordError> {
check_count(targets.len())?;
targets.sort_by(|a, b| a.canister_id().cmp(b.canister_id()));
for pair in targets.windows(2) {
if pair[0].canister_id() == pair[1].canister_id() {
return Err(InventoryRecordError::DuplicateTarget(
pair[0].canister_id().into(),
));
}
}
let parents: BTreeMap<_, _> = targets
.iter()
.map(|target| (target.canister_id(), target.parent_canister_id()))
.collect();
for target in &targets {
if let Some(parent) = target.parent_canister_id()
&& !parents.contains_key(parent)
{
return Err(InventoryRecordError::MissingParent {
canister_id: target.canister_id().into(),
parent: parent.into(),
});
}
}
for target in &targets {
let mut current = Some(target.canister_id());
let mut seen = BTreeSet::new();
while let Some(id) = current {
if !seen.insert(id) {
return Err(InventoryRecordError::Cycle(id.into()));
}
current = parents[id];
}
}
Ok(Self {
version: 1,
targets,
})
}
#[must_use]
pub fn targets(&self) -> &[InventoryTargetRecord] {
&self.targets
}
pub fn target(
&self,
canister_id: &str,
) -> Result<&InventoryTargetRecord, InventoryRecordError> {
let id = super::principal::canonical_text(canister_id)
.ok_or(InventoryRecordError::InvalidPrincipal("canister_id"))?;
self.targets
.binary_search_by(|target| target.canister_id().cmp(&id))
.map(|index| &self.targets[index])
.map_err(|_| InventoryRecordError::UnknownTarget(id))
}
#[must_use]
pub fn digest(&self) -> ArtifactChecksumRecord {
let mut bytes = b"ic-backup/inventory/v1\0".to_vec();
#[expect(
clippy::cast_possible_truncation,
reason = "validated target count is at most 1024"
)]
let count = self.targets.len() as u32;
bytes.extend_from_slice(&count.to_be_bytes());
for target in &self.targets {
append_text(&mut bytes, target.canister_id());
for field in [
target.parent_canister_id(),
target.role(),
target.module_hash(),
] {
match field {
None => bytes.push(0),
Some(value) => {
bytes.push(1);
append_text(&mut bytes, value);
}
}
}
}
ArtifactChecksumRecord::from_bytes(&bytes)
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "all admitted text is bounded to at most 256 UTF-8 bytes"
)]
fn append_text(bytes: &mut Vec<u8>, value: &str) {
bytes.extend_from_slice(&(value.len() as u32).to_be_bytes());
bytes.extend_from_slice(value.as_bytes());
}
pub(super) fn check_count(count: usize) -> Result<(), InventoryRecordError> {
if count == 0 {
return Err(InventoryRecordError::EmptyInventory);
}
if count > MAX_INVENTORY_TARGETS {
return Err(InventoryRecordError::TooManyTargets);
}
Ok(())
}
fn bounded_targets<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<InventoryTargetRecord>, D::Error> {
struct TargetsVisitor;
impl<'de> de::Visitor<'de> for TargetsVisitor {
type Value = Vec<InventoryTargetRecord>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a bounded physical target list")
}
fn visit_seq<A: de::SeqAccess<'de>>(
self,
mut sequence: A,
) -> Result<Self::Value, A::Error> {
let mut targets = Vec::new();
while targets.len() < MAX_INVENTORY_TARGETS {
match sequence.next_element()? {
Some(target) => targets.push(target),
None => return Ok(targets),
}
}
if sequence.next_element::<de::IgnoredAny>()?.is_some() {
return Err(de::Error::custom(InventoryRecordError::TooManyTargets));
}
Ok(targets)
}
}
deserializer.deserialize_seq(TargetsVisitor)
}
#[derive(Debug, Error)]
pub enum InventoryRecordError {
#[error("unsupported inventory version {0}")]
UnsupportedVersion(u16),
#[error("inventory contains no targets")]
EmptyInventory,
#[error("inventory exceeds {MAX_INVENTORY_TARGETS} targets")]
TooManyTargets,
#[error("invalid inventory principal in {0}")]
InvalidPrincipal(&'static str),
#[error("inventory role exceeds {MAX_INVENTORY_ROLE_BYTES} bytes")]
RoleTooLarge,
#[error(transparent)]
Checksum(#[from] ChecksumError),
#[error("duplicate inventory target {0}")]
DuplicateTarget(String),
#[error("unknown inventory target {0}")]
UnknownTarget(String),
#[error("inventory target {canister_id} has absent parent {parent}")]
MissingParent {
canister_id: String,
parent: String,
},
#[error("inventory cycle at {0}")]
Cycle(String),
}
#[cfg(test)]
mod tests;