1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest as _, Sha256};
4use std::collections::{BTreeMap, BTreeSet, VecDeque};
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7use std::process::Command;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11pub const CARGO_LOCK_CANDIDATE_PROTOCOL: &str = "lenso.cargo-lock-candidate.v1";
12static TEMP_SANDBOX_SEQUENCE: AtomicU64 = AtomicU64::new(0);
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CargoLockResolutionRequest {
16 pub read_set: BTreeMap<String, Vec<u8>>,
17 pub candidate_files: BTreeMap<String, Vec<u8>>,
18 pub root_manifest_path: String,
19 pub lock_path: String,
20 pub allowed_root_packages: Vec<String>,
21 pub current_linked_packages: Vec<ExpectedLinkedPackage>,
22 pub expected_linked_packages: Vec<ExpectedLinkedPackage>,
23 pub offline: bool,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ExpectedLinkedPackage {
28 pub package: String,
29 pub version: String,
30 pub archive_checksum: Option<String>,
31 pub default_features: bool,
32 pub features: Vec<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct CargoLockCandidate {
38 pub protocol: String,
39 pub current_lock_digest: String,
40 pub candidate_lock_digest: String,
41 pub changed_packages: Vec<CargoPackageChange>,
42 pub command: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct CargoPackageChange {
48 pub package: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub previous_version: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub candidate_version: Option<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub previous_features: Vec<String>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub candidate_features: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct CargoLockResolution {
61 pub candidate_lock: Vec<u8>,
62 pub evidence: CargoLockCandidate,
63}
64
65#[derive(Debug, thiserror::Error)]
66pub enum CargoLockResolutionError {
67 #[error("invalid isolated Cargo path `{path}`")]
68 InvalidPath { path: String },
69 #[error("required isolated Cargo input `{path}` is missing")]
70 MissingInput { path: String },
71 #[error("failed to prepare isolated Cargo workspace: {0}")]
72 Io(#[from] std::io::Error),
73 #[error("isolated Cargo resolution failed: {stderr}")]
74 CommandFailed { stderr: String },
75 #[error("Cargo.lock is invalid: {message}")]
76 InvalidLock { message: String },
77 #[error("Cargo.lock changed package `{package}` outside the approved module closure")]
78 UnrelatedPackageChurn { package: String },
79 #[error("Cargo.lock package `{package}` does not match the verified release: {message}")]
80 PackageProvenanceMismatch { package: String, message: String },
81}
82
83pub trait CargoLockGenerator: Send + Sync {
84 fn generate(
85 &self,
86 sandbox: &Path,
87 manifest_path: &Path,
88 offline: bool,
89 ) -> Result<Vec<String>, CargoLockResolutionError>;
90}
91
92#[derive(Debug, Default, Clone, Copy)]
93pub struct CargoGenerateLockfile;
94
95impl CargoLockGenerator for CargoGenerateLockfile {
96 fn generate(
97 &self,
98 sandbox: &Path,
99 manifest_path: &Path,
100 offline: bool,
101 ) -> Result<Vec<String>, CargoLockResolutionError> {
102 let stable_manifest_path = manifest_path.strip_prefix(sandbox).map_err(|_| {
103 CargoLockResolutionError::InvalidPath {
104 path: manifest_path.display().to_string(),
105 }
106 })?;
107 let mut command = vec![
108 "cargo".to_owned(),
109 "generate-lockfile".to_owned(),
110 "--manifest-path".to_owned(),
111 stable_manifest_path.display().to_string(),
112 ];
113 if offline {
114 command.push("--offline".to_owned());
115 }
116 let mut process = Command::new(&command[0]);
117 process.args(&command[1..]).current_dir(sandbox);
118 let output = process.output()?;
119 if !output.status.success() {
120 return Err(CargoLockResolutionError::CommandFailed {
121 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
122 });
123 }
124 Ok(command)
125 }
126}
127
128#[derive(Debug, Clone)]
129pub struct IsolatedCargoLockResolver<G = CargoGenerateLockfile> {
130 generator: G,
131}
132
133impl Default for IsolatedCargoLockResolver<CargoGenerateLockfile> {
134 fn default() -> Self {
135 Self {
136 generator: CargoGenerateLockfile,
137 }
138 }
139}
140
141impl<G> IsolatedCargoLockResolver<G>
142where
143 G: CargoLockGenerator,
144{
145 pub fn new(generator: G) -> Self {
146 Self { generator }
147 }
148
149 pub fn resolve(
150 &self,
151 request: &CargoLockResolutionRequest,
152 ) -> Result<CargoLockResolution, CargoLockResolutionError> {
153 validate_relative_path(&request.root_manifest_path)?;
154 validate_relative_path(&request.lock_path)?;
155 let current_lock = request.read_set.get(&request.lock_path).ok_or_else(|| {
156 CargoLockResolutionError::MissingInput {
157 path: request.lock_path.clone(),
158 }
159 })?;
160 if !request.read_set.contains_key(&request.root_manifest_path)
161 && !request
162 .candidate_files
163 .contains_key(&request.root_manifest_path)
164 {
165 return Err(CargoLockResolutionError::MissingInput {
166 path: request.root_manifest_path.clone(),
167 });
168 }
169
170 let sandbox = TempSandbox::create()?;
171 materialize(&sandbox.path, &request.read_set)?;
172 materialize(&sandbox.path, &request.candidate_files)?;
173 let manifest_path = sandbox.path.join(&request.root_manifest_path);
174 let command = self
175 .generator
176 .generate(&sandbox.path, &manifest_path, request.offline)?;
177 let candidate_lock = fs::read(sandbox.path.join(&request.lock_path))?;
178 let evidence = validate_cargo_lock_candidate(
179 current_lock,
180 &candidate_lock,
181 &request.allowed_root_packages,
182 &request.current_linked_packages,
183 &request.expected_linked_packages,
184 command,
185 )?;
186 Ok(CargoLockResolution {
187 candidate_lock,
188 evidence,
189 })
190 }
191}
192
193pub fn validate_cargo_lock_candidate(
194 current_lock: &[u8],
195 candidate_lock: &[u8],
196 allowed_root_packages: &[String],
197 current_linked_packages: &[ExpectedLinkedPackage],
198 expected_linked_packages: &[ExpectedLinkedPackage],
199 command: Vec<String>,
200) -> Result<CargoLockCandidate, CargoLockResolutionError> {
201 let current = ParsedLock::parse(current_lock)?;
202 let candidate = ParsedLock::parse(candidate_lock)?;
203 let changed_keys = changed_package_keys(¤t, &candidate);
204 let current_features = feature_selections(current_linked_packages)?;
205 let candidate_features = feature_selections(expected_linked_packages)?;
206 let allowed = dependency_closure(¤t, allowed_root_packages)
207 .into_iter()
208 .chain(dependency_closure(&candidate, allowed_root_packages))
209 .collect::<BTreeSet<_>>();
210 if let Some(package) = changed_keys
211 .iter()
212 .map(|key| key.name.as_str())
213 .find(|package| !allowed.contains(*package))
214 {
215 return Err(CargoLockResolutionError::UnrelatedPackageChurn {
216 package: package.to_owned(),
217 });
218 }
219 if let Some(package) = current_features
220 .keys()
221 .chain(candidate_features.keys())
222 .find(|package| {
223 current_features.get(*package) != candidate_features.get(*package)
224 && !allowed.contains(*package)
225 })
226 {
227 return Err(CargoLockResolutionError::UnrelatedPackageChurn {
228 package: package.clone(),
229 });
230 }
231
232 for expected in expected_linked_packages {
233 let matches = candidate
234 .packages
235 .iter()
236 .filter(|(key, _)| key.name == expected.package && key.version == expected.version)
237 .collect::<Vec<_>>();
238 if matches.len() != 1 {
239 return Err(CargoLockResolutionError::PackageProvenanceMismatch {
240 package: expected.package.clone(),
241 message: format!(
242 "expected exactly one {}@{}, found {}",
243 expected.package,
244 expected.version,
245 matches.len()
246 ),
247 });
248 }
249 if let Some(expected_checksum) = &expected.archive_checksum {
250 let checksum = matches[0].1.checksum.as_deref().unwrap_or_default();
251 if normalize_checksum(checksum) != normalize_checksum(expected_checksum) {
252 return Err(CargoLockResolutionError::PackageProvenanceMismatch {
253 package: expected.package.clone(),
254 message: "registry checksum differs from the verified archive checksum"
255 .to_owned(),
256 });
257 }
258 }
259 }
260
261 let changed_names = changed_keys
262 .iter()
263 .map(|key| key.name.clone())
264 .chain(
265 current_features
266 .keys()
267 .chain(candidate_features.keys())
268 .filter(|package| {
269 current_features.get(*package) != candidate_features.get(*package)
270 })
271 .cloned(),
272 )
273 .collect::<BTreeSet<_>>();
274 let changed_packages = changed_names
275 .into_iter()
276 .map(|package| CargoPackageChange {
277 previous_version: versions_for(¤t, &package),
278 candidate_version: versions_for(&candidate, &package),
279 previous_features: current_features.get(&package).cloned().unwrap_or_default(),
280 candidate_features: candidate_features
281 .get(&package)
282 .cloned()
283 .unwrap_or_default(),
284 package,
285 })
286 .collect();
287
288 Ok(CargoLockCandidate {
289 protocol: CARGO_LOCK_CANDIDATE_PROTOCOL.to_owned(),
290 current_lock_digest: bytes_digest(current_lock),
291 candidate_lock_digest: bytes_digest(candidate_lock),
292 changed_packages,
293 command,
294 })
295}
296
297fn feature_selections(
298 packages: &[ExpectedLinkedPackage],
299) -> Result<BTreeMap<String, Vec<String>>, CargoLockResolutionError> {
300 let mut selections = BTreeMap::new();
301 for package in packages {
302 let mut features = package.features.clone();
303 if package.default_features {
304 features.push("default".to_owned());
305 }
306 features.sort();
307 features.dedup();
308 if selections
309 .insert(package.package.clone(), features)
310 .is_some()
311 {
312 return Err(CargoLockResolutionError::PackageProvenanceMismatch {
313 package: package.package.clone(),
314 message: "duplicate linked package feature selection".to_owned(),
315 });
316 }
317 }
318 Ok(selections)
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
322struct PackageKey {
323 name: String,
324 version: String,
325 source: String,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
329struct ParsedPackage {
330 checksum: Option<String>,
331 dependencies: BTreeSet<String>,
332 canonical: String,
333}
334
335#[derive(Debug, Clone, Default)]
336struct ParsedLock {
337 packages: BTreeMap<PackageKey, ParsedPackage>,
338}
339
340impl ParsedLock {
341 fn parse(bytes: &[u8]) -> Result<Self, CargoLockResolutionError> {
342 let text =
343 std::str::from_utf8(bytes).map_err(|error| CargoLockResolutionError::InvalidLock {
344 message: error.to_string(),
345 })?;
346 let mut lock = Self::default();
347 for block in text.split("[[package]]").skip(1) {
348 let name = string_field(block, "name").ok_or_else(|| {
349 CargoLockResolutionError::InvalidLock {
350 message: "package entry is missing name".to_owned(),
351 }
352 })?;
353 let version = string_field(block, "version").ok_or_else(|| {
354 CargoLockResolutionError::InvalidLock {
355 message: format!("package `{name}` is missing version"),
356 }
357 })?;
358 let source = string_field(block, "source").unwrap_or_default();
359 let checksum = string_field(block, "checksum");
360 let dependencies = dependency_names(block);
361 let canonical = block
362 .lines()
363 .map(str::trim)
364 .filter(|line| !line.is_empty())
365 .collect::<Vec<_>>()
366 .join("\n");
367 let key = PackageKey {
368 name,
369 version,
370 source,
371 };
372 if lock
373 .packages
374 .insert(
375 key.clone(),
376 ParsedPackage {
377 checksum,
378 dependencies,
379 canonical,
380 },
381 )
382 .is_some()
383 {
384 return Err(CargoLockResolutionError::InvalidLock {
385 message: format!("duplicate package identity {}@{}", key.name, key.version),
386 });
387 }
388 }
389 Ok(lock)
390 }
391}
392
393fn string_field(block: &str, field: &str) -> Option<String> {
394 block.lines().find_map(|line| {
395 let (name, value) = line.trim().split_once('=')?;
396 (name.trim() == field).then(|| value.trim().trim_matches('"').to_owned())
397 })
398}
399
400fn dependency_names(block: &str) -> BTreeSet<String> {
401 let Some(start) = block.find("dependencies = [") else {
402 return BTreeSet::new();
403 };
404 let tail = &block[start..];
405 let Some(end) = tail.find(']') else {
406 return BTreeSet::new();
407 };
408 tail[..end]
409 .lines()
410 .filter_map(|line| {
411 let value = line.trim().trim_end_matches(',').trim_matches('"');
412 (!value.is_empty() && value != "dependencies = [").then(|| {
413 value
414 .split_whitespace()
415 .next()
416 .unwrap_or_default()
417 .to_owned()
418 })
419 })
420 .filter(|value| !value.is_empty())
421 .collect()
422}
423
424fn changed_package_keys(current: &ParsedLock, candidate: &ParsedLock) -> BTreeSet<PackageKey> {
425 current
426 .packages
427 .keys()
428 .chain(candidate.packages.keys())
429 .filter(|key| current.packages.get(*key) != candidate.packages.get(*key))
430 .cloned()
431 .collect()
432}
433
434fn dependency_closure(lock: &ParsedLock, roots: &[String]) -> BTreeSet<String> {
435 let mut closure = BTreeSet::new();
436 let mut queue = roots.iter().cloned().collect::<VecDeque<_>>();
437 while let Some(name) = queue.pop_front() {
438 if !closure.insert(name.clone()) {
439 continue;
440 }
441 for package in lock.packages.iter().filter(|(key, _)| key.name == name) {
442 queue.extend(package.1.dependencies.iter().cloned());
443 }
444 }
445 closure
446}
447
448fn versions_for(lock: &ParsedLock, package: &str) -> Option<String> {
449 let values = lock
450 .packages
451 .keys()
452 .filter(|key| key.name == package)
453 .map(|key| key.version.clone())
454 .collect::<BTreeSet<_>>();
455 (!values.is_empty()).then(|| values.into_iter().collect::<Vec<_>>().join(","))
456}
457
458fn normalize_checksum(value: &str) -> &str {
459 value.strip_prefix("sha256:").unwrap_or(value)
460}
461
462fn bytes_digest(bytes: &[u8]) -> String {
463 let mut hasher = Sha256::new();
464 hasher.update(bytes);
465 let mut digest = String::with_capacity(71);
466 digest.push_str("sha256:");
467 for byte in hasher.finalize() {
468 use std::fmt::Write as _;
469 write!(&mut digest, "{byte:02x}").expect("writing to a String cannot fail");
470 }
471 digest
472}
473
474fn validate_relative_path(path: &str) -> Result<(), CargoLockResolutionError> {
475 let parsed = Path::new(path);
476 if path.is_empty()
477 || parsed.components().any(|component| {
478 matches!(
479 component,
480 Component::ParentDir | Component::RootDir | Component::Prefix(_)
481 )
482 })
483 {
484 return Err(CargoLockResolutionError::InvalidPath {
485 path: path.to_owned(),
486 });
487 }
488 Ok(())
489}
490
491fn materialize(
492 root: &Path,
493 files: &BTreeMap<String, Vec<u8>>,
494) -> Result<(), CargoLockResolutionError> {
495 for (path, contents) in files {
496 validate_relative_path(path)?;
497 let destination = root.join(path);
498 if let Some(parent) = destination.parent() {
499 fs::create_dir_all(parent)?;
500 }
501 fs::write(destination, contents)?;
502 }
503 Ok(())
504}
505
506struct TempSandbox {
507 path: PathBuf,
508}
509
510impl TempSandbox {
511 fn create() -> Result<Self, std::io::Error> {
512 let nonce = SystemTime::now()
513 .duration_since(UNIX_EPOCH)
514 .unwrap_or_default()
515 .as_nanos();
516 let path = std::env::temp_dir().join(format!(
517 "lenso-cargo-lock-{}-{nonce}-{}",
518 std::process::id(),
519 TEMP_SANDBOX_SEQUENCE.fetch_add(1, Ordering::Relaxed)
520 ));
521 fs::create_dir(&path)?;
522 Ok(Self { path })
523 }
524}
525
526impl Drop for TempSandbox {
527 fn drop(&mut self) {
528 let _ = fs::remove_dir_all(&self.path);
529 }
530}