Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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}");
        }
    }
}