Skip to main content

ic_build/cargo/packages/external_lock/
mod.rs

1//! One external-selection reader for pure subset checks and archive admission.
2
3use crate::cargo::lockfile::{external_package, optional_string, required_string};
4use ic_host_artifacts::artifact::Sha256Digest;
5use std::{collections::BTreeMap, error::Error, fmt};
6
7type ExternalRows = BTreeMap<(String, String, String), Option<String>>;
8
9/// External lockfile comparison failed without changing either input.
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum ExternalLockError {
13    /// An input exceeds the caller's per-document byte allowance.
14    LimitExceeded {
15        /// `original` or `candidate` input role.
16        input: &'static str,
17        /// Caller-selected byte allowance.
18        limit: usize,
19    },
20    /// An input is not UTF-8.
21    Utf8 {
22        /// Input role.
23        input: &'static str,
24        /// Original decoding failure.
25        source: std::str::Utf8Error,
26    },
27    /// An input is not valid TOML.
28    Parse {
29        /// Input role.
30        input: &'static str,
31        /// Original parser failure.
32        source: toml::de::Error,
33    },
34    /// An input has no package array.
35    MissingPackages {
36        /// Input role.
37        input: &'static str,
38    },
39    /// A package record or external identity field is malformed.
40    InvalidPackage {
41        /// Input role.
42        input: &'static str,
43        /// Zero-based package-array position.
44        index: usize,
45        /// Failed structural or field requirement.
46        reason: &'static str,
47    },
48    /// An external name/version/source identity appears more than once.
49    DuplicateSelection {
50        /// Input role.
51        input: &'static str,
52        /// Package name.
53        package: String,
54        /// Package version.
55        version: String,
56    },
57    /// A candidate identity or checksum does not match the original.
58    SelectionChanged {
59        /// Candidate package name.
60        package: String,
61        /// Candidate package version.
62        version: String,
63    },
64}
65
66impl fmt::Display for ExternalLockError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::LimitExceeded { input, limit } => {
70                write!(f, "{input} lockfile exceeds {limit} bytes")
71            }
72            Self::Utf8 { input, source } => write!(f, "invalid {input} lockfile UTF-8: {source}"),
73            Self::Parse { input, source } => write!(f, "invalid {input} lockfile: {source}"),
74            Self::MissingPackages { input } => write!(f, "missing {input} lock packages"),
75            Self::InvalidPackage {
76                input,
77                index,
78                reason,
79            } => {
80                write!(f, "{input} lock package {index}: {reason}")
81            }
82            Self::DuplicateSelection {
83                input,
84                package,
85                version,
86            } => {
87                write!(
88                    f,
89                    "duplicate {input} package selection: {package}@{version}"
90                )
91            }
92            Self::SelectionChanged { package, version } => {
93                write!(
94                    f,
95                    "packaging changed external selection: {package}@{version}"
96                )
97            }
98        }
99    }
100}
101
102impl Error for ExternalLockError {
103    fn source(&self) -> Option<&(dyn Error + 'static)> {
104        match self {
105            Self::Utf8 { source, .. } => Some(source),
106            Self::Parse { source, .. } => Some(source),
107            _ => None,
108        }
109    }
110}
111
112/// Require every candidate external package to match an original selection.
113///
114/// Both documents are bounded by `max_bytes` before decoding. Compare exact
115/// name/version/source identities and optional checksum fields; registry
116/// checksums must be canonical lowercase SHA-256. Duplicate external identities
117/// refuse even when their checksums agree. Extra original packages are allowed.
118/// An empty candidate package array is a valid subset.
119///
120/// Unsourced tables are outside this comparison: callers admit their local
121/// package names, versions, paths, feature graph and nonempty-graph requirements
122/// independently. This does not validate dependency edges or admit a release.
123/// No I/O, resolution, tool execution, mutation or network access occurs.
124///
125/// # Errors
126/// Returns typed byte-bound, UTF-8, TOML, record, duplicate or selection failures.
127pub fn verify_external_lock_subset(
128    original: &[u8],
129    candidate: &[u8],
130    max_bytes: usize,
131) -> Result<(), ExternalLockError> {
132    for (input, bytes) in [("original", original), ("candidate", candidate)] {
133        if bytes.len() > max_bytes {
134            return Err(ExternalLockError::LimitExceeded {
135                input,
136                limit: max_bytes,
137            });
138        }
139    }
140    let original = external_rows(original, "original")?;
141    let candidate = external_rows(candidate, "candidate")?;
142    verify_rows(&original, &candidate)
143}
144
145fn external_rows(bytes: &[u8], input: &'static str) -> Result<ExternalRows, ExternalLockError> {
146    let text =
147        std::str::from_utf8(bytes).map_err(|source| ExternalLockError::Utf8 { input, source })?;
148    let lock: toml::Value =
149        toml::from_str(text).map_err(|source| ExternalLockError::Parse { input, source })?;
150    let mut rows = BTreeMap::new();
151    for (index, package) in lock
152        .get("package")
153        .and_then(toml::Value::as_array)
154        .ok_or(ExternalLockError::MissingPackages { input })?
155        .iter()
156        .enumerate()
157    {
158        let invalid = |reason| ExternalLockError::InvalidPackage {
159            input,
160            index,
161            reason,
162        };
163        // Local identity admission stays with each consumer. Non-table records
164        // cannot be Cargo packages, regardless of whether they expose a source.
165        let external = external_package(package).map_err(invalid)?;
166        let Some(table) = external else { continue };
167        let text = |key| required_string(table, key).map_err(invalid);
168        let source = text("source")?;
169        let checksum = optional_string(table, "checksum").map_err(invalid)?;
170        if source.starts_with("registry+") {
171            checksum
172                .ok_or_else(|| invalid("missing lock checksum"))?
173                .parse::<Sha256Digest>()
174                .map_err(|_| invalid("invalid package checksum"))?;
175        }
176        let name = text("name")?;
177        let version = text("version")?;
178        if rows
179            .insert(
180                (name.into(), version.into(), source.into()),
181                checksum.map(str::to_owned),
182            )
183            .is_some()
184        {
185            return Err(ExternalLockError::DuplicateSelection {
186                input,
187                package: name.into(),
188                version: version.into(),
189            });
190        }
191    }
192    Ok(rows)
193}
194
195fn verify_rows(original: &ExternalRows, candidate: &ExternalRows) -> Result<(), ExternalLockError> {
196    for (key @ (package, version, _), checksum) in candidate {
197        if original.get(key) != Some(checksum) {
198            return Err(ExternalLockError::SelectionChanged {
199                package: package.clone(),
200                version: version.clone(),
201            });
202        }
203    }
204    Ok(())
205}
206
207#[cfg(any(unix, test))]
208pub(super) fn admit_lock(entry: &[u8], payload: &[u8]) -> Result<(), Box<dyn Error>> {
209    let entry = external_rows(entry, "original")?;
210    let payload = external_rows(payload, "candidate")?;
211    if payload.is_empty() {
212        return Err("empty package external graph".into());
213    }
214    verify_rows(&entry, &payload)?;
215    Ok(())
216}
217
218#[cfg(test)]
219mod tests;