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