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