use crate::error::{DepsError, Result};
use crate::fs_probe;
use dashmap::DashMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime};
use url::Url;
const MAX_WORKSPACE_DEPTH: usize = 5;
pub const MAX_LOCKFILE_BYTES: u64 = 32 * 1024 * 1024;
#[tracing::instrument(skip_all, fields(path = %path.display(), file_type = %file_type), level = "debug")]
pub async fn read_lockfile_content(path: &Path, file_type: &str) -> Result<String> {
let to_parse_error = |e: std::io::Error| {
DepsError::parse_error(format!("{file_type} at {}", path.display()), &e)
};
let oversized_error = || {
to_parse_error(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("exceeds {MAX_LOCKFILE_BYTES} byte size cap"),
))
};
let span = tracing::Span::current();
let path_buf = path.to_path_buf();
let read_result = tokio::task::spawn_blocking(move || {
let _entered = span.enter();
if let Ok(metadata) = fs_probe::metadata(&path_buf) {
if !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"not a regular file",
));
}
if metadata.len() > MAX_LOCKFILE_BYTES {
tracing::warn!(
path = %path_buf.display(),
len = metadata.len(),
cap = MAX_LOCKFILE_BYTES,
"lock file exceeds size cap; not reading"
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("exceeds {MAX_LOCKFILE_BYTES} byte size cap"),
));
}
}
fs_probe::read_to_string_capped(&path_buf, MAX_LOCKFILE_BYTES)
})
.await
.map_err(|e| to_parse_error(std::io::Error::other(e)))?;
match read_result.map_err(to_parse_error)? {
Some(content) => Ok(content),
None => {
tracing::warn!(
path = %path.display(),
cap = MAX_LOCKFILE_BYTES,
"lock file exceeds size cap during read; not reading"
);
Err(oversized_error())
}
}
}
#[tracing::instrument(skip_all, fields(path = %path.display(), file_type = %file_type), level = "debug")]
pub async fn read_and_parse_lockfile<T, F>(path: &Path, file_type: &str, parse: F) -> Result<T>
where
F: FnOnce(String) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let content = read_lockfile_content(path, file_type).await?;
tokio::task::spawn_blocking(move || parse(content))
.await
.map_err(|e| DepsError::parse_error(format!("{file_type} at {}", path.display()), &e))?
}
#[must_use]
pub fn resolve_manifest_file_path(manifest_uri: &Url) -> Option<PathBuf> {
if !manifest_uri.scheme().eq_ignore_ascii_case("file") {
return None;
}
if let Some(host) = manifest_uri.host_str()
&& !host.is_empty()
&& !host.eq_ignore_ascii_case("localhost")
{
return None;
}
let manifest_path = manifest_uri.to_file_path().ok()?;
manifest_path.is_absolute().then_some(manifest_path)
}
pub fn locate_lockfile_for_manifest(
manifest_uri: &Url,
lockfile_names: &[&str],
) -> Option<PathBuf> {
locate_lockfile_for_manifest_with_max_depth(manifest_uri, lockfile_names, MAX_WORKSPACE_DEPTH)
}
pub fn locate_lockfile_for_manifest_with_max_depth(
manifest_uri: &Url,
lockfile_names: &[&str],
max_ancestor_depth: usize,
) -> Option<PathBuf> {
let manifest_path = resolve_manifest_file_path(manifest_uri)?;
let manifest_dir = manifest_path.parent()?;
let mut lock_path = manifest_dir.to_path_buf();
for &name in lockfile_names {
lock_path.push(name);
if fs_probe::is_file(&lock_path) {
tracing::debug!("Found {} at: {}", name, lock_path.display());
return Some(lock_path);
}
lock_path.pop();
}
let Some(mut current_dir) = manifest_dir.parent() else {
tracing::debug!("No lock file found for: {:?}", manifest_uri);
return None;
};
for depth in 0..max_ancestor_depth {
lock_path.clear();
lock_path.push(current_dir);
for &name in lockfile_names {
lock_path.push(name);
if fs_probe::is_file(&lock_path) {
tracing::debug!(
"Found workspace {} at depth {}: {}",
name,
depth + 1,
lock_path.display()
);
return Some(lock_path);
}
lock_path.pop();
}
match current_dir.parent() {
Some(parent) => current_dir = parent,
None => break,
}
}
tracing::debug!("No lock file found for: {:?}", manifest_uri);
None
}
#[non_exhaustive]
#[derive(Clone, PartialEq, Eq)]
pub struct ResolvedPackage {
pub name: String,
pub version: String,
pub source: ResolvedSource,
pub dependencies: Vec<String>,
}
impl std::fmt::Debug for ResolvedPackage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedPackage")
.field(
"name",
&crate::net_policy::redact_declaration_key(&self.name),
)
.field("version", &self.version)
.field("source", &self.source)
.field(
"dependencies",
&self
.dependencies
.iter()
.map(|name| crate::net_policy::redact_declaration_key(name))
.collect::<Vec<_>>(),
)
.finish()
}
}
impl ResolvedPackage {
#[must_use]
pub fn new(name: String, version: String, source: ResolvedSource) -> Self {
Self {
name,
version,
source,
dependencies: Vec::new(),
}
}
#[must_use]
pub fn with_dependencies(mut self, dependencies: Vec<String>) -> Self {
self.dependencies = dependencies;
self
}
}
#[non_exhaustive]
#[derive(Clone, PartialEq, Eq)]
pub enum ResolvedSource {
Registry {
url: String,
checksum: String,
},
Git {
url: String,
rev: String,
},
Path {
path: String,
},
}
impl std::fmt::Debug for ResolvedSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Registry { url, checksum } => f
.debug_struct("Registry")
.field("url", &crate::net_policy::RedactedUrl::new(url))
.field("checksum", checksum)
.finish(),
Self::Git { url, rev } => f
.debug_struct("Git")
.field("url", &crate::net_policy::RedactedUrl::new(url))
.field("rev", rev)
.finish(),
Self::Path { path } => f
.debug_struct("Path")
.field("path", &crate::net_policy::redact_declaration_key(path))
.finish(),
}
}
}
#[derive(Default, Clone)]
pub struct ResolvedPackages {
packages: HashMap<String, Vec<ResolvedPackage>>,
}
impl std::fmt::Debug for ResolvedPackages {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
struct RedactedMap<'a>(&'a HashMap<String, Vec<ResolvedPackage>>);
impl std::fmt::Debug for RedactedMap<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_map()
.entries(self.0.iter().map(|(name, versions)| {
(crate::net_policy::redact_declaration_key(name), versions)
}))
.finish()
}
}
f.debug_struct("ResolvedPackages")
.field("packages", &RedactedMap(&self.packages))
.finish()
}
}
pub(crate) fn compare_lockfile_versions(a: &str, b: &str) -> std::cmp::Ordering {
match (semver::Version::parse(a), semver::Version::parse(b)) {
(Ok(va), Ok(vb)) => va.cmp(&vb),
(Ok(_), Err(_)) => std::cmp::Ordering::Greater,
(Err(_), Ok(_)) => std::cmp::Ordering::Less,
(Err(_), Err(_)) => a.cmp(b),
}
}
fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> {
packages
.iter()
.max_by(|a, b| compare_lockfile_versions(&a.version, &b.version))
}
impl ResolvedPackages {
pub fn new() -> Self {
Self {
packages: HashMap::new(),
}
}
pub fn insert(&mut self, package: ResolvedPackage) {
self.packages
.entry(package.name.clone())
.or_default()
.push(package);
}
pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
self.packages.get(name).and_then(|v| best_package(v))
}
pub fn version(&self, name: &str) -> Option<&str> {
self.get(name).map(|p| p.version.as_str())
}
pub fn all(&self, name: &str) -> Option<&[ResolvedPackage]> {
self.packages.get(name).map(|v| v.as_slice())
}
pub fn iter_all(&self) -> impl Iterator<Item = (&String, &[ResolvedPackage])> {
self.packages
.iter()
.map(|(name, versions)| (name, versions.as_slice()))
}
pub fn len(&self) -> usize {
self.packages.len()
}
pub fn is_empty(&self) -> bool {
self.packages.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
self.packages.keys().filter_map(|name| {
self.packages
.get(name)
.and_then(|v| best_package(v).map(|p| (name, p)))
})
}
pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
self.packages
.into_iter()
.filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p)))
.collect()
}
}
pub trait LockFileProvider: Send + Sync {
fn locate_lockfile(&self, manifest_uri: &Url) -> Option<PathBuf>;
fn parse_lockfile<'a>(
&'a self,
lockfile_path: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>;
}
pub const DEFAULT_MAX_CACHED_LOCKFILES: usize = 256;
struct CachedLockFile {
packages: ResolvedPackages,
modified_at: SystemTime,
parsed_at: Instant,
}
pub struct LockFileCache {
entries: DashMap<PathBuf, CachedLockFile>,
capacity: usize,
}
impl std::fmt::Debug for LockFileCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LockFileCache")
.field("capacity", &self.capacity)
.field("len", &self.entries.len())
.finish_non_exhaustive()
}
}
impl LockFileCache {
#[must_use]
pub fn new() -> Self {
Self::with_capacity(DEFAULT_MAX_CACHED_LOCKFILES)
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
entries: DashMap::new(),
capacity,
}
}
fn evict_oldest(&self) {
let oldest = self
.entries
.iter()
.min_by_key(|entry| entry.parsed_at)
.map(|entry| entry.key().clone());
if let Some(key) = oldest {
tracing::warn!(
path = %key.display(),
capacity = self.capacity,
"lock file cache capacity reached; evicting least-recently-used entry"
);
self.entries.remove(&key);
}
}
pub async fn get_or_parse(
&self,
provider: &dyn LockFileProvider,
lockfile_path: &Path,
) -> Result<ResolvedPackages> {
let cached = self
.entries
.get(lockfile_path)
.map(|entry| (entry.modified_at, entry.packages.clone()));
if let Some((cached_modified_at, cached_packages)) = cached
&& let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
&& let Ok(mtime) = metadata.modified()
&& mtime <= cached_modified_at
{
tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
if let Some(mut entry) = self.entries.get_mut(lockfile_path) {
entry.parsed_at = Instant::now();
}
return Ok(cached_packages);
}
tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
let metadata = tokio::fs::metadata(lockfile_path).await?;
let modified_at = metadata.modified()?;
let packages = provider.parse_lockfile(lockfile_path).await?;
if !self.entries.contains_key(lockfile_path) && self.entries.len() >= self.capacity {
self.evict_oldest();
}
self.entries.insert(
lockfile_path.to_path_buf(),
CachedLockFile {
packages: packages.clone(),
modified_at,
parsed_at: Instant::now(),
},
);
Ok(packages)
}
pub fn invalidate(&self, lockfile_path: &Path) {
self.entries.remove(lockfile_path);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for LockFileCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn non_file_uri(prefix: &str, manifest_path: &std::path::Path) -> Url {
let file_uri = Url::from_file_path(manifest_path).unwrap();
let path_part = file_uri.as_str().strip_prefix("file://").unwrap();
format!("{prefix}{path_part}").parse().unwrap()
}
#[tokio::test]
async fn test_read_lockfile_content_success() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lock_path, "version = 4").unwrap();
let content = read_lockfile_content(&lock_path, "Cargo.lock")
.await
.unwrap();
assert_eq!(content, "version = 4");
}
#[tokio::test]
async fn test_read_lockfile_content_rejects_oversized_file() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
let file = std::fs::File::create(&lock_path).unwrap();
file.set_len(MAX_LOCKFILE_BYTES + 1).unwrap();
let err = read_lockfile_content(&lock_path, "Cargo.lock")
.await
.unwrap_err();
match err {
DepsError::ParseError { file_type, .. } => {
assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
}
other => panic!("Expected ParseError, got: {other:?}"),
}
}
#[tokio::test]
async fn test_read_lockfile_content_rejects_non_regular_file() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::create_dir(&lock_path).unwrap();
let err = read_lockfile_content(&lock_path, "Cargo.lock")
.await
.unwrap_err();
match err {
DepsError::ParseError { file_type, .. } => {
assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
}
other => panic!("Expected ParseError, got: {other:?}"),
}
}
#[tokio::test]
async fn test_read_lockfile_content_missing_file_wraps_error() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
let err = read_lockfile_content(&lock_path, "Cargo.lock")
.await
.unwrap_err();
match err {
DepsError::ParseError { file_type, .. } => {
assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
}
other => panic!("Expected ParseError, got: {other:?}"),
}
}
#[tokio::test]
async fn test_read_and_parse_lockfile_panic_in_parse_becomes_parse_error() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lock_path, "version = 4").unwrap();
let err = read_and_parse_lockfile(&lock_path, "Cargo.lock", |_content| -> Result<()> {
panic!("boom")
})
.await
.unwrap_err();
match err {
DepsError::ParseError { file_type, source } => {
assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
assert!(
source.to_string().contains("boom"),
"panic message should be preserved in the error source, got: {source}"
);
}
other => panic!("Expected ParseError, got: {other:?}"),
}
}
#[tokio::test]
async fn test_read_and_parse_lockfile_runs_parse_off_calling_thread() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lock_path, "version = 4").unwrap();
let calling_thread = std::thread::current().id();
let content = read_and_parse_lockfile(&lock_path, "Cargo.lock", move |content| {
assert_ne!(
std::thread::current().id(),
calling_thread,
"parse must run on the blocking pool, not the calling thread"
);
Ok(content)
})
.await
.unwrap();
assert_eq!(content, "version = 4");
}
#[test]
fn test_resolved_packages_new() {
let packages = ResolvedPackages::new();
assert!(packages.is_empty());
assert_eq!(packages.len(), 0);
}
#[test]
fn test_resolved_packages_insert_and_get() {
let mut packages = ResolvedPackages::new();
let pkg = ResolvedPackage {
name: "serde".into(),
version: "1.0.195".into(),
source: ResolvedSource::Registry {
url: "https://github.com/rust-lang/crates.io-index".into(),
checksum: "abc123".into(),
},
dependencies: vec!["serde_derive".into()],
};
packages.insert(pkg);
assert_eq!(packages.len(), 1);
assert!(!packages.is_empty());
assert_eq!(packages.version("serde"), Some("1.0.195"));
let retrieved = packages.get("serde");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().name, "serde");
assert_eq!(retrieved.unwrap().dependencies.len(), 1);
}
#[test]
fn test_resolved_packages_get_nonexistent() {
let packages = ResolvedPackages::new();
assert_eq!(packages.get("nonexistent"), None);
assert_eq!(packages.version("nonexistent"), None);
}
#[test]
fn test_resolved_packages_replace() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "old".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.195".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "new".into(),
},
dependencies: vec![],
});
assert_eq!(packages.len(), 1);
assert_eq!(packages.version("serde"), Some("1.0.195"));
assert_eq!(packages.all("serde").unwrap().len(), 2);
}
#[test]
fn test_resolved_packages_multiple_versions() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.195".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "a".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "0.9.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "b".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "2.0.0-beta.1".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "c".into(),
},
dependencies: vec![],
});
assert_eq!(packages.len(), 1);
assert_eq!(packages.version("serde"), Some("2.0.0-beta.1"));
assert_eq!(packages.all("serde").unwrap().len(), 3);
}
#[test]
fn test_resolved_packages_non_semver_fallback() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "weird".into(),
version: "abc".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "weird".into(),
version: "xyz".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
assert_eq!(packages.version("weird"), Some("xyz"));
}
#[test]
fn test_resolved_packages_semver_preferred_over_non_semver() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "mixed".into(),
version: "not-a-version".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "mixed".into(),
version: "1.0.0".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
assert_eq!(packages.version("mixed"), Some("1.0.0"));
}
#[test]
fn test_resolved_source_equality() {
let source1 = ResolvedSource::Registry {
url: "https://test.com".into(),
checksum: "abc".into(),
};
let source2 = ResolvedSource::Registry {
url: "https://test.com".into(),
checksum: "abc".into(),
};
let source3 = ResolvedSource::Git {
url: "https://github.com/test".into(),
rev: "abc123".into(),
};
assert_eq!(source1, source2);
assert_ne!(source1, source3);
}
#[test]
fn test_resolved_packages_iter() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "a".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "tokio".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "b".into(),
},
dependencies: vec![],
});
let count = packages.iter().count();
assert_eq!(count, 2);
let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
assert!(names.contains(&"serde"));
assert!(names.contains(&"tokio"));
}
#[test]
fn test_resolved_packages_into_map() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "a".into(),
},
dependencies: vec![],
});
let map = packages.into_map();
assert_eq!(map.len(), 1);
assert!(map.contains_key("serde"));
}
#[test]
fn test_lockfile_cache_new() {
let cache = LockFileCache::new();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(
cache.capacity, DEFAULT_MAX_CACHED_LOCKFILES,
"new() must use the documented default capacity"
);
}
#[test]
fn test_lockfile_cache_invalidate() {
let cache = LockFileCache::new();
let test_path = PathBuf::from("/test/Cargo.lock");
cache.entries.insert(
test_path.clone(),
CachedLockFile {
packages: ResolvedPackages::new(),
modified_at: SystemTime::now(),
parsed_at: Instant::now(),
},
);
assert_eq!(cache.len(), 1);
cache.invalidate(&test_path);
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[test]
fn test_lockfile_cache_with_capacity() {
let cache = LockFileCache::with_capacity(64);
assert_eq!(cache.capacity, 64);
assert!(cache.is_empty());
}
#[tokio::test]
async fn test_get_or_parse_evicts_oldest_entry_at_capacity() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_a = temp_dir.path().join("a.lock");
let lock_b = temp_dir.path().join("b.lock");
let lock_c = temp_dir.path().join("c.lock");
std::fs::write(&lock_a, "a").unwrap();
std::fs::write(&lock_b, "b").unwrap();
std::fs::write(&lock_c, "c").unwrap();
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::with_capacity(2);
cache.get_or_parse(&provider, &lock_a).await.unwrap();
cache.get_or_parse(&provider, &lock_b).await.unwrap();
assert_eq!(
cache.len(),
2,
"cache should hold exactly `capacity` entries"
);
cache.get_or_parse(&provider, &lock_c).await.unwrap();
assert_eq!(
cache.len(),
2,
"cache must not grow past its configured capacity"
);
assert!(
cache.entries.contains_key(&lock_b),
"the more recently parsed entry must survive eviction"
);
assert!(
cache.entries.contains_key(&lock_c),
"the newly inserted entry must be present"
);
assert!(
!cache.entries.contains_key(&lock_a),
"the least-recently-parsed entry must have been evicted"
);
let parses_before = provider.parse_count();
cache.get_or_parse(&provider, &lock_a).await.unwrap();
assert_eq!(
provider.parse_count(),
parses_before + 1,
"an evicted entry must be re-parsed on next access"
);
}
#[tokio::test]
async fn test_get_or_parse_cache_hit_at_capacity_does_not_evict() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_a = temp_dir.path().join("a.lock");
let lock_b = temp_dir.path().join("b.lock");
std::fs::write(&lock_a, "a").unwrap();
std::fs::write(&lock_b, "b").unwrap();
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::with_capacity(2);
cache.get_or_parse(&provider, &lock_a).await.unwrap();
cache.get_or_parse(&provider, &lock_b).await.unwrap();
cache.get_or_parse(&provider, &lock_a).await.unwrap();
assert_eq!(cache.len(), 2);
assert!(cache.entries.contains_key(&lock_a));
assert!(cache.entries.contains_key(&lock_b));
}
#[test]
fn test_locate_lockfile_for_manifest_same_directory() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
std::fs::write(&lock_path, "version = 4").unwrap();
let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), lock_path);
}
#[test]
fn test_locate_lockfile_for_manifest_workspace_root() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let workspace_lock = temp_dir.path().join("Cargo.lock");
let member_dir = temp_dir.path().join("crates").join("member");
std::fs::create_dir_all(&member_dir).unwrap();
let member_manifest = member_dir.join("Cargo.toml");
std::fs::write(&workspace_lock, "version = 4").unwrap();
std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
let manifest_uri = Url::from_file_path(&member_manifest).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), workspace_lock);
}
#[test]
fn test_locate_lockfile_for_manifest_with_max_depth_zero_ignores_ancestors() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let workspace_lock = temp_dir.path().join("Cargo.lock");
let member_dir = temp_dir.path().join("crates").join("member");
std::fs::create_dir_all(&member_dir).unwrap();
let member_manifest = member_dir.join("Cargo.toml");
let member_lock = member_dir.join("Cargo.lock");
std::fs::write(&workspace_lock, "version = 4").unwrap();
std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
let manifest_uri = Url::from_file_path(&member_manifest).unwrap();
assert_eq!(
locate_lockfile_for_manifest_with_max_depth(&manifest_uri, &["Cargo.lock"], 0),
None,
"depth 0 must not walk up to the workspace root's lock file"
);
assert_eq!(
locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]),
Some(workspace_lock),
"the default wrapper must keep the ancestor-walk behavior for existing callers"
);
std::fs::write(&member_lock, "version = 4").unwrap();
assert_eq!(
locate_lockfile_for_manifest_with_max_depth(&manifest_uri, &["Cargo.lock"], 0),
Some(member_lock),
"depth 0 must still find a lock file in the manifest's own directory"
);
}
#[test]
fn test_locate_lockfile_for_manifest_skips_non_regular_file() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
std::fs::create_dir(&lock_path).unwrap();
let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(
located.is_none(),
"a directory at the lock file path must not be treated as a found lock file"
);
}
#[test]
fn test_locate_lockfile_for_manifest_rejects_non_file_uri() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
std::fs::write(&lock_path, "version = 4").unwrap();
let manifest_uri = non_file_uri("untitled:", &manifest_path);
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(
located.is_none(),
"a non-file-scheme URI must never resolve to a filesystem path, even when that \
path names a real, existing lock file"
);
}
#[cfg(unix)]
#[test]
fn test_locate_lockfile_for_manifest_rejects_remote_host_file_uri() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
std::fs::write(&lock_path, "version = 4").unwrap();
let manifest_uri = non_file_uri("file://attacker.example", &manifest_path);
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(
located.is_none(),
"a file: URI carrying a non-localhost host must never resolve to a filesystem path"
);
}
#[cfg(unix)]
#[test]
fn test_locate_lockfile_for_manifest_accepts_localhost_file_uri() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
std::fs::write(&lock_path, "version = 4").unwrap();
let manifest_uri = non_file_uri("file://localhost", &manifest_path);
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert_eq!(located, Some(lock_path));
}
#[test]
fn test_locate_lockfile_for_manifest_not_found() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(located.is_none());
}
#[test]
fn test_locate_lockfile_for_manifest_multiple_names() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("pyproject.toml");
let uv_lock = temp_dir.path().join("uv.lock");
std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
std::fs::write(&uv_lock, "version = 1").unwrap();
let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), uv_lock);
}
struct CountingLockFileProvider {
parse_count: std::sync::atomic::AtomicUsize,
}
impl CountingLockFileProvider {
fn new() -> Self {
Self {
parse_count: std::sync::atomic::AtomicUsize::new(0),
}
}
fn parse_count(&self) -> usize {
self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl LockFileProvider for CountingLockFileProvider {
fn locate_lockfile(&self, _manifest_uri: &Url) -> Option<PathBuf> {
None
}
fn parse_lockfile<'a>(
&'a self,
lockfile_path: &'a Path,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
> {
Box::pin(async move {
self.parse_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let content = read_lockfile_content(lockfile_path, "test.lock").await?;
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "test-package".into(),
version: content.trim().to_string(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
Ok(packages)
})
}
}
#[tokio::test]
async fn test_get_or_parse_cache_hit_does_not_reparse() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("test.lock");
std::fs::write(&lock_path, "1.0.0").unwrap();
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::new();
let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(provider.parse_count(), 1, "second call should hit cache");
assert_eq!(first.version("test-package"), Some("1.0.0"));
assert_eq!(second.version("test-package"), Some("1.0.0"));
}
#[tokio::test]
async fn test_get_or_parse_missing_file_returns_error() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("does-not-exist.lock");
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::new();
let err = cache.get_or_parse(&provider, &lock_path).await.unwrap_err();
assert!(
matches!(err, DepsError::Io(_)),
"expected an Io error for a missing lock file, got: {err:?}"
);
assert_eq!(
provider.parse_count(),
0,
"parse_lockfile must not be called when the file cannot be stat'd"
);
}
#[tokio::test]
async fn test_get_or_parse_cached_entry_with_deleted_file_returns_error() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("test.lock");
std::fs::write(&lock_path, "1.0.0").unwrap();
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::new();
let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(first.version("test-package"), Some("1.0.0"));
std::fs::remove_file(&lock_path).unwrap();
let err = cache.get_or_parse(&provider, &lock_path).await.unwrap_err();
assert!(
matches!(err, DepsError::Io(_)),
"expected an Io error once the cached file is deleted, got: {err:?}"
);
}
#[tokio::test]
async fn test_get_or_parse_reparses_when_mtime_advances() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("test.lock");
std::fs::write(&lock_path, "1.0.0").unwrap();
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::new();
let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(first.version("test-package"), Some("1.0.0"));
std::fs::write(&lock_path, "2.0.0").unwrap();
let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::OpenOptions::new()
.write(true)
.open(&lock_path)
.unwrap()
.set_modified(future_mtime)
.unwrap();
let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(
provider.parse_count(),
2,
"stale mtime should trigger reparse"
);
assert_eq!(second.version("test-package"), Some("2.0.0"));
}
#[tokio::test]
async fn test_get_or_parse_cache_hit_when_mtime_moves_backward() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("test.lock");
std::fs::write(&lock_path, "1.0.0").unwrap();
let provider = CountingLockFileProvider::new();
let cache = LockFileCache::new();
let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(first.version("test-package"), Some("1.0.0"));
std::fs::write(&lock_path, "2.0.0").unwrap();
let past_mtime = SystemTime::now() - std::time::Duration::from_secs(3600);
std::fs::OpenOptions::new()
.write(true)
.open(&lock_path)
.unwrap()
.set_modified(past_mtime)
.unwrap();
let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(
provider.parse_count(),
1,
"a backward-moving mtime must still be treated as a cache hit"
);
assert_eq!(
second.version("test-package"),
Some("1.0.0"),
"cached (pre-rewrite) content should be served, not the new on-disk content"
);
}
struct RewritingDuringParseProvider {
parse_count: std::sync::atomic::AtomicUsize,
}
impl RewritingDuringParseProvider {
fn new() -> Self {
Self {
parse_count: std::sync::atomic::AtomicUsize::new(0),
}
}
fn parse_count(&self) -> usize {
self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl LockFileProvider for RewritingDuringParseProvider {
fn locate_lockfile(&self, _manifest_uri: &Url) -> Option<PathBuf> {
None
}
fn parse_lockfile<'a>(
&'a self,
lockfile_path: &'a Path,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
> {
Box::pin(async move {
self.parse_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let content = read_lockfile_content(lockfile_path, "test.lock").await?;
std::fs::write(lockfile_path, "2.0.0").unwrap();
let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::OpenOptions::new()
.write(true)
.open(lockfile_path)
.unwrap()
.set_modified(future_mtime)
.unwrap();
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "test-package".into(),
version: content.trim().to_string(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
Ok(packages)
})
}
}
#[tokio::test]
async fn test_get_or_parse_detects_rewrite_during_parse() {
let _guard = fs_probe::snapshot_guard_async().await;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("test.lock");
std::fs::write(&lock_path, "1.0.0").unwrap();
let provider = RewritingDuringParseProvider::new();
let cache = LockFileCache::new();
let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(first.version("test-package"), Some("1.0.0"));
let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
assert_eq!(
provider.parse_count(),
2,
"rewrite during first parse must be detected and trigger a reparse"
);
assert_eq!(second.version("test-package"), Some("2.0.0"));
}
#[test]
fn test_locate_lockfile_for_manifest_first_match_wins() {
let _guard = fs_probe::snapshot_guard();
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("pyproject.toml");
let poetry_lock = temp_dir.path().join("poetry.lock");
let uv_lock = temp_dir.path().join("uv.lock");
std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
std::fs::write(&poetry_lock, "# poetry lock").unwrap();
std::fs::write(&uv_lock, "version = 1").unwrap();
let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), poetry_lock);
}
crate::debug_redaction_conformance!(
test_resolved_package_debug_redacts_credentials,
3,
ResolvedPackage {
name: crate::conformance::CREDENTIAL_PROBE_KEY.to_string(),
version: "1.0.0".to_string(),
source: ResolvedSource::Git {
url: crate::conformance::CREDENTIAL_PROBE_URL.to_string(),
rev: "deadbeef".to_string(),
},
dependencies: vec![crate::conformance::CREDENTIAL_PROBE_KEY.to_string()],
},
);
crate::debug_redaction_conformance!(
test_resolved_source_registry_debug_redacts_credentials,
1,
ResolvedSource::Registry {
url: crate::conformance::CREDENTIAL_PROBE_URL.to_string(),
checksum: "abc123".to_string(),
},
);
crate::debug_redaction_conformance!(
test_resolved_source_path_debug_redacts_unrecognized_scheme_credential,
1,
ResolvedSource::Path {
path: format!(
"unknown-scheme+{}",
crate::conformance::CREDENTIAL_PROBE_URL
),
},
);
crate::debug_redaction_conformance!(
test_resolved_packages_debug_redacts_map_key,
2,
ResolvedPackages {
packages: HashMap::from([(
crate::conformance::CREDENTIAL_PROBE_KEY.to_string(),
vec![ResolvedPackage::new(
crate::conformance::CREDENTIAL_PROBE_KEY.to_string(),
"1.0.0".to_string(),
ResolvedSource::Path {
path: String::new(),
},
)],
)]),
},
);
#[test]
fn test_resolved_packages_debug_preserves_colliding_redacted_keys() {
let name_a = "orgA:deployA:secretA@git.internal.corp";
let name_b = "orgB.other:deployB:secretB@git.internal.corp";
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage::new(
name_a.to_string(),
"1.0.0".to_string(),
ResolvedSource::Path {
path: String::new(),
},
));
packages.insert(ResolvedPackage::new(
name_b.to_string(),
"2.0.0".to_string(),
ResolvedSource::Path {
path: String::new(),
},
));
assert_eq!(packages.len(), 2);
let rendered = format!("{packages:?}");
assert!(!rendered.contains("secretA"));
assert!(!rendered.contains("secretB"));
assert!(
rendered.contains("1.0.0"),
"package a's version must survive: {rendered}"
);
assert!(
rendered.contains("2.0.0"),
"package b's version must survive: {rendered}"
);
assert_eq!(
rendered.matches("***@git.internal.corp").count(),
4,
"expected 2 redacted map keys + 2 redacted ResolvedPackage.name fields, found: {rendered}"
);
}
}