Skip to main content

lenso_module_management/
cargo_lock.rs

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(&current, &candidate);
204    let current_features = feature_selections(current_linked_packages)?;
205    let candidate_features = feature_selections(expected_linked_packages)?;
206    let allowed = dependency_closure(&current, 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(&current, &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}