1use ic_host_process::tool::{CompletedExecution, OutputLimits, ToolError, capture_command};
6use serde_json::{Value, json};
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 error::Error,
10 fmt::{self, Write as _},
11 fs, io,
12 path::{Path, PathBuf},
13 process::Command,
14};
15
16type Cause = Box<dyn Error>;
17
18pub struct PackageArchiveSpec<'a> {
20 pub name: &'a str,
22 pub version: &'a str,
24 pub patch_registry: bool,
26}
27
28#[derive(Clone, Copy)]
30pub struct PackageQualificationLimits {
31 pub process: OutputLimits,
33 pub file_bytes: usize,
35 pub source_entries: usize,
37 pub source_depth: usize,
39}
40
41#[derive(Debug)]
43pub struct PackageCommandError {
44 pub execution: Option<ToolError>,
46 pub retention: Vec<io::Error>,
48}
49impl fmt::Display for PackageCommandError {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 if let Some(error) = &self.execution {
52 error.fmt(f)
53 } else {
54 f.write_str("package command diagnostic retention failed")
55 }
56 }
57}
58impl Error for PackageCommandError {
59 fn source(&self) -> Option<&(dyn Error + 'static)> {
60 self.execution
61 .as_ref()
62 .map(|error| error as &dyn Error)
63 .or_else(|| self.retention.first().map(|error| error as &dyn Error))
64 }
65}
66
67#[derive(Debug)]
69pub struct PackageQualificationError {
70 pub evidence: PathBuf,
72 pub cause: Cause,
74}
75impl fmt::Display for PackageQualificationError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 self.cause.fmt(f)
78 }
79}
80impl Error for PackageQualificationError {
81 fn source(&self) -> Option<&(dyn Error + 'static)> {
82 Some(self.cause.as_ref())
83 }
84}
85
86pub fn qualify_package_archives(
110 workspace: &Path,
111 archive_root: &Path,
112 evidence: &Path,
113 packages: &[PackageArchiveSpec<'_>],
114 limits: PackageQualificationLimits,
115 mut cargo: impl FnMut() -> Command,
116 mut extractor: impl FnMut(&Path, &Path) -> Command,
117) -> Result<(), PackageQualificationError> {
118 qualify(
119 workspace,
120 archive_root,
121 evidence,
122 packages,
123 limits,
124 &mut cargo,
125 &mut extractor,
126 )
127 .map_err(|cause| PackageQualificationError {
128 evidence: evidence.to_owned(),
129 cause,
130 })
131}
132
133#[expect(
134 clippy::too_many_lines,
135 reason = "ordered archive qualification retains one before/after custody boundary"
136)]
137fn qualify(
138 workspace: &Path,
139 archive_root: &Path,
140 evidence: &Path,
141 packages: &[PackageArchiveSpec<'_>],
142 limits: PackageQualificationLimits,
143 cargo: &mut impl FnMut() -> Command,
144 extractor: &mut impl FnMut(&Path, &Path) -> Command,
145) -> Result<(), Cause> {
146 if packages.is_empty() || limits.file_bytes == 0 || limits.source_entries == 0 {
147 return Err("empty package selection or admission budget".into());
148 }
149 let mut names = BTreeSet::new();
150 for package in packages {
151 if !identifier(package.name) || !identifier(package.version) || !names.insert(package.name)
152 {
153 return Err("invalid or duplicate package archive identity".into());
154 }
155 }
156 let workspace = workspace.canonicalize()?;
157 let archive_root = archive_root.canonicalize()?;
158 let evidence = evidence.canonicalize()?;
159 for path in [&workspace, &archive_root, &evidence] {
160 if path
161 .as_os_str()
162 .as_encoded_bytes()
163 .iter()
164 .any(|b| matches!(b, b'\r' | b'\n'))
165 {
166 return Err("invalid directory namespace".into());
167 }
168 }
169 if fs::read_dir(&evidence)?.next().is_some() {
170 return Err("evidence directory is not empty".into());
171 }
172 let lock = workspace.join("Cargo.lock");
173 let entry_lock = read(&lock, limits.file_bytes)?;
174 fs::write(evidence.join("entry-Cargo.lock"), &entry_lock)?;
175 let mut command = cargo();
176 command.current_dir(&workspace).args([
177 "metadata",
178 "--offline",
179 "--locked",
180 "--no-deps",
181 "--format-version",
182 "1",
183 ]);
184 let metadata = execute(&mut command, &evidence, "entry-metadata", limits.process)?;
185 let metadata: Value = serde_json::from_slice(&metadata)?;
186 let mut originals = BTreeMap::new();
187 originals.insert(
188 workspace.join("Cargo.toml"),
189 read(&workspace.join("Cargo.toml"), limits.file_bytes)?,
190 );
191 for spec in packages {
192 let selected = package(&metadata, spec.name)?;
193 if selected["version"] != spec.version {
194 return Err("entry package version differs from selection".into());
195 }
196 let path = PathBuf::from(
197 selected["manifest_path"]
198 .as_str()
199 .ok_or("missing entry manifest")?,
200 );
201 originals.insert(path.clone(), read(&path, limits.file_bytes)?);
202 }
203 for (index, bytes) in originals.values().enumerate() {
204 fs::write(evidence.join(format!("entry-manifest-{index}")), bytes)?;
205 }
206 let mut command = cargo();
207 command.current_dir(&workspace).args([
208 "package",
209 "--offline",
210 "--locked",
211 "--allow-dirty",
212 "--no-verify",
213 ]);
214 for spec in packages {
215 command.args(["-p", spec.name]);
216 }
217 execute(&mut command, &evidence, "package", limits.process)?;
218 let mut archives = BTreeMap::new();
219 for (index, spec) in packages.iter().enumerate() {
220 let path = archive_root.join(format!("{}-{}.crate", spec.name, spec.version));
221 archives.insert(path.clone(), hash(&path, limits.file_bytes)?);
222 execute(
223 &mut extractor(&path, &evidence),
224 &evidence,
225 &format!("extract-{index}"),
226 limits.process,
227 )?;
228 }
229 let mut archive_hashes = String::new();
230 for digest in archives.values() {
231 writeln!(archive_hashes, "{digest}")?;
232 }
233 fs::write(evidence.join("archive.sha256"), archive_hashes)?;
234 let sources = sources(&evidence, packages, limits)?;
235 fs::write(
236 evidence.join("entry-sources.json"),
237 serde_json::to_vec(&sources)?,
238 )?;
239 let members = packages
240 .iter()
241 .map(|p| format!("{}-{}", p.name, p.version))
242 .collect::<Vec<_>>();
243 let mut manifest = format!(
244 "[workspace]\nresolver = \"3\"\nmembers = {}\n[patch.crates-io]\n",
245 serde_json::to_string(&members)?
246 );
247 for spec in packages.iter().filter(|p| p.patch_registry) {
248 writeln!(
249 manifest,
250 "{} = {{ path = \"{}-{}\" }}",
251 spec.name, spec.name, spec.version
252 )?;
253 }
254 let manifest_path = evidence.join("Cargo.toml");
255 let payload_lock = evidence.join("Cargo.lock");
256 fs::write(&manifest_path, &manifest)?;
257 fs::write(&payload_lock, &entry_lock)?;
258 let mut command = cargo();
259 command
260 .current_dir(&evidence)
261 .env("CARGO_TARGET_DIR", evidence.join("target"))
262 .args([
263 "metadata",
264 "--offline",
265 "--format-version",
266 "1",
267 "--manifest-path",
268 ])
269 .arg(&manifest_path);
270 let metadata = execute(&mut command, &evidence, "metadata", limits.process)?;
271 admit_graph(&serde_json::from_slice(&metadata)?, &evidence, packages)?;
272 let admitted_lock = read(&payload_lock, limits.file_bytes)?;
273 admit_lock(&entry_lock, &admitted_lock)?;
274 fs::write(evidence.join("admitted-Cargo.toml"), &manifest)?;
275 fs::write(evidence.join("admitted-Cargo.lock"), &admitted_lock)?;
276 for (label, selection) in [("test-targets", "--all-targets"), ("test-docs", "--doc")] {
277 let mut command = cargo();
278 command
279 .current_dir(&evidence)
280 .env("CARGO_TARGET_DIR", evidence.join("target"))
281 .args(["test", "--offline", "--locked", "--manifest-path"])
282 .arg(&manifest_path)
283 .args(["--workspace", selection]);
284 execute(&mut command, &evidence, label, limits.process)?;
285 }
286 if read(&lock, limits.file_bytes)? != entry_lock {
287 return Err("original lock changed".into());
288 }
289 for (path, bytes) in originals {
290 if read(&path, limits.file_bytes)? != bytes {
291 return Err("original manifest changed".into());
292 }
293 }
294 for (path, digest) in archives {
295 if hash(&path, limits.file_bytes)? != digest {
296 return Err("package archive changed".into());
297 }
298 }
299 let final_sources = self::sources(&evidence, packages, limits)?;
300 fs::write(
301 evidence.join("final-sources.json"),
302 serde_json::to_vec(&final_sources)?,
303 )?;
304 if final_sources != sources {
305 return Err("extracted package sources changed".into());
306 }
307 if read(&manifest_path, limits.file_bytes)? != manifest.as_bytes()
308 || read(&payload_lock, limits.file_bytes)? != admitted_lock
309 {
310 return Err("admitted verification manifest or lock changed".into());
311 }
312 Ok(())
313}
314
315fn identifier(value: &str) -> bool {
316 !value.is_empty()
317 && value
318 .bytes()
319 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
320}
321
322fn read(path: &Path, limit: usize) -> Result<Vec<u8>, Cause> {
323 Ok(ic_host_fs::read::read_file_no_follow(path, limit)?)
324}
325fn hash(path: &Path, limit: usize) -> Result<String, Cause> {
326 Ok(
327 ic_host_fs::read::hash_file_no_follow(path, u64::try_from(limit)?)?
328 .sha256
329 .to_string(),
330 )
331}
332
333fn execute(
334 command: &mut Command,
335 evidence: &Path,
336 label: &str,
337 limits: OutputLimits,
338) -> Result<Vec<u8>, Cause> {
339 let result = capture_command(command, limits)
340 .and_then(CompletedExecution::require_success)
341 .and_then(CompletedExecution::require_complete);
342 let (stdout, stderr) = match &result {
343 Ok(output) => (&output.stdout, &output.stderr),
344 Err(ToolError::Execution(error)) => (&error.evidence.stdout, &error.evidence.stderr),
345 Err(_) => (&Vec::new(), &Vec::new()),
346 };
347 let retention = [
348 fs::write(evidence.join(format!("{label}.json")), stdout),
349 fs::write(evidence.join(format!("{label}.stderr")), stderr),
350 ]
351 .into_iter()
352 .filter_map(Result::err)
353 .collect::<Vec<_>>();
354 match result {
355 Ok(output) if retention.is_empty() => Ok(output.stdout),
356 other => Err(Box::new(PackageCommandError {
357 execution: other.err(),
358 retention,
359 })),
360 }
361}
362
363fn package<'a>(metadata: &'a Value, name: &str) -> Result<&'a Value, Cause> {
364 let selected = metadata["packages"]
365 .as_array()
366 .ok_or("missing metadata packages")?
367 .iter()
368 .filter(|p| p["name"] == name)
369 .collect::<Vec<_>>();
370 if selected.len() != 1 {
371 return Err(format!("expected one extracted package: {name}").into());
372 }
373 Ok(selected[0])
374}
375
376fn admit_graph(
377 metadata: &Value,
378 root: &Path,
379 packages: &[PackageArchiveSpec<'_>],
380) -> Result<(), Cause> {
381 let members = metadata["workspace_members"]
382 .as_array()
383 .ok_or("missing workspace members")?;
384 let mut selected = BTreeSet::new();
385 for spec in packages {
386 let package = package(metadata, spec.name)?;
387 let manifest = root
388 .join(format!("{}-{}/Cargo.toml", spec.name, spec.version))
389 .canonicalize()?;
390 if package["version"] != spec.version
391 || !package["source"].is_null()
392 || package["manifest_path"].as_str().map(Path::new) != Some(manifest.as_path())
393 || !members.contains(&package["id"])
394 {
395 return Err(format!(
396 "resolved package is not the exact extracted payload: {}",
397 spec.name
398 )
399 .into());
400 }
401 selected.insert(package["id"].as_str().ok_or("missing package identity")?);
402 }
403 if members.len() != packages.len() || selected.len() != members.len() {
404 return Err("unexpected extracted workspace member".into());
405 }
406 Ok(())
407}
408
409fn external_rows(bytes: &[u8]) -> Result<BTreeMap<(String, String, String), String>, Cause> {
410 let lock: toml::Value = toml::from_str(std::str::from_utf8(bytes)?)?;
411 let mut rows = BTreeMap::new();
412 for package in lock
413 .get("package")
414 .and_then(toml::Value::as_array)
415 .ok_or("missing lock packages")?
416 {
417 let Some(source) = package.get("source") else {
418 continue;
419 };
420 let text = |key| {
421 package
422 .get(key)
423 .and_then(toml::Value::as_str)
424 .ok_or_else(|| format!("missing lock {key}"))
425 };
426 let source = source.as_str().ok_or("invalid lock source")?;
427 let checksum = if source.starts_with("registry+") {
428 let checksum = text("checksum")?;
429 if checksum.len() != 64
430 || !checksum
431 .bytes()
432 .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
433 {
434 return Err("invalid package checksum".into());
435 }
436 checksum
437 } else {
438 ""
439 };
440 if rows
441 .insert(
442 (text("name")?.into(), text("version")?.into(), source.into()),
443 checksum.into(),
444 )
445 .is_some()
446 {
447 return Err("duplicate package selection".into());
448 }
449 }
450 Ok(rows)
451}
452fn admit_lock(entry: &[u8], payload: &[u8]) -> Result<(), Cause> {
453 let entry = external_rows(entry)?;
454 let payload = external_rows(payload)?;
455 if payload.is_empty() {
456 return Err("empty package external graph".into());
457 }
458 if payload
459 .iter()
460 .any(|(key, checksum)| entry.get(key) != Some(checksum))
461 {
462 return Err("packaging changed external selection".into());
463 }
464 Ok(())
465}
466
467fn sources(
468 root: &Path,
469 packages: &[PackageArchiveSpec<'_>],
470 limits: PackageQualificationLimits,
471) -> Result<BTreeMap<PathBuf, Value>, Cause> {
472 let mut files = BTreeMap::new();
473 let mut remaining = limits.source_entries;
474 for spec in packages {
475 inventory(
476 root,
477 &root.join(format!("{}-{}", spec.name, spec.version)),
478 limits,
479 0,
480 &mut remaining,
481 &mut files,
482 )?;
483 }
484 Ok(files)
485}
486fn inventory(
487 root: &Path,
488 path: &Path,
489 limits: PackageQualificationLimits,
490 depth: usize,
491 remaining: &mut usize,
492 files: &mut BTreeMap<PathBuf, Value>,
493) -> Result<(), Cause> {
494 if depth > limits.source_depth || *remaining == 0 {
495 return Err("extracted source traversal budget exceeded".into());
496 }
497 *remaining -= 1;
498 let metadata = fs::symlink_metadata(path)?;
499 if metadata.is_dir() {
500 for entry in fs::read_dir(path)? {
501 inventory(root, &entry?.path(), limits, depth + 1, remaining, files)?;
502 }
503 } else if metadata.is_file() {
504 #[cfg(unix)]
505 let mode = {
506 use std::os::unix::fs::MetadataExt as _;
507 metadata.mode() & 0o777
508 };
509 #[cfg(not(unix))]
510 let mode = metadata.permissions().readonly();
511 files.insert(
512 path.strip_prefix(root)?.to_owned(),
513 json!({"sha256": hash(path, limits.file_bytes)?, "mode": mode}),
514 );
515 } else {
516 return Err(format!("unsupported extracted source entry: {}", path.display()).into());
517 }
518 Ok(())
519}
520
521#[cfg(test)]
522mod tests;