//! Small helpers that don't depend on git or the web server. /// Turn "seconds ago" into a short human string like "3 hours ago". pub fn age(seconds_ago: i64) -> String { if seconds_ago < 0 { return "in the future".to_string(); } let s = seconds_ago; let (n, unit) = if s < 60 { return "just now".to_string(); } else if s < 3600 { (s / 60, "minute") } else if s < 86_400 { (s / 3600, "hour") } else if s < 86_400 * 30 { (s / 86_400, "day") } else if s < 86_400 * 365 { (s / (86_400 * 30), "month") } else { (s / (86_400 * 365), "year") }; let plural = if n == 1 { "" } else { "s" }; format!("{n} {unit}{plural} ago") } /// Seconds since the Unix epoch, right now. pub fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// Format a Unix timestamp + UTC offset (in minutes) as "2026-10-31 15:04:05 -0700". /// /// Implemented by hand (days-to-civil-date algorithm) to avoid pulling in a /// date/time crate for one function. pub fn format_time(unix: i64, offset_minutes: i32) -> String { let local = unix + offset_minutes as i64 * 60; let days = local.div_euclid(86_400); let secs = local.rem_euclid(86_400); let (y, m, d) = civil_from_days(days); let sign = if offset_minutes < 0 { '-' } else { '+' }; let off = offset_minutes.abs(); format!( "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} {sign}{:02}{:02}", secs / 3600, (secs / 60) % 60, secs % 60, off / 60, off % 60 ) } /// Howard Hinnant's algorithm: days since 1970-01-01 -> (year, month, day). fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d) } /// Check a path that came from a URL, like `src/main.rs`. /// /// We never touch the filesystem with these paths (files are looked up inside /// git objects), but we still refuse anything odd so that `..`, absolute paths /// and control characters can't sneak through. Returns the cleaned path with /// no leading or trailing slashes ("" means the repo root). pub fn clean_repo_path(raw: &str) -> Option { let mut parts = Vec::new(); for part in raw.split('/') { match part { "" => continue, // tolerate "a//b" and leading/trailing slashes "." | ".." => return None, p if p.chars().any(|c| c.is_control() || c == '\\') => return None, p => parts.push(p), } } Some(parts.join("/")) } /// Only allow ref names / commit ids made of "normal" characters. This keeps /// out git revision syntax such as `HEAD@{1}`, `main^`, `:/text` or `a..b`. pub fn is_safe_ref(name: &str) -> bool { !name.is_empty() && name.len() <= 200 && !name.starts_with('-') && !name.contains("..") && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')) } #[cfg(test)] mod tests { use super::*; #[test] fn ages() { assert_eq!(age(5), "just now"); assert_eq!(age(60), "1 minute ago"); assert_eq!(age(7200), "2 hours ago"); assert_eq!(age(86_400 * 3), "3 days ago"); assert_eq!(age(86_400 * 65), "2 months ago"); assert_eq!(age(86_400 * 800), "2 years ago"); assert_eq!(age(-10), "in the future"); } #[test] fn formats_time() { assert_eq!(format_time(0, 0), "1970-01-01 00:00:00 +0000"); // 2026-10-31 22:31:00 UTC shown in UTC-7 assert_eq!(format_time(1_793_485_860, -420), "2026-10-31 15:31:00 -0700"); assert_eq!(format_time(951_782_400, 330), "2000-02-29 05:30:00 +0530"); } #[test] fn repo_paths() { assert_eq!(clean_repo_path("src/main.rs").as_deref(), Some("src/main.rs")); assert_eq!(clean_repo_path("/src//main.rs/").as_deref(), Some("src/main.rs")); assert_eq!(clean_repo_path("").as_deref(), Some("")); assert_eq!(clean_repo_path("../etc/passwd"), None); assert_eq!(clean_repo_path("src/../../x"), None); assert_eq!(clean_repo_path("./x"), None); assert_eq!(clean_repo_path("a\\..\\b"), None); assert_eq!(clean_repo_path("a\0b"), None); } #[test] fn refs() { assert!(is_safe_ref("main")); assert!(is_safe_ref("edward/syscalls")); assert!(is_safe_ref("v0.15.1")); assert!(is_safe_ref("58fc4fd")); assert!(!is_safe_ref("HEAD@{1}")); assert!(!is_safe_ref("main^")); assert!(!is_safe_ref("a..b")); assert!(!is_safe_ref("--upload-pack=x")); assert!(!is_safe_ref("")); } } /// Percent-encode a repo path for use inside a URL path (keeps `/`). /// File names can contain spaces, `#`, `?` and other characters that would /// otherwise break links. pub fn encode_path(path: &str) -> String { encode(path, true) } /// Percent-encode a value for use in a `?key=value` query string. pub fn encode_query(value: &str) -> String { encode(value, false) } fn encode(s: &str, keep_slash: bool) -> String { let mut out = String::with_capacity(s.len()); for &b in s.as_bytes() { let keep = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') || (keep_slash && b == b'/'); if keep { out.push(b as char); } else { out.push_str(&format!("%{b:02X}")); } } out } #[cfg(test)] mod encode_tests { use super::*; #[test] fn encodes() { assert_eq!(encode_path("src/a b#c?.rs"), "src/a%20b%23c%3F.rs"); assert_eq!(encode_query("edward/syscalls"), "edward%2Fsyscalls"); assert_eq!(encode_path("ünï"), "%C3%BCn%C3%AF"); } }