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