use regex::Regex;
use std::sync::LazyLock;
pub const WORKSPACE_CLONE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20 * 60);
pub const CLONE_LOG_LINE_MAX_CHARS: usize = 500;
static RE_RECEIVING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)Receiving objects:\s+(\d+)%").expect("regex"));
static RE_RESOLVING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)Resolving deltas:\s+(\d+)%").expect("regex"));
static RE_COMPRESSING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)Compressing objects:\s+(\d+)%").expect("regex"));
static RE_CRED_URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)(https?://)([^/@\s]+):([^/@\s]+)@").expect("regex"));
static RE_BEARER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]{8,}\b").expect("regex"));
static RE_BASIC_AUTH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bBasic\s+[A-Za-z0-9+/=]{8,}\b").expect("regex"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloneUrlError {
Empty,
UnsupportedScheme,
Invalid,
}
impl CloneUrlError {
pub fn user_message(&self) -> &'static str {
match self {
Self::Empty => "仓库 URL 不能为空",
Self::UnsupportedScheme => {
"仅支持 https://、http://、git@host:path 或 ssh:// 形式的仓库 URL"
}
Self::Invalid => "仓库 URL 无效",
}
}
}
pub fn validate_clone_repo_url(raw: &str) -> Result<&str, CloneUrlError> {
let url = raw.trim();
if url.is_empty() {
return Err(CloneUrlError::Empty);
}
if url_has_illegal_chars(url) {
return Err(CloneUrlError::Invalid);
}
let lower = url.to_ascii_lowercase();
if let Some(r) = try_validate_http_clone_url(url, &lower) {
return r;
}
if let Some(r) = try_validate_ssh_clone_url(url, &lower) {
return r;
}
if let Some(r) = try_validate_git_at_clone_url(url) {
return r;
}
if lower.starts_with("file:") || lower.starts_with("ftp:") {
return Err(CloneUrlError::UnsupportedScheme);
}
Err(CloneUrlError::UnsupportedScheme)
}
fn url_has_illegal_chars(url: &str) -> bool {
url.contains('\0') || url.chars().any(|c| c.is_control())
}
fn try_validate_http_clone_url<'a>(
url: &'a str,
lower: &str,
) -> Option<Result<&'a str, CloneUrlError>> {
if !(lower.starts_with("https://") || lower.starts_with("http://")) {
return None;
}
Some(if url.len() < 12 {
Err(CloneUrlError::Invalid)
} else {
Ok(url)
})
}
fn try_validate_ssh_clone_url<'a>(
url: &'a str,
lower: &str,
) -> Option<Result<&'a str, CloneUrlError>> {
if !lower.starts_with("ssh://") {
return None;
}
Some(if url.len() < 10 {
Err(CloneUrlError::Invalid)
} else {
Ok(url)
})
}
fn try_validate_git_at_clone_url(url: &str) -> Option<Result<&str, CloneUrlError>> {
let rest = url.strip_prefix("git@")?;
Some(if rest.contains(':') && !rest.contains("://") {
Ok(url)
} else {
Err(CloneUrlError::Invalid)
})
}
pub fn validate_clone_branch(raw: &str) -> Result<&str, &'static str> {
let b = raw.trim();
if b.is_empty() {
return Err("分支名不能为空");
}
if b.len() > 255 {
return Err("分支名过长");
}
if b.starts_with('-') {
return Err("分支名不能以 '-' 开头");
}
if b.contains('\0') || b.chars().any(|c| c.is_control()) {
return Err("分支名含非法字符");
}
if b.contains("..") || b.contains('\\') {
return Err("分支名无效");
}
Ok(b)
}
pub fn parse_clone_progress_percent(line: &str) -> Option<(u8, &'static str)> {
if let Some(c) = RE_RECEIVING.captures(line) {
let p: u8 = c.get(1)?.as_str().parse().ok()?;
return Some((p.min(100), "Receiving objects"));
}
if let Some(c) = RE_RESOLVING.captures(line) {
let p: u8 = c.get(1)?.as_str().parse().ok()?;
return Some((p.min(100), "Resolving deltas"));
}
if let Some(c) = RE_COMPRESSING.captures(line) {
let p: u8 = c.get(1)?.as_str().parse().ok()?;
return Some((p.min(100), "Compressing objects"));
}
None
}
pub fn redact_clone_log_line(line: &str) -> String {
let cleaned = RE_CRED_URL.replace_all(line.trim(), "${1}***:***@");
let cleaned = RE_BEARER.replace_all(cleaned.as_ref(), "Bearer <redacted>");
let cleaned = RE_BASIC_AUTH.replace_all(cleaned.as_ref(), "Basic <redacted>");
let s = cleaned.as_ref();
if s.chars().count() <= CLONE_LOG_LINE_MAX_CHARS {
return s.to_string();
}
let truncated: String = s.chars().take(CLONE_LOG_LINE_MAX_CHARS).collect();
format!("{truncated}…")
}
pub fn split_progress_chunks(buf: &mut String, chunk: &str) -> Vec<String> {
buf.push_str(chunk);
let mut out = Vec::new();
while let Some(pos) = buf.find(['\n', '\r']) {
let mut line = buf[..pos].to_string();
buf.drain(..=pos);
if buf.starts_with('\n') {
buf.remove(0);
}
line = line.trim_end_matches('\r').to_string();
if !line.is_empty() {
out.push(line);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_https_and_git_at() {
assert!(validate_clone_repo_url("https://github.com/a/b.git").is_ok());
assert!(validate_clone_repo_url("git@github.com:a/b.git").is_ok());
assert!(validate_clone_repo_url("ssh://git@host/a/b.git").is_ok());
}
#[test]
fn rejects_file_and_empty() {
assert_eq!(
validate_clone_repo_url("file:///tmp/x"),
Err(CloneUrlError::UnsupportedScheme)
);
assert_eq!(validate_clone_repo_url(" "), Err(CloneUrlError::Empty));
}
#[test]
fn rejects_bad_branch() {
assert!(validate_clone_branch("-main").is_err());
assert!(validate_clone_branch("ma\nin").is_err());
assert_eq!(validate_clone_branch("feature/x").unwrap(), "feature/x");
}
#[test]
fn parses_receiving_percent() {
assert_eq!(
parse_clone_progress_percent("Receiving objects: 42% (12/28)"),
Some((42, "Receiving objects"))
);
}
#[test]
fn redacts_embedded_basic_auth() {
let s = redact_clone_log_line("fatal: https://user:secret@github.com/a/b.git");
assert!(!s.contains("secret"));
assert!(s.contains("***:***@"));
}
#[test]
fn redacts_bearer_token() {
let s = redact_clone_log_line("header Authorization: Bearer ghp_abcdefghijklmnop");
assert!(s.contains("Bearer <redacted>"));
assert!(!s.contains("ghp_"));
}
#[test]
fn redacts_basic_auth() {
let s = redact_clone_log_line(
"header Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2h1X3Rlc3RfdG9rZW4=",
);
assert!(s.contains("Basic <redacted>"));
assert!(!s.contains("eC1hY2Nlc3M"));
}
#[test]
fn split_cr_progress() {
let mut buf = String::new();
let lines =
split_progress_chunks(&mut buf, "Receiving objects: 10%\rReceiving objects: 20%\n");
assert_eq!(lines.len(), 2);
assert!(buf.is_empty());
}
}