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