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};
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}