Skip to main content

provenant/parsers/
gitmodules.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Git submodule manifest files (`.gitmodules`).
5//!
6//! Extracts submodule dependencies from `.gitmodules` files, treating
7//! git submodules as package dependencies.
8//!
9//! # Supported Formats
10//! - `.gitmodules` (Git submodule configuration)
11//!
12//! # Key Features
13//! - Parses INI-style `.gitmodules` format
14//! - Extracts submodule name, path, and URL
15//! - Generates purl for GitHub/GitLab URLs when possible
16//! - Reports submodules as dependencies
17//!
18//! # Implementation Notes
19//! - Git submodules are treated as dependencies of the containing repository
20//! - URLs are parsed to extract package name when possible
21//! - Supports both https and git@ URL formats
22
23use std::collections::HashMap;
24use std::path::Path;
25
26use crate::parser_warn as warn;
27
28use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
29use crate::parsers::utils::{
30    CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field,
31};
32
33use super::PackageParser;
34use super::metadata::ParserMetadata;
35
36const PACKAGE_TYPE: PackageType = PackageType::Github;
37
38fn default_package_data() -> PackageData {
39    PackageData {
40        package_type: Some(PACKAGE_TYPE),
41        datasource_id: Some(DatasourceId::Gitmodules),
42        ..Default::default()
43    }
44}
45
46pub struct GitmodulesParser;
47
48impl PackageParser for GitmodulesParser {
49    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
50
51    fn metadata() -> Vec<ParserMetadata> {
52        vec![ParserMetadata {
53            description: "Git submodules manifest",
54            file_patterns: &["**/.gitmodules"],
55            package_type: "gitmodules",
56            primary_language: "",
57            documentation_url: Some("https://git-scm.com/docs/gitmodules"),
58        }]
59    }
60
61    fn is_match(path: &Path) -> bool {
62        path.file_name().is_some_and(|name| name == ".gitmodules")
63    }
64
65    fn extract_packages(path: &Path) -> Vec<PackageData> {
66        let content = match read_file_to_string(path, None) {
67            Ok(c) => c,
68            Err(e) => {
69                warn!("Failed to read .gitmodules {:?}: {}", path, e);
70                return vec![default_package_data()];
71            }
72        };
73
74        let submodules = parse_gitmodules(&content);
75        if submodules.is_empty() {
76            return vec![default_package_data()];
77        }
78
79        let submodules_limit = capped_iteration_limit(submodules.len(), ".gitmodules submodules");
80        let dependencies: Vec<Dependency> = submodules
81            .into_iter()
82            .take(submodules_limit)
83            .map(|sub| Dependency {
84                purl: sub.purl.map(truncate_field),
85                extracted_requirement: Some(truncate_field(format!("{} at {}", sub.path, sub.url))),
86                scope: Some(truncate_field("runtime".to_string())),
87                is_runtime: Some(true),
88                is_optional: Some(false),
89                is_direct: Some(true),
90                resolved_package: None,
91                extra_data: None,
92                is_pinned: Some(false),
93            })
94            .collect();
95
96        vec![PackageData {
97            package_type: Some(PACKAGE_TYPE),
98            datasource_id: Some(DatasourceId::Gitmodules),
99            dependencies,
100            ..Default::default()
101        }]
102    }
103}
104
105struct Submodule {
106    path: String,
107    url: String,
108    purl: Option<String>,
109}
110
111fn parse_gitmodules(content: &str) -> Vec<Submodule> {
112    let mut submodules = Vec::new();
113    let mut current_section: Option<HashMap<String, String>> = None;
114    let mut current_name: Option<String> = None;
115
116    for line in content.lines().capped(".gitmodules lines") {
117        let line = line.trim();
118
119        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
120            continue;
121        }
122
123        if line.starts_with('[') && line.ends_with(']') {
124            if let Some(section) = current_section.take()
125                && let Some(name) = current_name.take()
126                && let Some(submodule) = build_submodule(name, section)
127            {
128                submodules.push(submodule);
129            }
130
131            let section_name = &line[1..line.len() - 1];
132            if let Some(stripped) = section_name.strip_prefix("submodule ") {
133                current_name = Some(truncate_field(stripped.trim_matches('"').to_string()));
134                current_section = Some(HashMap::new());
135            }
136        } else if let Some(ref mut section) = current_section
137            && let Some((key, value)) = line.split_once('=')
138        {
139            let key = truncate_field(key.trim().to_string());
140            let value = truncate_field(value.trim().to_string());
141            section.insert(key, value);
142        }
143    }
144
145    if let Some(section) = current_section
146        && let Some(name) = current_name
147        && let Some(submodule) = build_submodule(name, section)
148    {
149        submodules.push(submodule);
150    }
151
152    submodules
153}
154
155fn build_submodule(_name: String, section: HashMap<String, String>) -> Option<Submodule> {
156    let path = truncate_field(section.get("path").cloned().unwrap_or_default());
157    let url = truncate_field(section.get("url").cloned().unwrap_or_default());
158
159    if path.is_empty() && url.is_empty() {
160        return None;
161    }
162
163    let purl = build_purl_from_url(&url);
164
165    Some(Submodule { path, url, purl })
166}
167
168fn build_purl_from_url(url: &str) -> Option<String> {
169    if url.is_empty() {
170        return None;
171    }
172
173    if let Some(purl) = parse_github_url(url) {
174        return Some(purl);
175    }
176
177    if let Some(purl) = parse_gitlab_url(url) {
178        return Some(purl);
179    }
180
181    None
182}
183
184fn parse_github_url(url: &str) -> Option<String> {
185    let (namespace, name) = if url.starts_with("https://github.com/") {
186        let path = url.strip_prefix("https://github.com/")?;
187        parse_repo_path(path)?
188    } else if url.starts_with("git@github.com:") {
189        let path = url.strip_prefix("git@github.com:")?;
190        parse_repo_path(path)?
191    } else {
192        return None;
193    };
194
195    Some(truncate_field(format!("pkg:github/{}/{}", namespace, name)))
196}
197
198fn parse_gitlab_url(url: &str) -> Option<String> {
199    let (namespace, name) = if url.starts_with("https://gitlab.com/") {
200        let path = url.strip_prefix("https://gitlab.com/")?;
201        parse_repo_path(path)?
202    } else if url.starts_with("git@gitlab.com:") {
203        let path = url.strip_prefix("git@gitlab.com:")?;
204        parse_repo_path(path)?
205    } else {
206        return None;
207    };
208
209    Some(truncate_field(format!("pkg:gitlab/{}/{}", namespace, name)))
210}
211
212fn parse_repo_path(path: &str) -> Option<(String, String)> {
213    let path = path.strip_suffix(".git").unwrap_or(path);
214    let parts: Vec<&str> = path.split('/').collect();
215
216    if parts.len() < 2 {
217        return None;
218    }
219
220    let name = truncate_field(parts.last()?.to_string());
221    let namespace = truncate_field(parts[..parts.len() - 1].join("/"));
222
223    if namespace.is_empty() || name.is_empty() {
224        return None;
225    }
226
227    Some((namespace, name))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::io::Write;
234    use tempfile::NamedTempFile;
235
236    fn create_gitmodules_file(content: &str) -> NamedTempFile {
237        let mut file = NamedTempFile::new().unwrap();
238        file.write_all(content.as_bytes()).unwrap();
239        file
240    }
241
242    #[test]
243    fn test_is_match() {
244        assert!(GitmodulesParser::is_match(Path::new(".gitmodules")));
245        assert!(GitmodulesParser::is_match(Path::new(
246            "/path/to/.gitmodules"
247        )));
248        assert!(!GitmodulesParser::is_match(Path::new("gitmodules")));
249        assert!(!GitmodulesParser::is_match(Path::new(".gitmodules.bak")));
250    }
251
252    #[test]
253    fn test_parse_single_submodule() {
254        let content = r#"
255[submodule "dep-lib"]
256    path = lib/dep
257    url = https://github.com/user/dep-lib.git
258"#;
259        let file = create_gitmodules_file(content);
260        let pkgs = GitmodulesParser::extract_packages(file.path());
261        assert_eq!(pkgs.len(), 1);
262        assert_eq!(pkgs[0].dependencies.len(), 1);
263        let dep = &pkgs[0].dependencies[0];
264        assert_eq!(dep.purl, Some("pkg:github/user/dep-lib".to_string()));
265    }
266
267    #[test]
268    fn test_parse_multiple_submodules() {
269        let content = r#"
270[submodule "lib1"]
271    path = libs/lib1
272    url = https://github.com/org/lib1.git
273
274[submodule "lib2"]
275    path = libs/lib2
276    url = git@github.com:org/lib2.git
277"#;
278        let file = create_gitmodules_file(content);
279        let pkgs = GitmodulesParser::extract_packages(file.path());
280        assert_eq!(pkgs.len(), 1);
281        assert_eq!(pkgs[0].dependencies.len(), 2);
282    }
283
284    #[test]
285    fn test_parse_git_ssh_url() {
286        let content = r#"
287[submodule "private-repo"]
288    path = private
289    url = git@github.com:company/private-repo.git
290"#;
291        let file = create_gitmodules_file(content);
292        let pkgs = GitmodulesParser::extract_packages(file.path());
293        let dep = &pkgs[0].dependencies[0];
294        assert_eq!(
295            dep.purl,
296            Some("pkg:github/company/private-repo".to_string())
297        );
298    }
299
300    #[test]
301    fn test_parse_gitlab_url() {
302        let content = r#"
303[submodule "gitlab-dep"]
304    path = gitlab-lib
305    url = https://gitlab.com/group/project.git
306"#;
307        let file = create_gitmodules_file(content);
308        let pkgs = GitmodulesParser::extract_packages(file.path());
309        let dep = &pkgs[0].dependencies[0];
310        assert_eq!(dep.purl, Some("pkg:gitlab/group/project".to_string()));
311    }
312
313    #[test]
314    fn test_parse_unknown_url() {
315        let content = r#"
316[submodule "custom"]
317    path = custom
318    url = https://example.com/repo.git
319"#;
320        let file = create_gitmodules_file(content);
321        let pkgs = GitmodulesParser::extract_packages(file.path());
322        let dep = &pkgs[0].dependencies[0];
323        assert!(dep.purl.is_none());
324        assert!(
325            dep.extracted_requirement
326                .as_ref()
327                .unwrap()
328                .contains("https://example.com/repo.git")
329        );
330    }
331
332    #[test]
333    fn test_parse_empty_file() {
334        let content = "";
335        let file = create_gitmodules_file(content);
336        let pkgs = GitmodulesParser::extract_packages(file.path());
337        assert_eq!(pkgs.len(), 1);
338        assert!(pkgs[0].dependencies.is_empty());
339    }
340
341    #[test]
342    fn test_parse_with_comments() {
343        let content = r#"
344# This is a comment
345[submodule "lib"]
346    ; another comment
347    path = lib
348    url = https://github.com/user/lib.git
349"#;
350        let file = create_gitmodules_file(content);
351        let pkgs = GitmodulesParser::extract_packages(file.path());
352        assert_eq!(pkgs[0].dependencies.len(), 1);
353    }
354}