Skip to main content

provenant/parsers/
go_mod_graph.rs

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