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