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