use sley_config::{GitConfig, parse_config_bool};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecurseMode {
Only,
Check,
Error,
#[default]
None,
OnDemand,
Off,
Default,
On,
}
impl RecurseMode {
pub fn as_i8(self) -> i8 {
match self {
RecurseMode::Only => -5,
RecurseMode::Check => -4,
RecurseMode::Error => -3,
RecurseMode::None => -2,
RecurseMode::OnDemand => -1,
RecurseMode::Off => 0,
RecurseMode::Default => 1,
RecurseMode::On => 2,
}
}
}
pub fn parse_fetch_recurse(arg: &str) -> RecurseMode {
match parse_config_bool(arg) {
Some(true) => RecurseMode::On,
Some(false) => RecurseMode::Off,
None => {
if arg == "on-demand" {
RecurseMode::OnDemand
} else {
RecurseMode::Error
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UpdateType {
#[default]
Unspecified,
Checkout,
Rebase,
Merge,
None,
Command,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UpdateStrategy {
pub kind: UpdateType,
pub command: Option<String>,
}
pub fn parse_update_type(value: &str) -> UpdateType {
match value {
"none" => UpdateType::None,
"checkout" => UpdateType::Checkout,
"rebase" => UpdateType::Rebase,
"merge" => UpdateType::Merge,
_ if value.starts_with('!') => UpdateType::Command,
_ => UpdateType::Unspecified,
}
}
pub fn parse_update_strategy(value: &str) -> Option<UpdateStrategy> {
let kind = parse_update_type(value);
if kind == UpdateType::Unspecified {
return None;
}
let command = if kind == UpdateType::Command {
Some(value[1..].to_string())
} else {
None
};
Some(UpdateStrategy { kind, command })
}
pub fn update_type_to_string(kind: UpdateType) -> Option<&'static str> {
match kind {
UpdateType::Checkout => Some("checkout"),
UpdateType::Merge => Some("merge"),
UpdateType::Rebase => Some("rebase"),
UpdateType::None => Some("none"),
UpdateType::Unspecified | UpdateType::Command => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Submodule {
pub name: String,
pub path: Option<String>,
pub url: Option<String>,
pub fetch_recurse: RecurseMode,
pub ignore: Option<String>,
pub branch: Option<String>,
pub update_strategy: UpdateStrategy,
pub recommend_shallow: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseWarning {
SuspiciousName { name: String },
CommandLineOption { var: String, value: String },
MultipleConfig { name: String, option: String },
InvalidIgnore { name: String, value: String },
InvalidUpdate { name: String },
}
#[derive(Debug, Clone, Default)]
pub struct SubmoduleConfigSet {
submodules: Vec<Submodule>,
pub warnings: Vec<ParseWarning>,
}
impl SubmoduleConfigSet {
pub fn parse(config: &GitConfig) -> Self {
let mut set = SubmoduleConfigSet::default();
for section in &config.sections {
if section.name != "submodule" {
continue;
}
let Some(name) = section.subsection.as_deref() else {
continue;
};
if !check_submodule_name(name) {
set.warnings.push(ParseWarning::SuspiciousName {
name: name.to_string(),
});
continue;
}
set.lookup_or_create_by_name(name);
for entry in §ion.entries {
let item = entry.key.to_ascii_lowercase();
let value = entry.value.as_deref();
parse_config(&mut set, name, &item, value);
}
}
set
}
fn lookup_or_create_by_name(&mut self, name: &str) -> usize {
if let Some(index) = self.submodules.iter().position(|sub| sub.name == name) {
return index;
}
self.submodules.push(Submodule {
name: name.to_string(),
..Submodule::default()
});
self.submodules.len() - 1
}
pub fn iter(&self) -> impl Iterator<Item = &Submodule> {
self.submodules.iter()
}
pub fn from_name(&self, name: &str) -> Option<&Submodule> {
self.submodules.iter().find(|sub| sub.name == name)
}
pub fn from_path(&self, path: &str) -> Option<&Submodule> {
self.submodules
.iter()
.find(|sub| sub.path.as_deref() == Some(path))
}
pub fn is_empty(&self) -> bool {
self.submodules.is_empty()
}
pub fn len(&self) -> usize {
self.submodules.len()
}
}
fn parse_config(set: &mut SubmoduleConfigSet, name: &str, item: &str, value: Option<&str>) {
let index = set
.submodules
.iter()
.position(|sub| sub.name == name)
.expect("submodule created before parse_config dispatch");
match item {
"path" => {
let Some(value) = value else { return };
if looks_like_command_line_option(value) {
set.warnings.push(ParseWarning::CommandLineOption {
var: format!("submodule.{name}.path"),
value: value.to_string(),
});
} else if set.submodules[index].path.is_some() {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "path".to_string(),
});
} else {
set.submodules[index].path = Some(value.to_string());
}
}
"fetchrecursesubmodules" => {
if set.submodules[index].fetch_recurse != RecurseMode::None {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "fetchrecursesubmodules".to_string(),
});
} else if let Some(value) = value {
set.submodules[index].fetch_recurse = parse_fetch_recurse(value);
}
}
"ignore" => {
let Some(value) = value else { return };
if set.submodules[index].ignore.is_some() {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "ignore".to_string(),
});
} else if !matches!(value, "untracked" | "dirty" | "all" | "none") {
set.warnings.push(ParseWarning::InvalidIgnore {
name: name.to_string(),
value: value.to_string(),
});
} else {
set.submodules[index].ignore = Some(value.to_string());
}
}
"url" => {
let Some(value) = value else { return };
if looks_like_command_line_option(value) {
set.warnings.push(ParseWarning::CommandLineOption {
var: format!("submodule.{name}.url"),
value: value.to_string(),
});
} else if set.submodules[index].url.is_some() {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "url".to_string(),
});
} else {
set.submodules[index].url = Some(value.to_string());
}
}
"update" => {
let Some(value) = value else { return };
if set.submodules[index].update_strategy.kind != UpdateType::Unspecified {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "update".to_string(),
});
} else {
match parse_update_strategy(value) {
Some(strategy) if strategy.kind != UpdateType::Command => {
set.submodules[index].update_strategy = strategy;
}
_ => {
set.warnings.push(ParseWarning::InvalidUpdate {
name: name.to_string(),
});
}
}
}
}
"shallow" => {
if set.submodules[index].recommend_shallow.is_some() {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "shallow".to_string(),
});
} else {
let parsed = value.is_none_or(|v| parse_config_bool(v).unwrap_or(false));
set.submodules[index].recommend_shallow = Some(parsed);
}
}
"branch" => {
let Some(value) = value else { return };
if set.submodules[index].branch.is_some() {
set.warnings.push(ParseWarning::MultipleConfig {
name: name.to_string(),
option: "branch".to_string(),
});
} else {
set.submodules[index].branch = Some(value.to_string());
}
}
_ => {}
}
}
pub fn check_submodule_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let bytes = name.as_bytes();
let mut i = 0;
let mut at_component_start = true;
while i <= bytes.len() {
if at_component_start && is_xplatform_dir_sep_component(bytes, i) {
return false;
}
at_component_start = false;
if i < bytes.len() && is_xplatform_dir_sep(bytes[i]) {
at_component_start = true;
}
i += 1;
}
true
}
fn is_xplatform_dir_sep_component(bytes: &[u8], i: usize) -> bool {
bytes.get(i) == Some(&b'.')
&& bytes.get(i + 1) == Some(&b'.')
&& match bytes.get(i + 2) {
None => true,
Some(&c) => is_xplatform_dir_sep(c),
}
}
fn is_xplatform_dir_sep(c: u8) -> bool {
c == b'/' || c == b'\\'
}
pub fn check_submodule_url(url: &str) -> bool {
if looks_like_command_line_option(url) {
return false;
}
if submodule_url_is_relative(url) || url.starts_with("git://") {
let decoded = url_decode(url);
if decoded.contains('\n') {
return false;
}
let (dotdots, next) = count_leading_dotdots(url);
if dotdots > 0 {
let first = next.as_bytes().first().copied();
if first == Some(b':') || first == Some(b'/') {
return false;
}
}
} else if let Some(curl_url) = url_to_curl_url(url) {
if !curl_url_is_normalizable(curl_url) {
return false;
}
let decoded = url_decode(curl_url);
if decoded.contains('\n') {
return false;
}
}
true
}
fn curl_url_is_normalizable(url: &str) -> bool {
let bytes = url.as_bytes();
let scheme_len = bytes
.iter()
.take_while(|&&c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))
.count();
if scheme_len == 0
|| !bytes[0].is_ascii_alphabetic()
|| scheme_len + 3 > bytes.len()
|| &bytes[scheme_len..scheme_len + 3] != b"://"
{
return false;
}
let scheme = &url[..scheme_len];
let after_scheme = &url[scheme_len + 3..];
let authority_end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
let host_start = match after_scheme.find('@') {
Some(at) if at < authority_end => &after_scheme[at + 1..],
_ => after_scheme,
};
let host_missing = host_start
.as_bytes()
.first()
.is_none_or(|c| matches!(c, b':' | b'/' | b'?' | b'#'));
if host_missing && !scheme.eq_ignore_ascii_case("file") {
return false;
}
true
}
fn starts_with_dot_slash_xplat(url: &str) -> bool {
let bytes = url.as_bytes();
bytes.first() == Some(&b'.') && matches!(bytes.get(1), Some(b'/') | Some(b'\\'))
}
fn starts_with_dot_dot_slash_xplat(url: &str) -> bool {
let bytes = url.as_bytes();
bytes.first() == Some(&b'.')
&& bytes.get(1) == Some(&b'.')
&& matches!(bytes.get(2), Some(b'/') | Some(b'\\'))
}
fn submodule_url_is_relative(url: &str) -> bool {
starts_with_dot_slash_xplat(url) || starts_with_dot_dot_slash_xplat(url)
}
fn count_leading_dotdots(url: &str) -> (usize, &str) {
let mut result = 0;
let mut rest = url;
loop {
if starts_with_dot_dot_slash_xplat(rest) {
result += 1;
rest = &rest[3..];
} else if starts_with_dot_slash_xplat(rest) {
rest = &rest[2..];
} else {
return (result, rest);
}
}
}
fn url_to_curl_url(url: &str) -> Option<&str> {
for prefix in ["http::", "https::", "ftp::", "ftps::"] {
if let Some(stripped) = url.strip_prefix(prefix) {
return Some(stripped);
}
}
for prefix in ["http://", "https://", "ftp://", "ftps://"] {
if url.starts_with(prefix) {
return Some(url);
}
}
None
}
pub fn looks_like_command_line_option(value: &str) -> bool {
value.starts_with('-')
}
fn url_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = hex_val(bytes[i + 1]);
let lo = hex_val(bytes[i + 2]);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi << 4) | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use sley_config::GitConfig;
fn config_from(text: &str) -> GitConfig {
GitConfig::parse(text.as_bytes()).expect("valid config")
}
#[test]
fn parses_basic_submodule() {
let cfg =
config_from("[submodule \"lib\"]\n\tpath = lib\n\turl = https://example.com/lib.git\n");
let set = SubmoduleConfigSet::parse(&cfg);
assert_eq!(set.len(), 1);
let sub = set.from_name("lib").expect("lib present");
assert_eq!(sub.path.as_deref(), Some("lib"));
assert_eq!(sub.url.as_deref(), Some("https://example.com/lib.git"));
assert_eq!(set.from_path("lib").map(|s| s.name.as_str()), Some("lib"));
}
#[test]
fn first_value_wins_and_warns_on_duplicate() {
let cfg = config_from("[submodule \"x\"]\n\tpath = a\n\tpath = b\n");
let set = SubmoduleConfigSet::parse(&cfg);
assert_eq!(
set.from_name("x").and_then(|s| s.path.as_deref()),
Some("a")
);
assert!(set.warnings.iter().any(|w| matches!(
w,
ParseWarning::MultipleConfig { option, .. } if option == "path"
)));
}
#[test]
fn suspicious_name_dropped() {
let cfg = config_from("[submodule \"../evil\"]\n\tpath = x\n");
let set = SubmoduleConfigSet::parse(&cfg);
assert!(set.is_empty());
assert!(matches!(
set.warnings.first(),
Some(ParseWarning::SuspiciousName { .. })
));
}
#[test]
fn check_submodule_name_rejects_dotdot() {
assert!(!check_submodule_name("a/../b"));
assert!(!check_submodule_name(".."));
assert!(!check_submodule_name("../x"));
assert!(!check_submodule_name("a/.."));
assert!(!check_submodule_name(""));
assert!(check_submodule_name("normal/name"));
assert!(check_submodule_name("a..b"));
assert!(check_submodule_name("..."));
}
#[test]
fn check_submodule_url_rejects_escapes() {
assert!(!check_submodule_url("-upload-pack=evil"));
assert!(!check_submodule_url("../:evil"));
assert!(!check_submodule_url("..//evil"));
assert!(check_submodule_url("../../../host/path"));
assert!(check_submodule_url("https://example.com/ok.git"));
assert!(check_submodule_url("./relative"));
assert!(!check_submodule_url("git://h/%0arepo"));
}
#[test]
fn update_strategy_parses() {
assert_eq!(parse_update_type("checkout"), UpdateType::Checkout);
assert_eq!(parse_update_type("none"), UpdateType::None);
assert_eq!(parse_update_type("!cmd"), UpdateType::Command);
assert_eq!(parse_update_type("bogus"), UpdateType::Unspecified);
let strat = parse_update_strategy("!run").expect("command");
assert_eq!(strat.kind, UpdateType::Command);
assert_eq!(strat.command.as_deref(), Some("run"));
assert!(parse_update_strategy("bogus").is_none());
}
#[test]
fn fetch_recurse_parses() {
assert_eq!(parse_fetch_recurse("true"), RecurseMode::On);
assert_eq!(parse_fetch_recurse("false"), RecurseMode::Off);
assert_eq!(parse_fetch_recurse("on-demand"), RecurseMode::OnDemand);
assert_eq!(parse_fetch_recurse("garbage"), RecurseMode::Error);
}
#[test]
fn shallow_and_branch_parse() {
let cfg = config_from("[submodule \"s\"]\n\tbranch = main\n\tshallow = true\n");
let set = SubmoduleConfigSet::parse(&cfg);
let sub = set.from_name("s").expect("s");
assert_eq!(sub.branch.as_deref(), Some("main"));
assert_eq!(sub.recommend_shallow, Some(true));
}
}