1use super::observation::schema::{Metadata, Package};
7use ic_host_artifacts::artifact::Sha256Digest;
8use ic_host_process::tool::{CompletedExecution, OutputLimits, ToolError, capture_command};
9mod external_lock;
10mod source_inventory;
11use external_lock::admit_lock;
12pub use external_lock::{ExternalLockError, verify_external_lock_subset};
13#[cfg(test)]
14use serde_json::{Value, json};
15use source_inventory::sources;
16use std::{
17 collections::{BTreeMap, BTreeSet},
18 error::Error,
19 fmt::{self, Write as _},
20 fs, io,
21 path::{Path, PathBuf},
22 process::Command,
23};
24
25type Cause = Box<dyn Error>;
26
27pub struct PackageArchiveSpec<'a> {
29 pub name: &'a str,
31 pub version: &'a str,
33 pub patch_registry: bool,
35}
36
37#[derive(Clone, Copy)]
39pub struct PackageQualificationLimits {
40 pub process: OutputLimits,
42 pub file_bytes: usize,
44 pub source_entries: usize,
46 pub source_depth: usize,
48}
49
50#[derive(Debug)]
52pub struct PackageCommandError {
53 pub execution: Option<ToolError>,
55 pub retention: Vec<io::Error>,
57}
58impl fmt::Display for PackageCommandError {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 if let Some(error) = &self.execution {
61 error.fmt(f)
62 } else {
63 f.write_str("package command diagnostic retention failed")
64 }
65 }
66}
67impl Error for PackageCommandError {
68 fn source(&self) -> Option<&(dyn Error + 'static)> {
69 self.execution
70 .as_ref()
71 .map(|error| error as &dyn Error)
72 .or_else(|| self.retention.first().map(|error| error as &dyn Error))
73 }
74}
75
76#[derive(Debug)]
78pub struct PackageQualificationError {
79 pub evidence: PathBuf,
81 pub cause: Cause,
83}
84impl fmt::Display for PackageQualificationError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 self.cause.fmt(f)
87 }
88}
89impl Error for PackageQualificationError {
90 fn source(&self) -> Option<&(dyn Error + 'static)> {
91 Some(self.cause.as_ref())
92 }
93}
94
95pub fn qualify_package_archives(
119 workspace: &Path,
120 archive_root: &Path,
121 evidence: &Path,
122 packages: &[PackageArchiveSpec<'_>],
123 limits: PackageQualificationLimits,
124 mut cargo: impl FnMut() -> Command,
125 mut extractor: impl FnMut(&Path, &Path) -> Command,
126) -> Result<(), PackageQualificationError> {
127 qualify(
128 workspace,
129 archive_root,
130 evidence,
131 packages,
132 limits,
133 &mut cargo,
134 &mut extractor,
135 )
136 .map_err(|cause| PackageQualificationError {
137 evidence: evidence.to_owned(),
138 cause,
139 })
140}
141
142#[expect(
143 clippy::too_many_lines,
144 reason = "ordered archive qualification retains one before/after custody boundary"
145)]
146fn qualify(
147 workspace: &Path,
148 archive_root: &Path,
149 evidence: &Path,
150 packages: &[PackageArchiveSpec<'_>],
151 limits: PackageQualificationLimits,
152 cargo: &mut impl FnMut() -> Command,
153 extractor: &mut impl FnMut(&Path, &Path) -> Command,
154) -> Result<(), Cause> {
155 if packages.is_empty() || limits.file_bytes == 0 || limits.source_entries == 0 {
156 return Err("empty package selection or admission budget".into());
157 }
158 let mut names = BTreeSet::new();
159 for package in packages {
160 if !identifier(package.name) || !identifier(package.version) || !names.insert(package.name)
161 {
162 return Err("invalid or duplicate package archive identity".into());
163 }
164 }
165 let workspace = workspace.canonicalize()?;
166 let archive_root = archive_root.canonicalize()?;
167 let evidence = evidence.canonicalize()?;
168 for path in [&workspace, &archive_root, &evidence] {
169 if path
170 .as_os_str()
171 .as_encoded_bytes()
172 .iter()
173 .any(|b| matches!(b, b'\r' | b'\n'))
174 {
175 return Err("invalid directory namespace".into());
176 }
177 }
178 if fs::read_dir(&evidence)?.next().is_some() {
179 return Err("evidence directory is not empty".into());
180 }
181 let lock = workspace.join("Cargo.lock");
182 let entry_lock = read(&lock, limits.file_bytes)?;
183 fs::write(evidence.join("entry-Cargo.lock"), &entry_lock)?;
184 let mut command = cargo();
185 command.current_dir(&workspace).args([
186 "metadata",
187 "--offline",
188 "--locked",
189 "--no-deps",
190 "--format-version",
191 "1",
192 ]);
193 let metadata = execute(&mut command, &evidence, "entry-metadata", limits.process)?;
194 let metadata = serde_json::from_slice(&metadata)?;
195 let mut originals = BTreeMap::new();
196 originals.insert(
197 workspace.join("Cargo.toml"),
198 read(&workspace.join("Cargo.toml"), limits.file_bytes)?,
199 );
200 for spec in packages {
201 let selected = package(&metadata, spec.name)?;
202 if selected.version.to_string() != spec.version {
203 return Err("entry package version differs from selection".into());
204 }
205 let path = selected.manifest_path.clone().into_std_path_buf();
206 originals.insert(path.clone(), read(&path, limits.file_bytes)?);
207 }
208 for (index, bytes) in originals.values().enumerate() {
209 fs::write(evidence.join(format!("entry-manifest-{index}")), bytes)?;
210 }
211 let mut command = cargo();
212 command.current_dir(&workspace).args([
213 "package",
214 "--offline",
215 "--locked",
216 "--allow-dirty",
217 "--no-verify",
218 ]);
219 for spec in packages {
220 command.args(["-p", spec.name]);
221 }
222 execute(&mut command, &evidence, "package", limits.process)?;
223 let mut archives = BTreeMap::new();
224 for (index, spec) in packages.iter().enumerate() {
225 let path = archive_root.join(format!("{}-{}.crate", spec.name, spec.version));
226 archives.insert(path.clone(), hash(&path, limits.file_bytes)?);
227 execute(
228 &mut extractor(&path, &evidence),
229 &evidence,
230 &format!("extract-{index}"),
231 limits.process,
232 )?;
233 }
234 let mut archive_hashes = String::new();
235 for digest in archives.values() {
236 writeln!(archive_hashes, "{digest}")?;
237 }
238 fs::write(evidence.join("archive.sha256"), archive_hashes)?;
239 let sources = sources(&evidence, packages, limits)?;
240 fs::write(
241 evidence.join("entry-sources.json"),
242 serde_json::to_vec(&sources)?,
243 )?;
244 let members = packages
245 .iter()
246 .map(|p| format!("{}-{}", p.name, p.version))
247 .collect::<Vec<_>>();
248 let mut manifest = format!(
249 "[workspace]\nresolver = \"3\"\nmembers = {}\n[patch.crates-io]\n",
250 serde_json::to_string(&members)?
251 );
252 for spec in packages.iter().filter(|p| p.patch_registry) {
253 writeln!(
254 manifest,
255 "{} = {{ path = \"{}-{}\" }}",
256 spec.name, spec.name, spec.version
257 )?;
258 }
259 let manifest_path = evidence.join("Cargo.toml");
260 let payload_lock = evidence.join("Cargo.lock");
261 fs::write(&manifest_path, &manifest)?;
262 fs::write(&payload_lock, &entry_lock)?;
263 let mut command = cargo();
264 command
265 .current_dir(&evidence)
266 .env("CARGO_TARGET_DIR", evidence.join("target"))
267 .args([
268 "metadata",
269 "--offline",
270 "--format-version",
271 "1",
272 "--manifest-path",
273 ])
274 .arg(&manifest_path);
275 let metadata = execute(&mut command, &evidence, "metadata", limits.process)?;
276 admit_graph(&serde_json::from_slice(&metadata)?, &evidence, packages)?;
277 let admitted_lock = read(&payload_lock, limits.file_bytes)?;
278 admit_lock(&entry_lock, &admitted_lock)?;
279 fs::write(evidence.join("admitted-Cargo.toml"), &manifest)?;
280 fs::write(evidence.join("admitted-Cargo.lock"), &admitted_lock)?;
281 for (label, selection) in [("test-targets", "--all-targets"), ("test-docs", "--doc")] {
282 let mut command = cargo();
283 command
284 .current_dir(&evidence)
285 .env("CARGO_TARGET_DIR", evidence.join("target"))
286 .args(["test", "--offline", "--locked", "--manifest-path"])
287 .arg(&manifest_path)
288 .args(["--workspace", selection]);
289 execute(&mut command, &evidence, label, limits.process)?;
290 }
291 if read(&lock, limits.file_bytes)? != entry_lock {
292 return Err("original lock changed".into());
293 }
294 for (path, bytes) in originals {
295 if read(&path, limits.file_bytes)? != bytes {
296 return Err("original manifest changed".into());
297 }
298 }
299 for (path, digest) in archives {
300 if hash(&path, limits.file_bytes)? != digest {
301 return Err("package archive changed".into());
302 }
303 }
304 let final_sources = self::sources(&evidence, packages, limits)?;
305 fs::write(
306 evidence.join("final-sources.json"),
307 serde_json::to_vec(&final_sources)?,
308 )?;
309 if final_sources != sources {
310 return Err("extracted package sources changed".into());
311 }
312 if read(&manifest_path, limits.file_bytes)? != manifest.as_bytes()
313 || read(&payload_lock, limits.file_bytes)? != admitted_lock
314 {
315 return Err("admitted verification manifest or lock changed".into());
316 }
317 Ok(())
318}
319
320fn identifier(value: &str) -> bool {
321 !value.is_empty()
322 && value
323 .bytes()
324 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
325}
326
327fn read(path: &Path, limit: usize) -> Result<Vec<u8>, Cause> {
328 Ok(ic_host_fs::read::read_file_no_follow(path, limit)?)
329}
330fn hash(path: &Path, limit: usize) -> Result<Sha256Digest, Cause> {
331 Ok(ic_host_fs::read::hash_file_no_follow(path, u64::try_from(limit)?)?.sha256)
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 mut matching = metadata.packages.iter().filter(|p| p.name == name);
366 match (matching.next(), matching.next()) {
367 (Some(package), None) => Ok(package),
368 _ => Err(format!("expected one extracted package: {name}").into()),
369 }
370}
371
372fn admit_graph(
373 metadata: &Metadata,
374 root: &Path,
375 packages: &[PackageArchiveSpec<'_>],
376) -> Result<(), Cause> {
377 let members = &metadata.workspace_members;
378 let mut selected = BTreeSet::new();
379 for spec in packages {
380 let package = package(metadata, spec.name)?;
381 let manifest = root
382 .join(format!("{}-{}/Cargo.toml", spec.name, spec.version))
383 .canonicalize()?;
384 if package.version.to_string() != spec.version
385 || package.source.is_some()
386 || package.manifest_path.as_std_path() != manifest
387 || !members.contains(&package.id)
388 {
389 return Err(format!(
390 "resolved package is not the exact extracted payload: {}",
391 spec.name
392 )
393 .into());
394 }
395 selected.insert(&package.id);
396 }
397 if members.len() != packages.len() || selected.len() != members.len() {
398 return Err("unexpected extracted workspace member".into());
399 }
400 Ok(())
401}
402
403#[cfg(test)]
404mod tests;