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