Skip to main content

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