deps_go/
lockfile.rs

1//! go.sum lock file parsing.
2//!
3//! Parses go.sum files to extract resolved dependency versions.
4//! go.sum contains checksums for all modules used in a build, including
5//! transitive dependencies and multiple versions.
6//!
7//! # go.sum Format
8//!
9//! Each line in go.sum has the format:
10//! ```text
11//! module_path version hash
12//! ```
13//!
14//! Example:
15//! ```text
16//! github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
17//! github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs=
18//! golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrq...
19//! golang.org/x/sync v0.5.0/go.mod h1:RxMgew5V...
20//! ```
21//!
22//! # Line Types
23//!
24//! - Lines ending with `/go.mod` are module file checksums (skipped for version resolution)
25//! - Lines with `h1:hash` are actual module content checksums (used for version resolution)
26//! - A module may appear multiple times with different versions
27
28use async_trait::async_trait;
29use deps_core::error::{DepsError, Result};
30use deps_core::lockfile::{
31    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
32    locate_lockfile_for_manifest,
33};
34use std::path::{Path, PathBuf};
35use tower_lsp_server::ls_types::Uri;
36
37/// go.sum file parser.
38///
39/// Implements lock file parsing for Go modules.
40/// Supports both project-level and workspace-level go.sum files.
41///
42/// # Lock File Location
43///
44/// The parser searches for go.sum in the following order:
45/// 1. Same directory as go.mod
46/// 2. Parent directories (up to 5 levels) for workspace root
47///
48/// # Examples
49///
50/// ```no_run
51/// use deps_go::lockfile::GoSumParser;
52/// use deps_core::lockfile::LockFileProvider;
53/// use tower_lsp_server::ls_types::Uri;
54///
55/// # async fn example() -> deps_core::error::Result<()> {
56/// let parser = GoSumParser;
57/// let manifest_uri = Uri::from_file_path("/path/to/go.mod").unwrap();
58///
59/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) {
60///     let resolved = parser.parse_lockfile(&lockfile_path).await?;
61///     println!("Found {} resolved packages", resolved.len());
62/// }
63/// # Ok(())
64/// # }
65/// ```
66pub struct GoSumParser;
67
68impl GoSumParser {
69    /// Lock file names for Go ecosystem.
70    const LOCKFILE_NAMES: &'static [&'static str] = &["go.sum"];
71}
72
73#[async_trait]
74impl LockFileProvider for GoSumParser {
75    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
76        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
77    }
78
79    async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages> {
80        let content = tokio::fs::read_to_string(lockfile_path)
81            .await
82            .map_err(|e| DepsError::ParseError {
83                file_type: format!("go.sum at {}", lockfile_path.display()),
84                source: Box::new(e),
85            })?;
86
87        Ok(parse_go_sum(&content))
88    }
89}
90
91/// Parses go.sum content and returns resolved packages.
92///
93/// Filters out `/go.mod` entries (module file checksums) and only processes
94/// module content checksums (lines with `h1:` hashes).
95///
96/// When a module appears multiple times with different versions, the **last**
97/// occurrence is used. Go's go.sum file typically has older versions first
98/// (from when they were initially added) and newer versions appended later
99/// (after upgrades). The last version represents the current state.
100///
101/// # Arguments
102///
103/// * `content` - The go.sum file content
104///
105/// # Returns
106///
107/// A collection of resolved packages with their versions
108///
109/// # Examples
110///
111/// ```
112/// use deps_go::lockfile::parse_go_sum;
113///
114/// let content = r#"
115/// github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
116/// github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs=
117/// "#;
118///
119/// let packages = parse_go_sum(content);
120/// assert_eq!(packages.get_version("github.com/gin-gonic/gin"), Some("v1.9.1"));
121/// ```
122pub fn parse_go_sum(content: &str) -> ResolvedPackages {
123    let mut packages = ResolvedPackages::new();
124
125    for line in content.lines() {
126        let line = line.trim();
127        if line.is_empty() {
128            continue;
129        }
130
131        // Skip /go.mod entries (we only want the h1: hash entries)
132        if line.contains("/go.mod ") {
133            continue;
134        }
135
136        // Parse: module_path version h1:hash
137        // Valid go.sum lines must have at least 3 parts (module, version, hash)
138        let parts: Vec<&str> = line.split_whitespace().collect();
139        if parts.len() >= 3 {
140            let module_path = parts[0];
141            let version = parts[1];
142            let checksum = parts[2];
143
144            // Validate that the hash starts with 'h1:' (standard Go checksum format)
145            // This filters out malformed lines
146            if !checksum.starts_with("h1:") {
147                continue;
148            }
149
150            // Always insert/overwrite (last occurrence wins)
151            // Go.sum files have older versions first, newer versions appended later
152            packages.insert(ResolvedPackage {
153                name: module_path.to_string(),
154                version: version.to_string(),
155                source: ResolvedSource::Registry {
156                    url: "https://proxy.golang.org".to_string(),
157                    checksum: checksum.to_string(),
158                },
159                dependencies: vec![],
160            });
161        }
162    }
163
164    packages
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_parse_simple_go_sum() {
173        let content = r"
174github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
175github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs=
176";
177        let packages = parse_go_sum(content);
178        assert_eq!(
179            packages.get_version("github.com/gin-gonic/gin"),
180            Some("v1.9.1")
181        );
182    }
183
184    #[test]
185    fn test_parse_multiple_modules() {
186        let content = r"
187github.com/gin-gonic/gin v1.9.1 h1:hash1=
188golang.org/x/sync v0.5.0 h1:hash2=
189github.com/stretchr/testify v1.8.4 h1:hash3=
190";
191        let packages = parse_go_sum(content);
192        assert_eq!(packages.len(), 3);
193        assert_eq!(
194            packages.get_version("github.com/gin-gonic/gin"),
195            Some("v1.9.1")
196        );
197        assert_eq!(packages.get_version("golang.org/x/sync"), Some("v0.5.0"));
198        assert_eq!(
199            packages.get_version("github.com/stretchr/testify"),
200            Some("v1.8.4")
201        );
202    }
203
204    #[test]
205    fn test_skip_go_mod_entries() {
206        let content = r"
207github.com/gin-gonic/gin v1.9.1/go.mod h1:mod_hash=
208github.com/gin-gonic/gin v1.9.1 h1:actual_hash=
209";
210        let packages = parse_go_sum(content);
211        assert_eq!(packages.len(), 1);
212        assert_eq!(
213            packages.get_version("github.com/gin-gonic/gin"),
214            Some("v1.9.1")
215        );
216    }
217
218    #[test]
219    fn test_last_version_wins() {
220        let content = r"
221github.com/pkg/errors v0.8.0 h1:hash1=
222github.com/pkg/errors v0.9.1 h1:hash2=
223";
224        let packages = parse_go_sum(content);
225        assert_eq!(packages.len(), 1);
226        // Last occurrence should win (newer version added after upgrade)
227        assert_eq!(
228            packages.get_version("github.com/pkg/errors"),
229            Some("v0.9.1")
230        );
231    }
232
233    #[test]
234    fn test_empty_content() {
235        let packages = parse_go_sum("");
236        assert!(packages.is_empty());
237    }
238
239    #[test]
240    fn test_whitespace_handling() {
241        let content = "  github.com/gin-gonic/gin   v1.9.1   h1:hash=  \n";
242        let packages = parse_go_sum(content);
243        assert_eq!(
244            packages.get_version("github.com/gin-gonic/gin"),
245            Some("v1.9.1")
246        );
247    }
248
249    #[test]
250    fn test_lockfile_provider_trait() {
251        let parser = GoSumParser;
252        let manifest_path = "/test/go.mod";
253        let uri = Uri::from_file_path(manifest_path).unwrap();
254
255        // Just verify the trait methods are callable
256        let _ = parser.locate_lockfile(&uri);
257    }
258
259    #[test]
260    fn test_pseudo_version() {
261        let content = "golang.org/x/tools v0.0.0-20191109021931-daa7c04131f5 h1:hash=\n";
262        let packages = parse_go_sum(content);
263        assert_eq!(
264            packages.get_version("golang.org/x/tools"),
265            Some("v0.0.0-20191109021931-daa7c04131f5")
266        );
267    }
268
269    #[test]
270    fn test_incompatible_version() {
271        let content = "github.com/some/module v2.0.0+incompatible h1:hash=\n";
272        let packages = parse_go_sum(content);
273        assert_eq!(
274            packages.get_version("github.com/some/module"),
275            Some("v2.0.0+incompatible")
276        );
277    }
278
279    #[test]
280    fn test_malformed_line_ignored() {
281        let content = r"
282github.com/gin-gonic/gin v1.9.1 h1:hash=
283invalid line with only one part
284github.com/valid/pkg v1.0.0 h1:valid_hash=
285";
286        let packages = parse_go_sum(content);
287        // Should only parse the valid lines
288        assert_eq!(packages.len(), 2);
289        assert_eq!(
290            packages.get_version("github.com/gin-gonic/gin"),
291            Some("v1.9.1")
292        );
293        assert_eq!(packages.get_version("github.com/valid/pkg"), Some("v1.0.0"));
294    }
295
296    #[tokio::test]
297    async fn test_parse_lockfile_simple() {
298        let lockfile_content = r"
299github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
300github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs=
301golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrq=
302golang.org/x/sync v0.5.0/go.mod h1:RxMgew5V=
303";
304
305        let temp_dir = tempfile::tempdir().unwrap();
306        let lockfile_path = temp_dir.path().join("go.sum");
307        std::fs::write(&lockfile_path, lockfile_content).unwrap();
308
309        let parser = GoSumParser;
310        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
311
312        assert_eq!(resolved.len(), 2);
313        assert_eq!(
314            resolved.get_version("github.com/gin-gonic/gin"),
315            Some("v1.9.1")
316        );
317        assert_eq!(resolved.get_version("golang.org/x/sync"), Some("v0.5.0"));
318    }
319
320    #[tokio::test]
321    async fn test_parse_lockfile_empty() {
322        let lockfile_content = "";
323
324        let temp_dir = tempfile::tempdir().unwrap();
325        let lockfile_path = temp_dir.path().join("go.sum");
326        std::fs::write(&lockfile_path, lockfile_content).unwrap();
327
328        let parser = GoSumParser;
329        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
330
331        assert_eq!(resolved.len(), 0);
332        assert!(resolved.is_empty());
333    }
334
335    #[tokio::test]
336    async fn test_parse_lockfile_not_found() {
337        let temp_dir = tempfile::tempdir().unwrap();
338        let lockfile_path = temp_dir.path().join("nonexistent.sum");
339
340        let parser = GoSumParser;
341        let result = parser.parse_lockfile(&lockfile_path).await;
342
343        assert!(result.is_err());
344    }
345
346    #[test]
347    fn test_locate_lockfile_same_directory() {
348        let temp_dir = tempfile::tempdir().unwrap();
349        let manifest_path = temp_dir.path().join("go.mod");
350        let lock_path = temp_dir.path().join("go.sum");
351
352        std::fs::write(&manifest_path, "module test").unwrap();
353        std::fs::write(&lock_path, "").unwrap();
354
355        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
356        let parser = GoSumParser;
357
358        let located = parser.locate_lockfile(&manifest_uri);
359        assert!(located.is_some());
360        assert_eq!(located.unwrap(), lock_path);
361    }
362
363    #[test]
364    fn test_locate_lockfile_workspace_root() {
365        let temp_dir = tempfile::tempdir().unwrap();
366        let workspace_lock = temp_dir.path().join("go.sum");
367        let member_dir = temp_dir.path().join("packages").join("member");
368        std::fs::create_dir_all(&member_dir).unwrap();
369        let member_manifest = member_dir.join("go.mod");
370
371        std::fs::write(&workspace_lock, "").unwrap();
372        std::fs::write(&member_manifest, "module member").unwrap();
373
374        let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
375        let parser = GoSumParser;
376
377        let located = parser.locate_lockfile(&manifest_uri);
378        assert!(located.is_some());
379        assert_eq!(located.unwrap(), workspace_lock);
380    }
381
382    #[test]
383    fn test_locate_lockfile_not_found() {
384        let temp_dir = tempfile::tempdir().unwrap();
385        let manifest_path = temp_dir.path().join("go.mod");
386        std::fs::write(&manifest_path, "module test").unwrap();
387
388        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
389        let parser = GoSumParser;
390
391        let located = parser.locate_lockfile(&manifest_uri);
392        assert!(located.is_none());
393    }
394
395    #[test]
396    fn test_is_lockfile_stale_not_modified() {
397        let temp_dir = tempfile::tempdir().unwrap();
398        let lockfile_path = temp_dir.path().join("go.sum");
399        std::fs::write(&lockfile_path, "").unwrap();
400
401        let mtime = std::fs::metadata(&lockfile_path)
402            .unwrap()
403            .modified()
404            .unwrap();
405        let parser = GoSumParser;
406
407        assert!(
408            !parser.is_lockfile_stale(&lockfile_path, mtime),
409            "Lock file should not be stale when mtime matches"
410        );
411    }
412
413    #[test]
414    fn test_is_lockfile_stale_modified() {
415        let temp_dir = tempfile::tempdir().unwrap();
416        let lockfile_path = temp_dir.path().join("go.sum");
417        std::fs::write(&lockfile_path, "").unwrap();
418
419        let old_time = std::time::UNIX_EPOCH;
420        let parser = GoSumParser;
421
422        assert!(
423            parser.is_lockfile_stale(&lockfile_path, old_time),
424            "Lock file should be stale when last_modified is old"
425        );
426    }
427
428    #[test]
429    fn test_is_lockfile_stale_deleted() {
430        let parser = GoSumParser;
431        let non_existent = std::path::Path::new("/nonexistent/go.sum");
432
433        assert!(
434            parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
435            "Non-existent lock file should be considered stale"
436        );
437    }
438
439    #[test]
440    fn test_is_lockfile_stale_future_time() {
441        let temp_dir = tempfile::tempdir().unwrap();
442        let lockfile_path = temp_dir.path().join("go.sum");
443        std::fs::write(&lockfile_path, "").unwrap();
444
445        // Use a time far in the future
446        let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); // +1 day
447        let parser = GoSumParser;
448
449        assert!(
450            !parser.is_lockfile_stale(&lockfile_path, future_time),
451            "Lock file should not be stale when last_modified is in the future"
452        );
453    }
454
455    #[test]
456    fn test_parse_go_sum_with_checksum() {
457        let content =
458            "github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\n";
459        let packages = parse_go_sum(content);
460
461        let pkg = packages.get("github.com/gin-gonic/gin").unwrap();
462        assert_eq!(pkg.version, "v1.9.1");
463
464        match &pkg.source {
465            ResolvedSource::Registry { url, checksum } => {
466                assert_eq!(url, "https://proxy.golang.org");
467                assert_eq!(checksum, "h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=");
468            }
469            _ => panic!("Expected Registry source"),
470        }
471    }
472
473    #[test]
474    fn test_parse_go_sum_dependencies_empty() {
475        let content = "github.com/gin-gonic/gin v1.9.1 h1:hash=\n";
476        let packages = parse_go_sum(content);
477
478        let pkg = packages.get("github.com/gin-gonic/gin").unwrap();
479        assert!(pkg.dependencies.is_empty());
480    }
481}