Skip to main content

deps_cargo/
lockfile.rs

1//! Cargo.lock file parsing.
2//!
3//! Parses Cargo.lock files (version 3 and 4) to extract resolved dependency
4//! versions. Supports workspace lock files and proper path resolution.
5//!
6//! # Cargo.lock Format
7//!
8//! Cargo.lock uses TOML format with an array of packages:
9//!
10//! ```toml
11//! # This file is automatically @generated by Cargo.
12//! # It is not intended for manual editing.
13//! version = 4
14//!
15//! [[package]]
16//! name = "serde"
17//! version = "1.0.195"
18//! source = "registry+https://github.com/rust-lang/crates.io-index"
19//! checksum = "..."
20//! dependencies = [
21//!     "serde_derive",
22//! ]
23//! ```
24
25use deps_core::error::{DepsError, Result};
26use deps_core::lockfile::{
27    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
28    locate_lockfile_for_manifest, read_lockfile_content,
29};
30use std::path::{Path, PathBuf};
31use tower_lsp_server::ls_types::Uri;
32
33/// Cargo.lock file parser.
34///
35/// Implements lock file parsing for Rust's Cargo build system.
36/// Supports both project-level and workspace-level lock files.
37///
38/// # Lock File Location
39///
40/// The parser searches for Cargo.lock in the following order:
41/// 1. Same directory as Cargo.toml
42/// 2. Parent directories (up to 5 levels) for workspace root
43///
44/// # Examples
45///
46/// ```no_run
47/// use deps_cargo::lockfile::CargoLockParser;
48/// use deps_core::lockfile::LockFileProvider;
49/// use tower_lsp_server::ls_types::Uri;
50///
51/// # async fn example() -> deps_core::error::Result<()> {
52/// let parser = CargoLockParser;
53/// let manifest_uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap();
54///
55/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) {
56///     let resolved = parser.parse_lockfile(&lockfile_path).await?;
57///     println!("Found {} resolved packages", resolved.len());
58/// }
59/// # Ok(())
60/// # }
61/// ```
62pub struct CargoLockParser;
63
64impl CargoLockParser {
65    /// Lock file names for Cargo ecosystem.
66    const LOCKFILE_NAMES: &'static [&'static str] = &["Cargo.lock"];
67}
68
69impl LockFileProvider for CargoLockParser {
70    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
71        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
72    }
73
74    fn parse_lockfile<'a>(
75        &'a self,
76        lockfile_path: &'a Path,
77    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
78    {
79        Box::pin(async move {
80            tracing::debug!("Parsing Cargo.lock: {}", lockfile_path.display());
81
82            let content = read_lockfile_content(lockfile_path, "Cargo.lock").await?;
83
84            if let Err(depth) =
85                deps_core::check_toml_nesting_depth(&content, deps_core::MAX_TOML_NESTING_DEPTH)
86            {
87                return Err(DepsError::ParseError {
88                    file_type: "Cargo.lock".into(),
89                    source: Box::new(std::io::Error::other(format!(
90                        "array/table nesting depth {depth} exceeds maximum of {}",
91                        deps_core::MAX_TOML_NESTING_DEPTH
92                    ))),
93                });
94            }
95
96            let doc = toml_span::parse(&content).map_err(|e| DepsError::ParseError {
97                file_type: "Cargo.lock".into(),
98                source: Box::new(std::io::Error::other(e.to_string())),
99            })?;
100
101            let mut packages = ResolvedPackages::new();
102
103            let Some(root_table) = doc.as_table() else {
104                tracing::warn!("Cargo.lock root is not a table");
105                return Ok(packages);
106            };
107
108            let Some(package_array_val) = root_table.get("package") else {
109                tracing::warn!("Cargo.lock missing [[package]] array of tables");
110                return Ok(packages);
111            };
112
113            let Some(package_array) = package_array_val.as_array() else {
114                tracing::warn!("Cargo.lock [[package]] is not an array");
115                return Ok(packages);
116            };
117
118            for entry in package_array {
119                let Some(table) = entry.as_table() else {
120                    continue;
121                };
122
123                // Extract required fields
124                let Some(name) = table.get("name").and_then(|v| v.as_str()) else {
125                    tracing::warn!("Package missing name field");
126                    continue;
127                };
128
129                let Some(version) = table.get("version").and_then(|v| v.as_str()) else {
130                    tracing::warn!("Package '{}' missing version field", name);
131                    continue;
132                };
133
134                // Parse source (optional for path dependencies)
135                let source = parse_cargo_source(table.get("source").and_then(|v| v.as_str()));
136
137                // Parse dependencies array (optional)
138                let dependencies = parse_cargo_dependencies_from_table(table);
139
140                packages.insert(ResolvedPackage {
141                    name: name.to_string(),
142                    version: version.to_string(),
143                    source,
144                    dependencies,
145                });
146            }
147
148            tracing::info!(
149                "Parsed Cargo.lock: {} packages from {}",
150                packages.len(),
151                lockfile_path.display()
152            );
153
154            Ok(packages)
155        })
156    }
157}
158
159/// Parses Cargo source field into ResolvedSource.
160///
161/// # Source Formats
162///
163/// - `"registry+https://github.com/rust-lang/crates.io-index"` → Registry
164/// - `"git+https://github.com/user/repo#commit"` → Git
165/// - None (path dependencies don't have source field) → Path
166fn parse_cargo_source(source_str: Option<&str>) -> ResolvedSource {
167    let Some(source) = source_str else {
168        return ResolvedSource::Path {
169            path: String::new(),
170        };
171    };
172
173    if let Some(registry_url) = source.strip_prefix("registry+") {
174        ResolvedSource::Registry {
175            url: registry_url.to_string(),
176            checksum: String::new(),
177        }
178    } else if let Some(git_part) = source.strip_prefix("git+") {
179        let (url, rev) = if let Some((u, r)) = git_part.split_once('#') {
180            (u.to_string(), r.to_string())
181        } else {
182            (git_part.to_string(), String::new())
183        };
184
185        ResolvedSource::Git { url, rev }
186    } else {
187        ResolvedSource::Path {
188            path: source.to_string(),
189        }
190    }
191}
192
193/// Parses dependencies array from package table.
194///
195/// Dependencies are typically simple strings in Cargo.lock v4:
196/// ```toml
197/// dependencies = ["serde_derive", "syn"]
198/// ```
199fn parse_cargo_dependencies_from_table(table: &toml_span::value::Table<'_>) -> Vec<String> {
200    let Some(deps_value) = table.get("dependencies") else {
201        return vec![];
202    };
203
204    let Some(deps_array) = deps_value.as_array() else {
205        return vec![];
206    };
207
208    deps_array
209        .iter()
210        .filter_map(|item| {
211            // Simple string format (most common)
212            if let Some(s) = item.as_str() {
213                return Some(s.to_string());
214            }
215
216            // Table format (rare, extract "name" field)
217            if let Some(t) = item.as_table()
218                && let Some(name) = t.get("name").and_then(|v| v.as_str())
219            {
220                return Some(name.to_string());
221            }
222
223            None
224        })
225        .collect()
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    use std::assert_matches;
233
234    #[test]
235    fn test_parse_cargo_source_registry() {
236        let source = parse_cargo_source(Some(
237            "registry+https://github.com/rust-lang/crates.io-index",
238        ));
239
240        match source {
241            ResolvedSource::Registry { url, .. } => {
242                assert_eq!(url, "https://github.com/rust-lang/crates.io-index");
243            }
244            _ => panic!("Expected Registry source"),
245        }
246    }
247
248    #[test]
249    fn test_parse_cargo_source_git() {
250        let source = parse_cargo_source(Some("git+https://github.com/user/repo#abc123"));
251
252        match source {
253            ResolvedSource::Git { url, rev } => {
254                assert_eq!(url, "https://github.com/user/repo");
255                assert_eq!(rev, "abc123");
256            }
257            _ => panic!("Expected Git source"),
258        }
259    }
260
261    #[test]
262    fn test_parse_cargo_source_git_no_commit() {
263        let source = parse_cargo_source(Some("git+https://github.com/user/repo"));
264
265        match source {
266            ResolvedSource::Git { url, rev } => {
267                assert_eq!(url, "https://github.com/user/repo");
268                assert!(rev.is_empty());
269            }
270            _ => panic!("Expected Git source"),
271        }
272    }
273
274    #[test]
275    fn test_parse_cargo_source_path() {
276        let source = parse_cargo_source(None);
277
278        match source {
279            ResolvedSource::Path { path } => {
280                assert!(path.is_empty());
281            }
282            _ => panic!("Expected Path source"),
283        }
284    }
285
286    #[tokio::test]
287    async fn test_parse_cargo_lock_rejects_excessive_nesting() {
288        // Well past MAX_TOML_NESTING_DEPTH (64) but far below the depth
289        // that would actually overflow the stack, so the guard is what's
290        // being exercised here, not the crash itself.
291        let lockfile_content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
292
293        let temp_dir = tempfile::tempdir().unwrap();
294        let lockfile_path = temp_dir.path().join("Cargo.lock");
295        std::fs::write(&lockfile_path, lockfile_content).unwrap();
296
297        let parser = CargoLockParser;
298        let result = parser.parse_lockfile(&lockfile_path).await;
299        assert_matches!(
300            result,
301            Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.lock"
302        );
303    }
304
305    #[tokio::test]
306    async fn test_parse_cargo_lock_rejects_excessive_inline_table_nesting() {
307        // Regression test for impl-critic C3: this is the exact shape
308        // (deeply nested inline tables inside a `[[package]]` field, on the
309        // lock file path that runs inside `tokio::spawn` on a 2 MiB worker
310        // stack) that still SIGABRT'd the real debug binary when
311        // `MAX_TOML_NESTING_DEPTH` was 256 — nested inline tables cost more
312        // stack per level than nested arrays, so an array-shaped test alone
313        // does not exercise the binding constraint.
314        let depth = deps_core::MAX_TOML_NESTING_DEPTH + 1;
315        let lockfile_content = format!(
316            "[[package]]\nname = \"evil\"\nversion = \"1.0.0\"\nx = {}1{}\n",
317            "{a=".repeat(depth),
318            "}".repeat(depth)
319        );
320
321        let temp_dir = tempfile::tempdir().unwrap();
322        let lockfile_path = temp_dir.path().join("Cargo.lock");
323        std::fs::write(&lockfile_path, lockfile_content).unwrap();
324
325        let parser = CargoLockParser;
326        let result = parser.parse_lockfile(&lockfile_path).await;
327        assert_matches!(
328            result,
329            Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.lock"
330        );
331    }
332
333    #[tokio::test]
334    async fn test_parse_cargo_lock_rejects_dotted_header_nesting() {
335        // Regression test for impl-critic C4: this is the critic's exact
336        // ~875-byte minimal repro — a dotted table header (`[package.a.a...]`)
337        // nests one table level per `.` segment with zero bracket
338        // characters, so the bracket-only version of the guard scored this
339        // depth 0 and let it straight through to `toml_span::parse`, which
340        // still stack-overflowed the real `tokio-rt-worker` thread.
341        let depth = deps_core::MAX_TOML_NESTING_DEPTH + 1;
342        let dotted_lockfile_content = format!(
343            "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n\n[package{}]\ny = 1\n",
344            ".a".repeat(depth)
345        );
346
347        let temp_dir = tempfile::tempdir().unwrap();
348        let lockfile_path = temp_dir.path().join("Cargo.lock");
349        std::fs::write(&lockfile_path, dotted_lockfile_content).unwrap();
350
351        let parser = CargoLockParser;
352        let result = parser.parse_lockfile(&lockfile_path).await;
353        assert_matches!(
354            result,
355            Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.lock"
356        );
357    }
358
359    #[tokio::test]
360    async fn test_parse_simple_cargo_lock() {
361        let lockfile_content = r#"
362# This file is automatically @generated by Cargo.
363version = 4
364
365[[package]]
366name = "serde"
367version = "1.0.195"
368source = "registry+https://github.com/rust-lang/crates.io-index"
369checksum = "abc123"
370dependencies = [
371    "serde_derive",
372]
373
374[[package]]
375name = "serde_derive"
376version = "1.0.195"
377source = "registry+https://github.com/rust-lang/crates.io-index"
378checksum = "def456"
379"#;
380
381        let temp_dir = tempfile::tempdir().unwrap();
382        let lockfile_path = temp_dir.path().join("Cargo.lock");
383        std::fs::write(&lockfile_path, lockfile_content).unwrap();
384
385        let parser = CargoLockParser;
386        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
387
388        assert_eq!(resolved.len(), 2);
389        assert_eq!(resolved.get_version("serde"), Some("1.0.195"));
390        assert_eq!(resolved.get_version("serde_derive"), Some("1.0.195"));
391
392        let serde_pkg = resolved.get("serde").unwrap();
393        assert_eq!(serde_pkg.dependencies.len(), 1);
394        assert_eq!(serde_pkg.dependencies[0], "serde_derive");
395    }
396
397    #[tokio::test]
398    async fn test_parse_cargo_lock_with_git() {
399        let lockfile_content = r#"
400version = 4
401
402[[package]]
403name = "my-git-dep"
404version = "0.1.0"
405source = "git+https://github.com/user/repo#abc123"
406"#;
407
408        let temp_dir = tempfile::tempdir().unwrap();
409        let lockfile_path = temp_dir.path().join("Cargo.lock");
410        std::fs::write(&lockfile_path, lockfile_content).unwrap();
411
412        let parser = CargoLockParser;
413        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
414
415        assert_eq!(resolved.len(), 1);
416        let pkg = resolved.get("my-git-dep").unwrap();
417        assert_eq!(pkg.version, "0.1.0");
418
419        match &pkg.source {
420            ResolvedSource::Git { url, rev } => {
421                assert_eq!(url, "https://github.com/user/repo");
422                assert_eq!(rev, "abc123");
423            }
424            _ => panic!("Expected Git source"),
425        }
426    }
427
428    #[tokio::test]
429    async fn test_parse_empty_cargo_lock() {
430        let lockfile_content = r"
431version = 4
432";
433
434        let temp_dir = tempfile::tempdir().unwrap();
435        let lockfile_path = temp_dir.path().join("Cargo.lock");
436        std::fs::write(&lockfile_path, lockfile_content).unwrap();
437
438        let parser = CargoLockParser;
439        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
440
441        assert_eq!(resolved.len(), 0);
442        assert!(resolved.is_empty());
443    }
444
445    #[tokio::test]
446    async fn test_parse_malformed_cargo_lock() {
447        let lockfile_content = "not valid toml {{{";
448
449        let temp_dir = tempfile::tempdir().unwrap();
450        let lockfile_path = temp_dir.path().join("Cargo.lock");
451        std::fs::write(&lockfile_path, lockfile_content).unwrap();
452
453        let parser = CargoLockParser;
454        let result = parser.parse_lockfile(&lockfile_path).await;
455
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn test_locate_lockfile_same_directory() {
461        let temp_dir = tempfile::tempdir().unwrap();
462        let manifest_path = temp_dir.path().join("Cargo.toml");
463        let lock_path = temp_dir.path().join("Cargo.lock");
464
465        std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
466        std::fs::write(&lock_path, "version = 4").unwrap();
467
468        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
469        let parser = CargoLockParser;
470
471        let located = parser.locate_lockfile(&manifest_uri);
472        assert!(located.is_some());
473        assert_eq!(located.unwrap(), lock_path);
474    }
475
476    #[test]
477    fn test_locate_lockfile_workspace_root() {
478        let temp_dir = tempfile::tempdir().unwrap();
479        let workspace_lock = temp_dir.path().join("Cargo.lock");
480        let member_dir = temp_dir.path().join("crates").join("member");
481        std::fs::create_dir_all(&member_dir).unwrap();
482        let member_manifest = member_dir.join("Cargo.toml");
483
484        std::fs::write(&workspace_lock, "version = 4").unwrap();
485        std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
486
487        let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
488        let parser = CargoLockParser;
489
490        let located = parser.locate_lockfile(&manifest_uri);
491        assert!(located.is_some());
492        assert_eq!(located.unwrap(), workspace_lock);
493    }
494
495    #[test]
496    fn test_locate_lockfile_not_found() {
497        let temp_dir = tempfile::tempdir().unwrap();
498        let manifest_path = temp_dir.path().join("Cargo.toml");
499        std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
500
501        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
502        let parser = CargoLockParser;
503
504        let located = parser.locate_lockfile(&manifest_uri);
505        assert!(located.is_none());
506    }
507
508    #[test]
509    fn test_is_lockfile_stale_not_modified() {
510        let temp_dir = tempfile::tempdir().unwrap();
511        let lockfile_path = temp_dir.path().join("Cargo.lock");
512        std::fs::write(&lockfile_path, "version = 4").unwrap();
513
514        let mtime = std::fs::metadata(&lockfile_path)
515            .unwrap()
516            .modified()
517            .unwrap();
518        let parser = CargoLockParser;
519
520        assert!(
521            !parser.is_lockfile_stale(&lockfile_path, mtime),
522            "Lock file should not be stale when mtime matches"
523        );
524    }
525
526    #[test]
527    fn test_is_lockfile_stale_modified() {
528        let temp_dir = tempfile::tempdir().unwrap();
529        let lockfile_path = temp_dir.path().join("Cargo.lock");
530        std::fs::write(&lockfile_path, "version = 4").unwrap();
531
532        let old_time = std::time::UNIX_EPOCH;
533        let parser = CargoLockParser;
534
535        assert!(
536            parser.is_lockfile_stale(&lockfile_path, old_time),
537            "Lock file should be stale when last_modified is old"
538        );
539    }
540
541    #[test]
542    fn test_is_lockfile_stale_deleted() {
543        let parser = CargoLockParser;
544        let non_existent = std::path::Path::new("/nonexistent/Cargo.lock");
545
546        assert!(
547            parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
548            "Non-existent lock file should be considered stale"
549        );
550    }
551
552    #[test]
553    fn test_is_lockfile_stale_future_time() {
554        let temp_dir = tempfile::tempdir().unwrap();
555        let lockfile_path = temp_dir.path().join("Cargo.lock");
556        std::fs::write(&lockfile_path, "version = 4").unwrap();
557
558        // Use a time far in the future
559        let future_time = std::time::SystemTime::now() + std::time::Duration::from_hours(24);
560        let parser = CargoLockParser;
561
562        assert!(
563            !parser.is_lockfile_stale(&lockfile_path, future_time),
564            "Lock file should not be stale when last_modified is in the future"
565        );
566    }
567}