Skip to main content

sley_submodule/
config.rs

1//! Typed `.gitmodules` configuration — a Rust port of git's `submodule-config.c`.
2//!
3//! Today every consumer in sley re-derives submodule fields by hand-walking a
4//! [`GitConfig`] (`section.name == "submodule"`, `find(|e| e.key == "path")`,
5//! …), scattered across ~14 call sites. This module centralizes that into one
6//! typed parser so the submodule command AND the tree-switch commands share a
7//! single source of truth for what a `.gitmodules` entry means.
8//!
9//! Porting notes (git `submodule-config.c`):
10//! - [`parse_config`] is the per-key dispatch; we drive it over a parsed
11//!   [`GitConfig`] rather than git's streaming `git_config_from_mem` callback,
12//!   but the per-key semantics (last-one-wins vs. first-one-wins, validation,
13//!   value normalization) match faithfully.
14//! - [`check_submodule_name`] / [`check_submodule_url`] port the security
15//!   checks that reject `..`-bearing names and command-line-option-looking
16//!   values.
17//! - The recurse-mode and update-strategy enums port `submodule.h`.
18
19use sley_config::{GitConfig, parse_config_bool};
20
21/// `enum submodule_recurse_mode` (git `submodule.h`). Discriminants match git's
22/// so the numeric values are stable across the wire / config.
23#[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    /// Port of git's numeric discriminants for `submodule_recurse_mode`.
38    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
52/// Port of `parse_fetch_recurse` (git `submodule-config.c`). Returns
53/// [`RecurseMode::Error`] on an unrecognized argument (the `die_on_error == 0`
54/// branch); callers that want the fatal behavior check for `Error`.
55pub 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/// `enum submodule_update_type` (git `submodule.h`).
70#[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/// `struct submodule_update_strategy` (git `submodule.h`).
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
83pub struct UpdateStrategy {
84    pub kind: UpdateType,
85    /// Only populated when `kind == Command`: the command string (the part
86    /// after the leading `!`).
87    pub command: Option<String>,
88}
89
90/// Port of `parse_submodule_update_type` (git `submodule.c`).
91pub 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
102/// Port of `parse_submodule_update_strategy` (git `submodule.c`). Returns the
103/// parsed strategy, or `None` for an unrecognized value (git's `-1`).
104pub 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
117/// Port of `submodule_update_type_to_string` (git `submodule.c`). Returns
118/// `None` for the two types that have no string form (`Unspecified`,
119/// `Command`), matching git's `BUG()` cases — callers handle those before
120/// stringifying.
121pub 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/// A single submodule's typed configuration, the analogue of git's
132/// `struct submodule`. Built by [`SubmoduleConfigSet::parse`].
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134pub struct Submodule {
135    /// The `.gitmodules` subsection (the submodule "name").
136    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    /// `submodule.<name>.shallow`; `None` means unset (git's `-1` sentinel).
144    pub recommend_shallow: Option<bool>,
145}
146
147/// A diagnostic emitted while parsing `.gitmodules`, mirroring git's
148/// `warning(...)` calls in `parse_config`. Surfacing them lets a consumer
149/// reproduce git's stderr without this crate owning an output channel.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ParseWarning {
152    /// `warning(_("ignoring suspicious submodule name: %s"), ...)`.
153    SuspiciousName { name: String },
154    /// `warning(_("ignoring '%s' which may be interpreted as a command-line
155    /// option: %s"), ...)`.
156    CommandLineOption { var: String, value: String },
157    /// `warning("...multiple configurations found for 'submodule.%s.%s'...")`.
158    MultipleConfig { name: String, option: String },
159    /// `warning("Invalid parameter '%s' for config option
160    /// 'submodule.%s.ignore'")`.
161    InvalidIgnore { name: String, value: String },
162    /// git `die(_("invalid value for '%s'"))` while parsing
163    /// `submodule.<name>.update` — either an unrecognized value or a `!command`
164    /// (which is forbidden from `.gitmodules` for security). This crate has no
165    /// fatal channel, so it surfaces here; the `submodule init`/`update` path
166    /// turns it into the fatal git behavior.
167    InvalidUpdate { name: String },
168}
169
170/// The parsed set of all submodules from one `.gitmodules`, the analogue of the
171/// path/name-keyed `submodule_cache`. Lookups are by name or by bound path,
172/// matching git's `submodule_from_name` / `submodule_from_path`.
173#[derive(Debug, Clone, Default)]
174pub struct SubmoduleConfigSet {
175    submodules: Vec<Submodule>,
176    /// Non-fatal diagnostics produced during parsing, in encounter order.
177    pub warnings: Vec<ParseWarning>,
178}
179
180impl SubmoduleConfigSet {
181    /// Parse a typed submodule set from an already-loaded `.gitmodules`
182    /// [`GitConfig`]. This is the moral equivalent of git driving
183    /// `parse_config` over every `submodule.*.*` key in the blob.
184    ///
185    /// Per-key precedence matches git's `parse_config` with `overwrite == 0`:
186    /// the FIRST value of a key wins and later duplicates emit a
187    /// [`ParseWarning::MultipleConfig`] (git's `warn_multiple_config`).
188    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            // `name_and_item_from_var`: the subsection IS the submodule name,
195            // and suspicious names are dropped wholesale with a warning.
196            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            // Ensure the submodule exists even if it has zero recognized keys
206            // (git's lookup_or_create_by_name runs before each key's dispatch).
207            set.lookup_or_create_by_name(name);
208            for entry in &section.entries {
209                // git lowercases the variable name (`key`) before dispatch.
210                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    /// All parsed submodules, in `.gitmodules` declaration order.
230    pub fn iter(&self) -> impl Iterator<Item = &Submodule> {
231        self.submodules.iter()
232    }
233
234    /// `submodule_from_name`: look up by the `.gitmodules` subsection name.
235    pub fn from_name(&self, name: &str) -> Option<&Submodule> {
236        self.submodules.iter().find(|sub| sub.name == name)
237    }
238
239    /// `submodule_from_path`: look up by the path a submodule is bound at.
240    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    /// True when no submodules were declared.
247    pub fn is_empty(&self) -> bool {
248        self.submodules.is_empty()
249    }
250
251    /// Number of declared submodules.
252    pub fn len(&self) -> usize {
253        self.submodules.len()
254    }
255}
256
257/// Per-key parse dispatch — direct port of git `submodule-config.c`'s
258/// `parse_config`. `set` already contains the named submodule (created by the
259/// caller); we index it back out so each arm can mutate in place and push
260/// warnings onto `set.warnings`.
261fn 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                    // git die()s on a bad value or a `!command` from .gitmodules
340                    // (the command-form is forbidden there for security). This
341                    // crate has no fatal channel, so we record the invalid value
342                    // as a warning and leave the strategy unspecified; the
343                    // `init`/`update` path promotes it to the fatal git behavior.
344                    _ => {
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                // git_config_bool: a bare key (no value) is true.
360                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        // git's parse_config silently ignores any other submodule.<name>.<key>.
376        _ => {}
377    }
378}
379
380/// Port of `check_submodule_name` (git `submodule-config.c`). Returns `true`
381/// if `name` is syntactically acceptable as a `.gitmodules` subsection, `false`
382/// otherwise (git's `0` vs `-1`). Rejects empty names and any `..` path
383/// component (using the cross-platform separator set `/` and `\` so the rule is
384/// OS-independent).
385pub fn check_submodule_name(name: &str) -> bool {
386    if name.is_empty() {
387        return false;
388    }
389    let bytes = name.as_bytes();
390    // git starts "inside a component" then re-checks at every separator.
391    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
406/// A `..` component begins at `i` when bytes[i..] is `..` followed by EOS or a
407/// separator. Mirrors git's `name[0]=='.' && name[1]=='.' && (!name[2] ||
408/// sep(name[2]))` check applied at each component boundary.
409fn 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
422/// Port of `check_submodule_url` (git `submodule-config.c`). Returns `true`
423/// if the URL is acceptable (per the CVE-2020-11008 / option-injection checks),
424/// `false` otherwise (git's `0` vs `-1`). Mirrors the relative-URL and
425/// `git://` newline/`../`-escape checks; the http(s) `url_normalize` round-trip
426/// is approximated by the same newline check on the decoded form (sley has no
427/// `url_normalize` yet — TODO(submodule) below).
428pub 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        // URLs that escape their root via "../" can overwrite the host field.
439        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        // git runs the curl url through `url_normalize`, rejecting anything that
448        // is not a valid absolute URL: a missing/empty scheme, a missing "://",
449        // or a missing host for a non-`file:` scheme (CVE-2020-11008 family).
450        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
462/// Minimal port of git's `url_normalize` validity gate (`urlmatch.c`): whether a
463/// curl url is a normalizable absolute URL. Rejects (returns false) a url whose
464/// scheme is empty / not letter-led / not followed by "://", or whose host is
465/// missing — the conditions that make `url_normalize` return NULL and so make
466/// `check_submodule_url` reject the submodule url.
467fn curl_url_is_normalizable(url: &str) -> bool {
468    let bytes = url.as_bytes();
469    // scheme: a non-empty run of [A-Za-z0-9+.-] starting with a letter, then "://"
470    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    // Skip an optional `user[:pass]@` that precedes the first `/?#`.
484    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    // A missing host (empty, or immediately a `:/?#`) is invalid for every
492    // scheme except `file:`.
493    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
503/// `starts_with_dot_slash` (git `dir.h`, XPLATFORM): a leading `./` where the
504/// separator is EITHER `/` or `\` — the cross-platform form git uses for
505/// submodule urls, so a `.\`-prefixed url is relative on every OS.
506fn 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
511/// `starts_with_dot_dot_slash` (git `dir.h`, XPLATFORM): a leading `../` with a
512/// `/`-or-`\` separator.
513fn 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
524/// Port of `count_leading_dotdots` (git `submodule-config.c`): counts leading
525/// `../` components (skipping `./`) and returns the remaining suffix. Both `/`
526/// and `\` count as the separator (XPLATFORM), matching git so a `..\`-escaping
527/// url is caught on every OS.
528fn 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
543/// Port of `url_to_curl_url` (git `submodule-config.c`): if the transport is one
544/// git-remote-curl handles, returns the URL that would be passed to it.
545fn 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
559/// Port of git's `looks_like_command_line_option`: a value starting with `-`
560/// could be mistaken for a CLI flag when passed to a child git process.
561pub fn looks_like_command_line_option(value: &str) -> bool {
562    value.starts_with('-')
563}
564
565/// Minimal percent-decoder for the `check_submodule_url` newline check. Mirrors
566/// git's `url_decode` for the bytes we care about (`%0a` etc.); leaves
567/// malformed escapes intact.
568fn 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        // Looks like a command-line option.
658        assert!(!check_submodule_url("-upload-pack=evil"));
659        // Relative URL whose first byte after the leading "../" is ':' / '/',
660        // the CVE-2020-11008 host-overwrite vector git guards.
661        assert!(!check_submodule_url("../:evil"));
662        assert!(!check_submodule_url("..//evil"));
663        // A relative URL with a normal first component after the "../" is fine
664        // (git only rejects the ':'/'/' first byte).
665        assert!(check_submodule_url("../../../host/path"));
666        assert!(check_submodule_url("https://example.com/ok.git"));
667        assert!(check_submodule_url("./relative"));
668        // Embedded newline in a git:// / relative URL is rejected.
669        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}