1use super::external_lock::admit_lock;
4use super::source_inventory::sources;
5use crate::cargo::observation::schema::{Metadata, Package};
6use ic_host_artifacts::artifact::Sha256Digest;
7use ic_host_process::tool::{CompletedExecution, capture_command};
8use ic_host_process::tool::{OutputLimits, ToolError};
9#[cfg(test)]
10use serde_json::{Value, json};
11use std::{
12 collections::{BTreeMap, BTreeSet},
13 fmt::Write as _,
14 fs,
15 path::Path,
16 process::Command,
17};
18use std::{error::Error, fmt, io, path::PathBuf};
19
20pub(super) type Cause = Box<dyn Error>;
21
22pub struct PackageArchiveSpec<'a> {
24 pub name: &'a str,
26 pub version: &'a str,
28 pub patch_registry: bool,
30}
31
32#[derive(Clone, Copy)]
34pub struct PackageQualificationLimits {
35 pub process: OutputLimits,
37 pub file_bytes: usize,
39 pub source_entries: usize,
41 pub source_depth: usize,
43}
44
45#[derive(Debug)]
47pub struct PackageCommandError {
48 pub execution: Option<ToolError>,
50 pub retention: Vec<io::Error>,
52}
53impl fmt::Display for PackageCommandError {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 if let Some(error) = &self.execution {
56 error.fmt(f)
57 } else {
58 f.write_str("package command diagnostic retention failed")
59 }
60 }
61}
62impl Error for PackageCommandError {
63 fn source(&self) -> Option<&(dyn Error + 'static)> {
64 self.execution
65 .as_ref()
66 .map(|error| error as &dyn Error)
67 .or_else(|| self.retention.first().map(|error| error as &dyn Error))
68 }
69}
70
71#[derive(Debug)]
73pub struct PackageQualificationError {
74 pub evidence: PathBuf,
76 pub cause: Cause,
78}
79impl fmt::Display for PackageQualificationError {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 self.cause.fmt(f)
82 }
83}
84impl Error for PackageQualificationError {
85 fn source(&self) -> Option<&(dyn Error + 'static)> {
86 Some(self.cause.as_ref())
87 }
88}
89
90pub fn qualify_package_archives(
116 workspace: &Path,
117 archive_root: &Path,
118 evidence: &Path,
119 packages: &[PackageArchiveSpec<'_>],
120 limits: PackageQualificationLimits,
121 mut cargo: impl FnMut() -> Command,
122 mut extractor: impl FnMut(&Path, &Path) -> Command,
123) -> Result<(), PackageQualificationError> {
124 qualify(
125 workspace,
126 archive_root,
127 evidence,
128 packages,
129 limits,
130 &mut cargo,
131 &mut extractor,
132 )
133 .map_err(|cause| PackageQualificationError {
134 evidence: evidence.to_owned(),
135 cause,
136 })
137}
138
139#[expect(
140 clippy::too_many_lines,
141 reason = "ordered archive qualification retains one before/after custody boundary"
142)]
143fn qualify(
144 workspace: &Path,
145 archive_root: &Path,
146 evidence: &Path,
147 packages: &[PackageArchiveSpec<'_>],
148 limits: PackageQualificationLimits,
149 cargo: &mut impl FnMut() -> Command,
150 extractor: &mut impl FnMut(&Path, &Path) -> Command,
151) -> Result<(), Cause> {
152 if packages.is_empty() || limits.file_bytes == 0 || limits.source_entries == 0 {
153 return Err("empty package selection or admission budget".into());
154 }
155 let mut names = BTreeSet::new();
156 for package in packages {
157 if !identifier(package.name) || !identifier(package.version) || !names.insert(package.name)
158 {
159 return Err("invalid or duplicate package archive identity".into());
160 }
161 }
162 let workspace = workspace.canonicalize()?;
163 let archive_root = archive_root.canonicalize()?;
164 let evidence = evidence.canonicalize()?;
165 for path in [&workspace, &archive_root, &evidence] {
166 if path
167 .as_os_str()
168 .as_encoded_bytes()
169 .iter()
170 .any(|b| matches!(b, b'\r' | b'\n'))
171 {
172 return Err("invalid directory namespace".into());
173 }
174 }
175 if fs::read_dir(&evidence)?.next().is_some() {
176 return Err("evidence directory is not empty".into());
177 }
178 let lock = workspace.join("Cargo.lock");
179 let entry_lock = read(&lock, limits.file_bytes)?;
180 fs::write(evidence.join("entry-Cargo.lock"), &entry_lock)?;
181 let mut command = cargo();
182 command.current_dir(&workspace).args([
183 "metadata",
184 "--offline",
185 "--locked",
186 "--no-deps",
187 "--format-version",
188 "1",
189 ]);
190 let metadata = execute(&mut command, &evidence, "entry-metadata", limits.process)?;
191 let metadata = serde_json::from_slice(&metadata)?;
192 let mut originals = BTreeMap::new();
193 originals.insert(
194 workspace.join("Cargo.toml"),
195 read(&workspace.join("Cargo.toml"), limits.file_bytes)?,
196 );
197 for spec in packages {
198 let selected = package(&metadata, spec.name)?;
199 if selected.version.to_string() != spec.version {
200 return Err("entry package version differs from selection".into());
201 }
202 let path = selected.manifest_path.clone().into_std_path_buf();
203 originals.insert(path.clone(), read(&path, limits.file_bytes)?);
204 }
205 for (index, bytes) in originals.values().enumerate() {
206 fs::write(evidence.join(format!("entry-manifest-{index}")), bytes)?;
207 }
208 let mut command = cargo();
209 command.current_dir(&workspace).args([
210 "package",
211 "--offline",
212 "--locked",
213 "--allow-dirty",
214 "--no-verify",
215 ]);
216 for spec in packages {
217 command.args(["-p", spec.name]);
218 }
219 execute(&mut command, &evidence, "package", limits.process)?;
220 let mut archives = BTreeMap::new();
221 for (index, spec) in packages.iter().enumerate() {
222 let path = archive_root.join(format!("{}-{}.crate", spec.name, spec.version));
223 archives.insert(path.clone(), hash(&path, limits.file_bytes)?);
224 execute(
225 &mut extractor(&path, &evidence),
226 &evidence,
227 &format!("extract-{index}"),
228 limits.process,
229 )?;
230 }
231 let mut archive_hashes = String::new();
232 for digest in archives.values() {
233 writeln!(archive_hashes, "{digest}")?;
234 }
235 fs::write(evidence.join("archive.sha256"), archive_hashes)?;
236 let sources = sources(&evidence, packages, limits)?;
237 fs::write(
238 evidence.join("entry-sources.json"),
239 serde_json::to_vec(&sources)?,
240 )?;
241 let manifest = verification_manifest(packages)?;
242 let manifest_path = evidence.join("Cargo.toml");
243 let payload_lock = evidence.join("Cargo.lock");
244 fs::write(&manifest_path, &manifest)?;
245 fs::write(&payload_lock, &entry_lock)?;
246 let mut command = cargo();
247 command
248 .current_dir(&evidence)
249 .env("CARGO_TARGET_DIR", evidence.join("target"))
250 .args([
251 "metadata",
252 "--offline",
253 "--format-version",
254 "1",
255 "--manifest-path",
256 ])
257 .arg(&manifest_path);
258 let metadata = execute(&mut command, &evidence, "metadata", limits.process)?;
259 admit_graph(&serde_json::from_slice(&metadata)?, &evidence, packages)?;
260 let admitted_lock = read(&payload_lock, limits.file_bytes)?;
261 admit_lock(&entry_lock, &admitted_lock)?;
262 fs::write(evidence.join("admitted-Cargo.toml"), &manifest)?;
263 fs::write(evidence.join("admitted-Cargo.lock"), &admitted_lock)?;
264 for (label, selection) in [("test-targets", "--all-targets"), ("test-docs", "--doc")] {
265 let mut command = cargo();
266 command
267 .current_dir(&evidence)
268 .env("CARGO_TARGET_DIR", evidence.join("target"))
269 .args(["test", "--offline", "--locked", "--manifest-path"])
270 .arg(&manifest_path)
271 .args(["--workspace", selection]);
272 execute(&mut command, &evidence, label, limits.process)?;
273 }
274 if read(&lock, limits.file_bytes)? != entry_lock {
275 return Err("original lock changed".into());
276 }
277 for (path, bytes) in originals {
278 if read(&path, limits.file_bytes)? != bytes {
279 return Err("original manifest changed".into());
280 }
281 }
282 for (path, digest) in archives {
283 if hash(&path, limits.file_bytes)? != digest {
284 return Err("package archive changed".into());
285 }
286 }
287 let final_sources = self::sources(&evidence, packages, limits)?;
288 fs::write(
289 evidence.join("final-sources.json"),
290 serde_json::to_vec(&final_sources)?,
291 )?;
292 if final_sources != sources {
293 return Err("extracted package sources changed".into());
294 }
295 if read(&manifest_path, limits.file_bytes)? != manifest.as_bytes()
296 || read(&payload_lock, limits.file_bytes)? != admitted_lock
297 {
298 return Err("admitted verification manifest or lock changed".into());
299 }
300 Ok(())
301}
302
303fn verification_manifest(packages: &[PackageArchiveSpec<'_>]) -> Result<String, toml::ser::Error> {
304 let members = packages
305 .iter()
306 .map(|p| format!("{}-{}", p.name, p.version))
307 .collect::<Vec<_>>();
308 let patches: toml::Table = packages
309 .iter()
310 .filter(|p| p.patch_registry)
311 .map(|p| {
312 let entry = toml::toml! { path = (format!("{}-{}", p.name, p.version)) };
313 (p.name.to_owned(), toml::Value::Table(entry))
314 })
315 .collect();
316 toml::to_string(&toml::toml! {
317 [workspace]
318 resolver = "3"
319 members = (members)
320 [patch]
321 crates-io = (patches)
322 })
323}
324
325fn identifier(value: &str) -> bool {
326 !value.is_empty()
327 && value
328 .bytes()
329 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
330}
331
332fn read(path: &Path, limit: usize) -> Result<Vec<u8>, Cause> {
333 Ok(ic_host_fs::read::read_file_no_follow(path, limit)?)
334}
335fn hash(path: &Path, limit: usize) -> Result<Sha256Digest, Cause> {
336 Ok(ic_host_fs::read::hash_file_no_follow(path, u64::try_from(limit)?)?.sha256)
337}
338
339fn execute(
340 command: &mut Command,
341 evidence: &Path,
342 label: &str,
343 limits: OutputLimits,
344) -> Result<Vec<u8>, Cause> {
345 let result = capture_command(command, limits)
346 .and_then(CompletedExecution::require_success)
347 .and_then(CompletedExecution::require_complete);
348 let (stdout, stderr) = match &result {
349 Ok(output) => (&output.stdout, &output.stderr),
350 Err(ToolError::Execution(error)) => (&error.evidence.stdout, &error.evidence.stderr),
351 Err(_) => (&Vec::new(), &Vec::new()),
352 };
353 let retention = [
354 fs::write(evidence.join(format!("{label}.json")), stdout),
355 fs::write(evidence.join(format!("{label}.stderr")), stderr),
356 ]
357 .into_iter()
358 .filter_map(Result::err)
359 .collect::<Vec<_>>();
360 match result {
361 Ok(output) if retention.is_empty() => Ok(output.stdout),
362 other => Err(Box::new(PackageCommandError {
363 execution: other.err(),
364 retention,
365 })),
366 }
367}
368
369fn package<'a>(metadata: &'a Metadata, name: &str) -> Result<&'a Package, Cause> {
370 let mut matching = metadata.packages.iter().filter(|p| p.name == name);
371 match (matching.next(), matching.next()) {
372 (Some(package), None) => Ok(package),
373 _ => Err(format!("expected one extracted package: {name}").into()),
374 }
375}
376
377fn admit_graph(
378 metadata: &Metadata,
379 root: &Path,
380 packages: &[PackageArchiveSpec<'_>],
381) -> Result<(), Cause> {
382 let members = &metadata.workspace_members;
383 let mut selected = BTreeSet::new();
384 for spec in packages {
385 let package = package(metadata, spec.name)?;
386 let manifest = root
387 .join(format!("{}-{}/Cargo.toml", spec.name, spec.version))
388 .canonicalize()?;
389 if package.version.to_string() != spec.version
390 || package.source.is_some()
391 || package.manifest_path.as_std_path() != manifest
392 || !members.contains(&package.id)
393 {
394 return Err(format!(
395 "resolved package is not the exact extracted payload: {}",
396 spec.name
397 )
398 .into());
399 }
400 selected.insert(&package.id);
401 }
402 if members.len() != packages.len() || selected.len() != members.len() {
403 return Err("unexpected extracted workspace member".into());
404 }
405 Ok(())
406}
407
408#[cfg(test)]
409#[path = "tests/mod.rs"]
410mod tests;