use crate::error::ForgeError;
pub fn percent_guard(path: &str) -> Result<(), ForgeError> {
if path.chars().any(|c| c == '\\' || c.is_control()) {
return Err(ForgeError::BadRequest("bad path".into()));
}
let target = path.split(['?', '#']).next().unwrap_or(path);
if target.split('/').any(|seg| seg == "." || seg == "..") {
return Err(ForgeError::BadRequest("bad path".into()));
}
let b = path.as_bytes();
for i in 0..b.len().saturating_sub(2) {
if b[i] == b'%' {
let hex = [b[i + 1].to_ascii_lowercase(), b[i + 2].to_ascii_lowercase()];
if matches!(&hex, b"2e" | b"2f" | b"5c" | b"00") {
return Err(ForgeError::BadRequest("bad path".into()));
}
}
}
Ok(())
}
#[must_use]
pub fn ok_owner(s: &str) -> bool {
name_ok(s, 64) && !s.contains("..") && !reserved_owner(s)
}
pub const RESERVED_OWNERS: &[&str] = &["api", "xlogin.js"];
#[must_use]
pub fn reserved_owner(s: &str) -> bool {
RESERVED_OWNERS.iter().any(|r| r.eq_ignore_ascii_case(s))
}
#[must_use]
pub fn ok_repo(s: &str) -> bool {
name_ok(s, 128) && !s.contains("..") && !s.ends_with(".git")
}
fn name_ok(s: &str, max: usize) -> bool {
let mut it = s.chars();
match it.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
s.len() <= max && it.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
#[must_use]
pub fn ok_segment(s: &str) -> bool {
if s.is_empty() || s.len() > 255 || s.starts_with('.') || s.contains("..") {
return false;
}
percent_guard(s).is_ok()
}
#[must_use]
pub fn ok_ref(s: &str) -> bool {
let mut it = s.chars();
match it.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
s.len() <= 201
&& it.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
&& !s.contains("..")
&& !s.ends_with('/')
&& !s.contains("//")
}
#[must_use]
pub fn is_sha(s: &str) -> bool {
(4..=64).contains(&s.len()) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
#[must_use]
pub fn ok_path(segs: &[String]) -> bool {
segs.iter().all(|s| ok_segment(s))
}
#[cfg(test)]
mod tests {
#[test]
fn dot_segments_are_refused_but_dotted_names_are_not() {
use super::percent_guard;
for bad in [
"/a/./b",
"/a/../b",
"/..",
"/me/../victim/demo.git/info/refs",
] {
assert!(percent_guard(bad).is_err(), "{bad}");
}
for ok in ["/a/.b", "/a/b.c/d", "/a/...x", "/a/b?x=/../y"] {
assert!(percent_guard(ok).is_ok(), "{ok}");
}
}
use super::*;
#[test]
fn percent_forms_are_refused_case_insensitively() {
for p in [
"/a/%2e%2e/x",
"/a/%2E/x",
"/a/b%2Fc",
"/a/b%5Cc",
"/a/b%5cc",
"/a/b%00c",
"/a/b%2f",
] {
assert!(percent_guard(p).is_err(), "{p}");
}
}
#[test]
fn decoded_nul_backslash_and_controls_are_refused() {
assert!(percent_guard("/a/b\0c").is_err());
assert!(percent_guard("/a/b\\c").is_err());
assert!(percent_guard("/a/b\nc").is_err());
}
#[test]
fn ordinary_paths_and_other_escapes_pass() {
assert!(percent_guard("/a/b/c.txt").is_ok());
assert!(percent_guard("/a/100%/x").is_ok());
assert!(percent_guard("/a/%41/x").is_ok());
assert!(percent_guard("/").is_ok());
assert!(percent_guard("").is_ok());
assert!(percent_guard("/a/%2").is_ok());
}
#[test]
fn validators_mirror_jss() {
assert!(ok_owner("casey") && ok_owner(&"a".repeat(64)));
assert!(!ok_owner(&"a".repeat(65)) && !ok_owner("") && !ok_owner("a/b"));
assert!(ok_repo("my.repo_1") && !ok_repo("x.git") && !ok_repo("a..b"));
assert!(ok_ref("v1.0") && ok_ref("feature/x") && !ok_ref("a//b") && !ok_ref("a/"));
assert!(!ok_ref("--upload-pack=x"));
assert!(is_sha("abcd") && !is_sha("abc") && !is_sha("ABCD"));
assert!(ok_segment("README.md") && !ok_segment("..") && !ok_segment(""));
assert!(!ok_segment("a%5Cb") && !ok_segment("a\tb"));
assert!(ok_path(&["src".to_string(), "lib.rs".to_string()]));
assert!(!ok_path(&["src".to_string(), ".git".to_string()]));
}
}