Skip to main content

provenant/parsers/
go_mod_graph.rs

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