1use sley_config::{GitConfig, parse_config_bool};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum RecurseMode {
25 Only,
26 Check,
27 Error,
28 #[default]
29 None,
30 OnDemand,
31 Off,
32 Default,
33 On,
34}
35
36impl RecurseMode {
37 pub fn as_i8(self) -> i8 {
39 match self {
40 RecurseMode::Only => -5,
41 RecurseMode::Check => -4,
42 RecurseMode::Error => -3,
43 RecurseMode::None => -2,
44 RecurseMode::OnDemand => -1,
45 RecurseMode::Off => 0,
46 RecurseMode::Default => 1,
47 RecurseMode::On => 2,
48 }
49 }
50}
51
52pub fn parse_fetch_recurse(arg: &str) -> RecurseMode {
56 match parse_config_bool(arg) {
57 Some(true) => RecurseMode::On,
58 Some(false) => RecurseMode::Off,
59 None => {
60 if arg == "on-demand" {
61 RecurseMode::OnDemand
62 } else {
63 RecurseMode::Error
64 }
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum UpdateType {
72 #[default]
73 Unspecified,
74 Checkout,
75 Rebase,
76 Merge,
77 None,
78 Command,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
83pub struct UpdateStrategy {
84 pub kind: UpdateType,
85 pub command: Option<String>,
88}
89
90pub fn parse_update_type(value: &str) -> UpdateType {
92 match value {
93 "none" => UpdateType::None,
94 "checkout" => UpdateType::Checkout,
95 "rebase" => UpdateType::Rebase,
96 "merge" => UpdateType::Merge,
97 _ if value.starts_with('!') => UpdateType::Command,
98 _ => UpdateType::Unspecified,
99 }
100}
101
102pub fn parse_update_strategy(value: &str) -> Option<UpdateStrategy> {
105 let kind = parse_update_type(value);
106 if kind == UpdateType::Unspecified {
107 return None;
108 }
109 let command = if kind == UpdateType::Command {
110 Some(value[1..].to_string())
111 } else {
112 None
113 };
114 Some(UpdateStrategy { kind, command })
115}
116
117pub fn update_type_to_string(kind: UpdateType) -> Option<&'static str> {
122 match kind {
123 UpdateType::Checkout => Some("checkout"),
124 UpdateType::Merge => Some("merge"),
125 UpdateType::Rebase => Some("rebase"),
126 UpdateType::None => Some("none"),
127 UpdateType::Unspecified | UpdateType::Command => None,
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Default)]
134pub struct Submodule {
135 pub name: String,
137 pub path: Option<String>,
138 pub url: Option<String>,
139 pub fetch_recurse: RecurseMode,
140 pub ignore: Option<String>,
141 pub branch: Option<String>,
142 pub update_strategy: UpdateStrategy,
143 pub recommend_shallow: Option<bool>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ParseWarning {
152 SuspiciousName { name: String },
154 CommandLineOption { var: String, value: String },
157 MultipleConfig { name: String, option: String },
159 InvalidIgnore { name: String, value: String },
162 InvalidUpdate { name: String },
168}
169
170#[derive(Debug, Clone, Default)]
174pub struct SubmoduleConfigSet {
175 submodules: Vec<Submodule>,
176 pub warnings: Vec<ParseWarning>,
178}
179
180impl SubmoduleConfigSet {
181 pub fn parse(config: &GitConfig) -> Self {
189 let mut set = SubmoduleConfigSet::default();
190 for section in &config.sections {
191 if section.name != "submodule" {
192 continue;
193 }
194 let Some(name) = section.subsection.as_deref() else {
197 continue;
198 };
199 if !check_submodule_name(name) {
200 set.warnings.push(ParseWarning::SuspiciousName {
201 name: name.to_string(),
202 });
203 continue;
204 }
205 set.lookup_or_create_by_name(name);
208 for entry in §ion.entries {
209 let item = entry.key.to_ascii_lowercase();
211 let value = entry.value.as_deref();
212 parse_config(&mut set, name, &item, value);
213 }
214 }
215 set
216 }
217
218 fn lookup_or_create_by_name(&mut self, name: &str) -> usize {
219 if let Some(index) = self.submodules.iter().position(|sub| sub.name == name) {
220 return index;
221 }
222 self.submodules.push(Submodule {
223 name: name.to_string(),
224 ..Submodule::default()
225 });
226 self.submodules.len() - 1
227 }
228
229 pub fn iter(&self) -> impl Iterator<Item = &Submodule> {
231 self.submodules.iter()
232 }
233
234 pub fn from_name(&self, name: &str) -> Option<&Submodule> {
236 self.submodules.iter().find(|sub| sub.name == name)
237 }
238
239 pub fn from_path(&self, path: &str) -> Option<&Submodule> {
241 self.submodules
242 .iter()
243 .find(|sub| sub.path.as_deref() == Some(path))
244 }
245
246 pub fn is_empty(&self) -> bool {
248 self.submodules.is_empty()
249 }
250
251 pub fn len(&self) -> usize {
253 self.submodules.len()
254 }
255}
256
257fn parse_config(set: &mut SubmoduleConfigSet, name: &str, item: &str, value: Option<&str>) {
262 let index = set
263 .submodules
264 .iter()
265 .position(|sub| sub.name == name)
266 .expect("submodule created before parse_config dispatch");
267
268 match item {
269 "path" => {
270 let Some(value) = value else { return };
271 if looks_like_command_line_option(value) {
272 set.warnings.push(ParseWarning::CommandLineOption {
273 var: format!("submodule.{name}.path"),
274 value: value.to_string(),
275 });
276 } else if set.submodules[index].path.is_some() {
277 set.warnings.push(ParseWarning::MultipleConfig {
278 name: name.to_string(),
279 option: "path".to_string(),
280 });
281 } else {
282 set.submodules[index].path = Some(value.to_string());
283 }
284 }
285 "fetchrecursesubmodules" => {
286 if set.submodules[index].fetch_recurse != RecurseMode::None {
287 set.warnings.push(ParseWarning::MultipleConfig {
288 name: name.to_string(),
289 option: "fetchrecursesubmodules".to_string(),
290 });
291 } else if let Some(value) = value {
292 set.submodules[index].fetch_recurse = parse_fetch_recurse(value);
293 }
294 }
295 "ignore" => {
296 let Some(value) = value else { return };
297 if set.submodules[index].ignore.is_some() {
298 set.warnings.push(ParseWarning::MultipleConfig {
299 name: name.to_string(),
300 option: "ignore".to_string(),
301 });
302 } else if !matches!(value, "untracked" | "dirty" | "all" | "none") {
303 set.warnings.push(ParseWarning::InvalidIgnore {
304 name: name.to_string(),
305 value: value.to_string(),
306 });
307 } else {
308 set.submodules[index].ignore = Some(value.to_string());
309 }
310 }
311 "url" => {
312 let Some(value) = value else { return };
313 if looks_like_command_line_option(value) {
314 set.warnings.push(ParseWarning::CommandLineOption {
315 var: format!("submodule.{name}.url"),
316 value: value.to_string(),
317 });
318 } else if set.submodules[index].url.is_some() {
319 set.warnings.push(ParseWarning::MultipleConfig {
320 name: name.to_string(),
321 option: "url".to_string(),
322 });
323 } else {
324 set.submodules[index].url = Some(value.to_string());
325 }
326 }
327 "update" => {
328 let Some(value) = value else { return };
329 if set.submodules[index].update_strategy.kind != UpdateType::Unspecified {
330 set.warnings.push(ParseWarning::MultipleConfig {
331 name: name.to_string(),
332 option: "update".to_string(),
333 });
334 } else {
335 match parse_update_strategy(value) {
336 Some(strategy) if strategy.kind != UpdateType::Command => {
337 set.submodules[index].update_strategy = strategy;
338 }
339 _ => {
345 set.warnings.push(ParseWarning::InvalidUpdate {
346 name: name.to_string(),
347 });
348 }
349 }
350 }
351 }
352 "shallow" => {
353 if set.submodules[index].recommend_shallow.is_some() {
354 set.warnings.push(ParseWarning::MultipleConfig {
355 name: name.to_string(),
356 option: "shallow".to_string(),
357 });
358 } else {
359 let parsed = value.is_none_or(|v| parse_config_bool(v).unwrap_or(false));
361 set.submodules[index].recommend_shallow = Some(parsed);
362 }
363 }
364 "branch" => {
365 let Some(value) = value else { return };
366 if set.submodules[index].branch.is_some() {
367 set.warnings.push(ParseWarning::MultipleConfig {
368 name: name.to_string(),
369 option: "branch".to_string(),
370 });
371 } else {
372 set.submodules[index].branch = Some(value.to_string());
373 }
374 }
375 _ => {}
377 }
378}
379
380pub fn check_submodule_name(name: &str) -> bool {
386 if name.is_empty() {
387 return false;
388 }
389 let bytes = name.as_bytes();
390 let mut i = 0;
392 let mut at_component_start = true;
393 while i <= bytes.len() {
394 if at_component_start && is_xplatform_dir_sep_component(bytes, i) {
395 return false;
396 }
397 at_component_start = false;
398 if i < bytes.len() && is_xplatform_dir_sep(bytes[i]) {
399 at_component_start = true;
400 }
401 i += 1;
402 }
403 true
404}
405
406fn is_xplatform_dir_sep_component(bytes: &[u8], i: usize) -> bool {
410 bytes.get(i) == Some(&b'.')
411 && bytes.get(i + 1) == Some(&b'.')
412 && match bytes.get(i + 2) {
413 None => true,
414 Some(&c) => is_xplatform_dir_sep(c),
415 }
416}
417
418fn is_xplatform_dir_sep(c: u8) -> bool {
419 c == b'/' || c == b'\\'
420}
421
422pub fn check_submodule_url(url: &str) -> bool {
429 if looks_like_command_line_option(url) {
430 return false;
431 }
432
433 if submodule_url_is_relative(url) || url.starts_with("git://") {
434 let decoded = url_decode(url);
435 if decoded.contains('\n') {
436 return false;
437 }
438 let (dotdots, next) = count_leading_dotdots(url);
440 if dotdots > 0 {
441 let first = next.as_bytes().first().copied();
442 if first == Some(b':') || first == Some(b'/') {
443 return false;
444 }
445 }
446 } else if let Some(curl_url) = url_to_curl_url(url) {
447 if !curl_url_is_normalizable(curl_url) {
451 return false;
452 }
453 let decoded = url_decode(curl_url);
454 if decoded.contains('\n') {
455 return false;
456 }
457 }
458
459 true
460}
461
462fn curl_url_is_normalizable(url: &str) -> bool {
468 let bytes = url.as_bytes();
469 let scheme_len = bytes
471 .iter()
472 .take_while(|&&c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))
473 .count();
474 if scheme_len == 0
475 || !bytes[0].is_ascii_alphabetic()
476 || scheme_len + 3 > bytes.len()
477 || &bytes[scheme_len..scheme_len + 3] != b"://"
478 {
479 return false;
480 }
481 let scheme = &url[..scheme_len];
482 let after_scheme = &url[scheme_len + 3..];
483 let authority_end = after_scheme
485 .find(['/', '?', '#'])
486 .unwrap_or(after_scheme.len());
487 let host_start = match after_scheme.find('@') {
488 Some(at) if at < authority_end => &after_scheme[at + 1..],
489 _ => after_scheme,
490 };
491 let host_missing = host_start
494 .as_bytes()
495 .first()
496 .is_none_or(|c| matches!(c, b':' | b'/' | b'?' | b'#'));
497 if host_missing && !scheme.eq_ignore_ascii_case("file") {
498 return false;
499 }
500 true
501}
502
503fn starts_with_dot_slash_xplat(url: &str) -> bool {
507 let bytes = url.as_bytes();
508 bytes.first() == Some(&b'.') && matches!(bytes.get(1), Some(b'/') | Some(b'\\'))
509}
510
511fn starts_with_dot_dot_slash_xplat(url: &str) -> bool {
514 let bytes = url.as_bytes();
515 bytes.first() == Some(&b'.')
516 && bytes.get(1) == Some(&b'.')
517 && matches!(bytes.get(2), Some(b'/') | Some(b'\\'))
518}
519
520fn submodule_url_is_relative(url: &str) -> bool {
521 starts_with_dot_slash_xplat(url) || starts_with_dot_dot_slash_xplat(url)
522}
523
524fn count_leading_dotdots(url: &str) -> (usize, &str) {
529 let mut result = 0;
530 let mut rest = url;
531 loop {
532 if starts_with_dot_dot_slash_xplat(rest) {
533 result += 1;
534 rest = &rest[3..];
535 } else if starts_with_dot_slash_xplat(rest) {
536 rest = &rest[2..];
537 } else {
538 return (result, rest);
539 }
540 }
541}
542
543fn url_to_curl_url(url: &str) -> Option<&str> {
546 for prefix in ["http::", "https::", "ftp::", "ftps::"] {
547 if let Some(stripped) = url.strip_prefix(prefix) {
548 return Some(stripped);
549 }
550 }
551 for prefix in ["http://", "https://", "ftp://", "ftps://"] {
552 if url.starts_with(prefix) {
553 return Some(url);
554 }
555 }
556 None
557}
558
559pub fn looks_like_command_line_option(value: &str) -> bool {
562 value.starts_with('-')
563}
564
565fn url_decode(input: &str) -> String {
569 let bytes = input.as_bytes();
570 let mut out = Vec::with_capacity(bytes.len());
571 let mut i = 0;
572 while i < bytes.len() {
573 if bytes[i] == b'%' && i + 2 < bytes.len() {
574 let hi = hex_val(bytes[i + 1]);
575 let lo = hex_val(bytes[i + 2]);
576 if let (Some(hi), Some(lo)) = (hi, lo) {
577 out.push((hi << 4) | lo);
578 i += 3;
579 continue;
580 }
581 }
582 out.push(bytes[i]);
583 i += 1;
584 }
585 String::from_utf8_lossy(&out).into_owned()
586}
587
588fn hex_val(c: u8) -> Option<u8> {
589 match c {
590 b'0'..=b'9' => Some(c - b'0'),
591 b'a'..=b'f' => Some(c - b'a' + 10),
592 b'A'..=b'F' => Some(c - b'A' + 10),
593 _ => None,
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use sley_config::GitConfig;
601
602 fn config_from(text: &str) -> GitConfig {
603 GitConfig::parse(text.as_bytes()).expect("valid config")
604 }
605
606 #[test]
607 fn parses_basic_submodule() {
608 let cfg =
609 config_from("[submodule \"lib\"]\n\tpath = lib\n\turl = https://example.com/lib.git\n");
610 let set = SubmoduleConfigSet::parse(&cfg);
611 assert_eq!(set.len(), 1);
612 let sub = set.from_name("lib").expect("lib present");
613 assert_eq!(sub.path.as_deref(), Some("lib"));
614 assert_eq!(sub.url.as_deref(), Some("https://example.com/lib.git"));
615 assert_eq!(set.from_path("lib").map(|s| s.name.as_str()), Some("lib"));
616 }
617
618 #[test]
619 fn first_value_wins_and_warns_on_duplicate() {
620 let cfg = config_from("[submodule \"x\"]\n\tpath = a\n\tpath = b\n");
621 let set = SubmoduleConfigSet::parse(&cfg);
622 assert_eq!(
623 set.from_name("x").and_then(|s| s.path.as_deref()),
624 Some("a")
625 );
626 assert!(set.warnings.iter().any(|w| matches!(
627 w,
628 ParseWarning::MultipleConfig { option, .. } if option == "path"
629 )));
630 }
631
632 #[test]
633 fn suspicious_name_dropped() {
634 let cfg = config_from("[submodule \"../evil\"]\n\tpath = x\n");
635 let set = SubmoduleConfigSet::parse(&cfg);
636 assert!(set.is_empty());
637 assert!(matches!(
638 set.warnings.first(),
639 Some(ParseWarning::SuspiciousName { .. })
640 ));
641 }
642
643 #[test]
644 fn check_submodule_name_rejects_dotdot() {
645 assert!(!check_submodule_name("a/../b"));
646 assert!(!check_submodule_name(".."));
647 assert!(!check_submodule_name("../x"));
648 assert!(!check_submodule_name("a/.."));
649 assert!(!check_submodule_name(""));
650 assert!(check_submodule_name("normal/name"));
651 assert!(check_submodule_name("a..b"));
652 assert!(check_submodule_name("..."));
653 }
654
655 #[test]
656 fn check_submodule_url_rejects_escapes() {
657 assert!(!check_submodule_url("-upload-pack=evil"));
659 assert!(!check_submodule_url("../:evil"));
662 assert!(!check_submodule_url("..//evil"));
663 assert!(check_submodule_url("../../../host/path"));
666 assert!(check_submodule_url("https://example.com/ok.git"));
667 assert!(check_submodule_url("./relative"));
668 assert!(!check_submodule_url("git://h/%0arepo"));
670 }
671
672 #[test]
673 fn update_strategy_parses() {
674 assert_eq!(parse_update_type("checkout"), UpdateType::Checkout);
675 assert_eq!(parse_update_type("none"), UpdateType::None);
676 assert_eq!(parse_update_type("!cmd"), UpdateType::Command);
677 assert_eq!(parse_update_type("bogus"), UpdateType::Unspecified);
678 let strat = parse_update_strategy("!run").expect("command");
679 assert_eq!(strat.kind, UpdateType::Command);
680 assert_eq!(strat.command.as_deref(), Some("run"));
681 assert!(parse_update_strategy("bogus").is_none());
682 }
683
684 #[test]
685 fn fetch_recurse_parses() {
686 assert_eq!(parse_fetch_recurse("true"), RecurseMode::On);
687 assert_eq!(parse_fetch_recurse("false"), RecurseMode::Off);
688 assert_eq!(parse_fetch_recurse("on-demand"), RecurseMode::OnDemand);
689 assert_eq!(parse_fetch_recurse("garbage"), RecurseMode::Error);
690 }
691
692 #[test]
693 fn shallow_and_branch_parse() {
694 let cfg = config_from("[submodule \"s\"]\n\tbranch = main\n\tshallow = true\n");
695 let set = SubmoduleConfigSet::parse(&cfg);
696 let sub = set.from_name("s").expect("s");
697 assert_eq!(sub.branch.as_deref(), Some("main"));
698 assert_eq!(sub.recommend_shallow, Some(true));
699 }
700}