Skip to main content

provenant/parsers/
go_mod_graph.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use log::warn;
6
7use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
8
9use super::PackageParser;
10use super::go::{create_golang_purl, split_module_path};
11
12const PACKAGE_TYPE: PackageType = PackageType::Golang;
13
14fn default_package_data() -> PackageData {
15    PackageData {
16        package_type: Some(PACKAGE_TYPE),
17        primary_language: Some("Go".to_string()),
18        datasource_id: Some(DatasourceId::GoModGraph),
19        ..Default::default()
20    }
21}
22
23pub struct GoModGraphParser;
24
25impl PackageParser for GoModGraphParser {
26    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
27
28    fn is_match(path: &Path) -> bool {
29        path.file_name()
30            .and_then(|name| name.to_str())
31            .is_some_and(|name| matches!(name, "go.mod.graph" | "go.modgraph"))
32    }
33
34    fn extract_packages(path: &Path) -> Vec<PackageData> {
35        let content = match fs::read_to_string(path) {
36            Ok(c) => c,
37            Err(e) => {
38                warn!("Failed to read Go module graph at {:?}: {}", path, e);
39                return vec![default_package_data()];
40            }
41        };
42
43        vec![parse_go_mod_graph(&content)]
44    }
45}
46
47#[derive(Debug, Clone)]
48struct GraphModule<'a> {
49    module_path: &'a str,
50    version: Option<&'a str>,
51}
52
53pub(crate) fn parse_go_mod_graph(content: &str) -> PackageData {
54    let mut root_module: Option<String> = None;
55    let mut dependency_map: BTreeMap<String, Dependency> = BTreeMap::new();
56
57    for line in content.lines() {
58        let trimmed = line.trim();
59        if trimmed.is_empty() {
60            continue;
61        }
62
63        let mut parts = trimmed.split_whitespace();
64        let Some(source) = parts.next() else {
65            continue;
66        };
67        let Some(target) = parts.next() else {
68            continue;
69        };
70        if parts.next().is_some() {
71            continue;
72        }
73
74        let source = parse_graph_module(source);
75        let target = parse_graph_module(target);
76
77        if source.version.is_none() && root_module.is_none() {
78            root_module = Some(source.module_path.to_string());
79        }
80
81        let Some(purl) = create_golang_purl(target.module_path, target.version) else {
82            continue;
83        };
84
85        dependency_map
86            .entry(purl.clone())
87            .and_modify(|existing: &mut Dependency| {
88                if source.version.is_none() {
89                    existing.is_direct = Some(true);
90                }
91            })
92            .or_insert_with(|| Dependency {
93                purl: Some(purl),
94                extracted_requirement: target.version.map(str::to_string),
95                scope: Some("dependency".to_string()),
96                is_runtime: Some(true),
97                is_optional: Some(false),
98                is_pinned: Some(target.version.is_some()),
99                is_direct: Some(source.version.is_none()),
100                resolved_package: None,
101                extra_data: None,
102            });
103    }
104
105    let (namespace, name): (Option<String>, String) = root_module
106        .as_deref()
107        .map(split_module_path)
108        .unwrap_or((None, String::new()));
109
110    let homepage_url = root_module
111        .as_ref()
112        .map(|module| format!("https://pkg.go.dev/{module}"));
113
114    let vcs_url = root_module
115        .as_ref()
116        .map(|module| format!("https://{module}.git"));
117
118    let purl = root_module
119        .as_deref()
120        .and_then(|module| create_golang_purl(module, None));
121
122    PackageData {
123        package_type: Some(PACKAGE_TYPE),
124        primary_language: Some("Go".to_string()),
125        datasource_id: Some(DatasourceId::GoModGraph),
126        namespace,
127        name: (!name.is_empty()).then_some(name),
128        homepage_url: homepage_url.clone(),
129        repository_homepage_url: homepage_url,
130        vcs_url,
131        purl,
132        dependencies: dependency_map.into_values().collect(),
133        ..Default::default()
134    }
135}
136
137fn parse_graph_module(token: &str) -> GraphModule<'_> {
138    if let Some((module_path, version)) = token.rsplit_once('@') {
139        GraphModule {
140            module_path,
141            version: Some(version),
142        }
143    } else {
144        GraphModule {
145            module_path: token,
146            version: None,
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::models::DatasourceId;
155    use tempfile::NamedTempFile;
156
157    #[test]
158    fn test_is_match() {
159        assert!(GoModGraphParser::is_match(Path::new("go.mod.graph")));
160        assert!(GoModGraphParser::is_match(Path::new("go.modgraph")));
161        assert!(!GoModGraphParser::is_match(Path::new("go.mod")));
162    }
163
164    #[test]
165    fn test_parse_go_mod_graph_direct_and_transitive() {
166        let content = "example.com/myapp github.com/gin-gonic/gin@v1.9.0\nexample.com/myapp github.com/stretchr/testify@v1.8.4\ngithub.com/gin-gonic/gin@v1.9.0 golang.org/x/net@v0.10.0\n";
167
168        let package_data = parse_go_mod_graph(content);
169
170        assert_eq!(package_data.datasource_id, Some(DatasourceId::GoModGraph));
171        assert_eq!(package_data.namespace.as_deref(), Some("example.com"));
172        assert_eq!(package_data.name.as_deref(), Some("myapp"));
173        assert_eq!(
174            package_data.purl.as_deref(),
175            Some("pkg:golang/example.com/myapp")
176        );
177        assert_eq!(package_data.dependencies.len(), 3);
178
179        let direct = package_data
180            .dependencies
181            .iter()
182            .find(|dep| dep.purl.as_deref() == Some("pkg:golang/github.com/gin-gonic/gin@v1.9.0"))
183            .unwrap();
184        assert_eq!(direct.is_direct, Some(true));
185
186        let transitive = package_data
187            .dependencies
188            .iter()
189            .find(|dep| dep.purl.as_deref() == Some("pkg:golang/golang.org/x/net@v0.10.0"))
190            .unwrap();
191        assert_eq!(transitive.is_direct, Some(false));
192    }
193
194    #[test]
195    fn test_extract_packages_graceful_error_handling() {
196        let path = Path::new("/nonexistent/path/go.mod.graph");
197        let result = GoModGraphParser::extract_first_package(path);
198
199        assert_eq!(result.package_type, Some(PackageType::Golang));
200        assert_eq!(result.datasource_id, Some(DatasourceId::GoModGraph));
201        assert!(result.dependencies.is_empty());
202    }
203
204    #[test]
205    fn test_extract_packages_reads_file() {
206        let file = NamedTempFile::new().unwrap();
207        fs::write(
208            file.path(),
209            "example.com/myapp github.com/gin-gonic/gin@v1.9.0\n",
210        )
211        .unwrap();
212
213        let package_data = GoModGraphParser::extract_first_package(file.path());
214
215        assert_eq!(package_data.name.as_deref(), Some("myapp"));
216        assert_eq!(package_data.dependencies.len(), 1);
217    }
218}
219
220crate::register_parser!(
221    "Go module graph file",
222    &["*go.mod.graph", "*go.modgraph"],
223    "golang",
224    "Go",
225    Some("https://go.dev/ref/mod#go-mod-graph"),
226);