provenant/parsers/
gitmodules.rs1use 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}