crates/gunnar-ui-server/src/address.rs
//! **The console's one address scheme** (GUN-GUEST, owner 2026-10-30).
//!
//! A repository is `/{ns}/{name}`, and its pages are under it: `tree/…` (a directory or a
//! file), `blob/…` (a file), `commits`, `issues`, `issues/{n}`, `pulls`, `pulls/{n}`, `diff`,
//! `blame`, `search`, `chain`, `activity` and `settings`. The same address for a visitor, a
//! signed-in person and a crawler, and the same one a search result links. The old
//! `/repo/{ns}/{name}…` answers 301 to it ([`moved`], [`legacy_redirect`]).
//!
//! A namespace can never be named like one of the console's or the server's own first
//! segments ([`RESERVED`]): `/admin/storage` is the console's page, not repository
//! `storage` in namespace `admin`.
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use http::{header, Method, StatusCode};
/// **Every first path segment that is the console's or the server's own**, never a
/// namespace: the console's routes (`serve::CONSOLE_ROUTES`), the server's prefixes
/// (`serve::SERVER_PREFIXES`) and every other route a build registers (the buy flow's ways
/// back in a `metered` build, the twin door, SCIM, OAuth and MCP), and the front's `download`.
pub const RESERVED: &[&str] = &[
"", "repos", "repo", "access", "storage", "usage", "events", "account", "mirrors", "overview", "admin", "machine", "cli",
"api", "auth", "health", "login", "welcome", "signup", "private",
"twin-door", "scim", "oauth", "mcp", "download",
// The buy flow's ways back, registered only in a `metered` build.
#[cfg(feature = "metered")]
"paid",
#[cfg(feature = "metered")]
"cancel",
];
/// The leaves a repository's address may carry (the console's repository pages, and the
/// site's three files beside them).
pub const LEAVES: &[&str] = &[
"tree", "blob", "commits", "issues", "pulls", "diff", "blame", "search", "chain", "activity", "settings", "files", "graph",
"readme.md", "tree.txt", "issues.md",
];
/// Is `segment` a name the console's routes and the server's own never take?
pub fn is_reserved(segment: &str) -> bool {
RESERVED.iter().any(|r| r.eq_ignore_ascii_case(segment))
}
/// A path segment that names a namespace or a repository: plain characters, as gunnar
/// allows them (no `/`, never `.` or `..`).
fn plain(segment: &str) -> bool {
!segment.is_empty() && segment != "." && segment != ".." && segment.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'%'))
}
/// **Is `path` a repository's address** (`/{ns}/{name}` or one of its pages)? `(ns, name,
/// leaf, rest)` where it is.
/// **A namespace's own address**, `/{ns}` (GUN-FIELD-TEST, 2026-11-01): one plain segment
/// that is no console route. The sitemap names it; its page lists the public repositories.
pub fn namespace(path: &str) -> Option<&str> {
let ns = path.strip_prefix('/')?.trim_end_matches('/');
(plain(ns) && !is_reserved(ns) && !ns.contains('.') && !ns.contains('/')).then_some(ns)
}
pub fn repository(path: &str) -> Option<(&str, &str, &str, &str)> {
let rest = path.strip_prefix('/')?;
let mut parts = rest.splitn(4, '/');
let ns = parts.next()?;
let name = parts.next()?;
let leaf = parts.next().unwrap_or("");
let tail = parts.next().unwrap_or("");
if !plain(ns) || is_reserved(ns) || ns.contains('.') || !plain(name) {
return None;
}
if !leaf.is_empty() && !LEAVES.contains(&leaf) {
return None;
}
Some((ns, name, leaf, tail))
}
/// **Where an old `/repo/{ns}/{name}…` address lives now**: `path` and its query to the
/// new address. `files` is `tree` (its `?path=` in the path), `graph` is `commits`, any
/// other leaf keeps its name, and the rest of the query stays.
pub fn moved(path: &str, query: Option<&str>) -> Option<String> {
let keep = |to: String| match query.filter(|q| !q.is_empty()) {
Some(q) => format!("{to}?{q}"),
None => to,
};
// `/repo` is the catalogue, `/repo/{ns}` the namespace (gunnar-ui-web's `addr::from_old`).
if path == "/repo" || path == "/repo/" {
return Some(keep("/repos".to_owned()));
}
let rest = path.strip_prefix("/repo/")?;
let mut parts = rest.splitn(3, '/');
let ns = parts.next().filter(|s| !s.is_empty())?;
let Some(name) = parts.next().filter(|s| !s.is_empty()) else {
return Some(keep(format!("/{ns}")));
};
let tail = parts.next().unwrap_or("").trim_end_matches('/');
let (leaf, deeper) = tail.split_once('/').unwrap_or((tail, ""));
let mut pairs: Vec<&str> = query.unwrap_or("").split('&').filter(|p| !p.is_empty()).collect();
let mut to = format!("/{ns}/{name}");
match leaf {
"" => {}
"files" => {
to.push_str("/tree");
if let Some(at) = pairs.iter().position(|p| p.starts_with("path=")) {
let file = pairs.remove(at)["path=".len()..].trim_matches('/').to_owned();
if !file.is_empty() {
to.push('/');
// A query value spells `/` as `%2F` at times; in the path it is a separator.
to.push_str(&file.replace("%2F", "/").replace("%2f", "/"));
}
}
}
"graph" => to.push_str("/commits"),
other => {
to.push('/');
to.push_str(other);
}
}
if !deeper.is_empty() && !matches!(leaf, "files" | "graph") {
to.push('/');
to.push_str(deeper);
}
if !pairs.is_empty() {
to.push('?');
to.push_str(&pairs.join("&"));
}
Some(to)
}
/// **The 301 from the old address**, for everyone, before any session is asked about.
pub async fn legacy_redirect(request: Request, next: Next) -> Response {
if matches!(*request.method(), Method::GET | Method::HEAD) {
if let Some(to) = moved(request.uri().path(), request.uri().query()) {
return (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, to)]).into_response();
}
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_old_address_moves_to_the_new_one() {
assert_eq!(moved("/repo/a/b", None).as_deref(), Some("/a/b"));
assert_eq!(moved("/repo/a/b/", None).as_deref(), Some("/a/b"));
assert_eq!(moved("/repo/a/b/files", Some("path=docs/x.md&rev=main")).as_deref(), Some("/a/b/tree/docs/x.md?rev=main"));
assert_eq!(moved("/repo/a/b/files", Some("path=docs%2Fx.md")).as_deref(), Some("/a/b/tree/docs/x.md"));
assert_eq!(moved("/repo/a/b/graph", Some("ref=refs%2Fheads%2Fmain")).as_deref(), Some("/a/b/commits?ref=refs%2Fheads%2Fmain"));
assert_eq!(moved("/repo/a/b/pulls/4", None).as_deref(), Some("/a/b/pulls/4"));
assert_eq!(moved("/repo", None).as_deref(), Some("/repos"));
assert_eq!(moved("/repo/a", None).as_deref(), Some("/a"));
for not in ["/repos", "/a/b", "/repository/a/b"] {
assert_eq!(moved(not, None), None, "{not}");
}
}
#[test]
fn a_repository_address_is_never_one_of_the_consoles_own() {
assert_eq!(repository("/nordisk/gunnar"), Some(("nordisk", "gunnar", "", "")));
assert_eq!(repository("/nordisk/gunnar/blob/docs/v2/README.md"), Some(("nordisk", "gunnar", "blob", "docs/v2/README.md")));
assert_eq!(repository("/nordisk/gunnar.rs/tree"), Some(("nordisk", "gunnar.rs", "tree", "")));
for not in ["/", "/nordisk", "/admin/storage", "/account/activity", "/api/repos/a", "/twin-door/restore", "/nordisk/gunnar/unknown-leaf", "/gunnar_silver.webp/x", "/a/.."] {
assert_eq!(repository(not), None, "{not}");
}
}
#[test]
fn every_console_route_and_server_prefix_is_reserved() {
for route in crate::serve::CONSOLE_ROUTES.iter().chain(crate::serve::SERVER_PREFIXES) {
let first = route.trim_start_matches('/').split('/').next().unwrap_or("");
assert!(is_reserved(first), "{route}");
}
}
}