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