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