Skip to main content

provenant/parsers/
go_mod_graph.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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);