provenant/parsers/
go_mod_graph.rs1use 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);