Skip to main content

provenant/parsers/
go_mod_graph.rs

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