crates/gunnar-ui-server/src/api/absent.rs
//! **A namespace, or a repository under it, that the caller cannot see does
//! not exist** — and it says so in ONE voice.
//!
//! Before this module, spelling a namespace that is not there in the address
//! bar drew the whole repository page: the header, every pane, each pane a
//! refusal box or an empty table. And the refusals differed by what was behind
//! the name: gunnar answers the console's own key `NotFound` ("namespace x is
//! not in the catalog") for a name it does not hold, and a narrowed caller —
//! the guest, a tenant — `PermissionDenied` for a private repository they hold
//! no grant on. A 404 beside a 403 is an existence oracle: the URL alone told a
//! stranger which private names are real.
//!
//! So every READ scoped to a namespace — `/api/repos/{ns}/{name}[/…]` and
//! `/api/namespaces/{ns}/usage|activity` — that gunnar refuses with either code
//! is answered with the SAME status and the SAME bytes, [`body`]. What is
//! behind a name the caller may not read is indistinguishable from nothing.
//!
//! # Why it asks gunnar a second time, and only on a refusal
//!
//! A 404 inside a repository the caller CAN read is not "the repository does
//! not exist": a path missing from a tree, a revision nobody pushed, a pull
//! request number never opened. Those keep gunnar's own words, as every
//! refusal in this console does (see [`crate::api`]). So when a scoped read
//! fails with 403 or 404, the repository (or namespace) itself is asked about
//! once more, with the SAME credential the handler used ([`Reader`]): visible
//! → the original answer stands; refused → [`body`]. A read that succeeded
//! costs nothing extra, and a call that could not be made at all (gunnar down)
//! leaves the original answer alone rather than guessing.
//!
//! The two routes whose own call IS that question — the repository record and
//! the namespace's usage — skip the second ask: their refusal already is the
//! verdict (unless the console had no credential to ask with at all, which is
//! a deployment fault and keeps its own words).
//!
//! # Why a layer and not a helper in each handler
//!
//! For the reason [`Reader`] is an extractor: a helper is something the next
//! repository route can forget to call, and forgetting here is the oracle back.
//! The scope is decided by the PATH, in [`scope_of`], so a route added under
//! either prefix tomorrow is covered without opting in.
use axum::extract::{FromRequestParts, Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use http::{Method, StatusCode};
use crate::api::{repo_path, Reader};
use crate::app::AppState;
use crate::pb;
/// The one message every hidden name is answered with. Deliberately says
/// nothing about WHY: "does not exist" and "you may not see it" must read the
/// same, byte for byte.
pub const MESSAGE: &str = "this page does not exist";
/// The one body. `code` is `notfound`, which the browser's `Fail::summary`
/// already words as "It does not exist."
pub fn body() -> serde_json::Value {
serde_json::json!({ "code": "notfound", "message": MESSAGE })
}
/// The one response.
pub fn not_here() -> Response {
(StatusCode::NOT_FOUND, Json(body())).into_response()
}
/// What a scoped path names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
/// `/api/repos/{ns}/{name}` itself — the handler's own call is the check.
RepoRecord { namespace: String, name: String },
/// `/api/repos/{ns}/{name}/<anything>`.
InRepo { namespace: String, name: String },
/// `/api/namespaces/{ns}/usage` — the handler's own call is the check.
NamespaceUsage { namespace: String },
/// `/api/namespaces/{ns}/activity`.
InNamespace { namespace: String },
}
/// Percent-decode one path segment the way axum's `Path` extractor does, so
/// the name asked about here is the name the handler asked about.
fn decode(segment: &str) -> Option<String> {
percent_decode(segment)
}
fn percent_decode(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = s.get(i + 1..i + 3)?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
/// Which namespace-scoped read, if any, this path is. Reads only: a mutation's
/// refusal is an operator's business and keeps gunnar's words.
pub fn scope_of(method: &Method, path: &str) -> Option<Scope> {
if !matches!(*method, Method::GET | Method::HEAD) {
return None;
}
if let Some(rest) = path.strip_prefix("/api/repos/") {
let mut segments = rest.split('/');
let (namespace, name) = (segments.next()?, segments.next()?);
if namespace.is_empty() || name.is_empty() {
return None;
}
let (namespace, name) = (decode(namespace)?, decode(name)?);
return Some(match segments.next() {
None => Scope::RepoRecord { namespace, name },
Some(_) => Scope::InRepo { namespace, name },
});
}
if let Some(rest) = path.strip_prefix("/api/namespaces/") {
let mut segments = rest.split('/');
let namespace = segments.next().filter(|n| !n.is_empty())?;
let leaf = segments.next()?;
if segments.next().is_some() {
return None;
}
let namespace = decode(namespace)?;
return match leaf {
"usage" => Some(Scope::NamespaceUsage { namespace }),
"activity" => Some(Scope::InNamespace { namespace }),
_ => None,
};
}
None
}
/// Is this gunnar's "I will not show you that"? Both codes, deliberately.
fn hidden(code: tonic::Code) -> bool {
matches!(code, tonic::Code::NotFound | tonic::Code::PermissionDenied)
}
/// **The layer.** See the module.
pub async fn hide(State(state): State<AppState>, request: Request, next: Next) -> Response {
let Some(scope) = scope_of(request.method(), request.uri().path()) else {
return next.run(request).await;
};
// The request head, kept so the second ask is signed with the credential
// the handler used: `Reader` is decided from the `Caller` the auth layer
// put in the extensions.
let mut head = {
let mut copy = http::Request::builder()
.method(request.method().clone())
.uri(request.uri().clone())
.body(())
.expect("a copy of a request head that already parsed");
*copy.headers_mut() = request.headers().clone();
*copy.extensions_mut() = request.extensions().clone();
copy.into_parts().0
};
let response = next.run(request).await;
if !matches!(response.status(), StatusCode::NOT_FOUND | StatusCode::FORBIDDEN) {
return response;
}
match scope {
// The handler's own call was the question; its refusal is the answer.
// Unless there was no call: a 403 the CONSOLE raised because it has no
// credential for this caller at all (no guest identity configured) is
// a deployment fault, the same for every name, and hiding it behind
// "does not exist" would send an operator hunting for a namespace
// instead of a missing setting. That case is exactly the one where no
// `Reader` can be built, so it is told apart without reading the body.
Scope::RepoRecord { .. } | Scope::NamespaceUsage { .. } => {
match Reader::from_request_parts(&mut head, &state).await {
Ok(_) => not_here(),
Err(_) => response,
}
}
Scope::InRepo { namespace, name } => {
let Ok(reader) = Reader::from_request_parts(&mut head, &state).await else {
return response;
};
let asked = reader
.gunnar
.repos()
.get(pb::GetRepoRequest {
path: Some(repo_path(&namespace, &name)),
})
.await;
match asked {
Ok(_) => response,
Err(status) if hidden(status.code()) => not_here(),
Err(_) => response,
}
}
Scope::InNamespace { namespace } => {
let Ok(reader) = Reader::from_request_parts(&mut head, &state).await else {
return response;
};
let asked = reader
.gunnar
.usage()
.read(pb::ReadUsageRequest { namespace })
.await;
match asked {
Ok(_) => response,
Err(status) if hidden(status.code()) => not_here(),
Err(_) => response,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_scope_is_decided_by_the_path_and_covers_every_repository_leaf() {
let get = Method::GET;
assert_eq!(
scope_of(&get, "/api/repos/nordisk/gunnar"),
Some(Scope::RepoRecord { namespace: "nordisk".into(), name: "gunnar".into() })
);
for leaf in ["refs", "log", "browse", "pulls", "pulls/3", "issues", "readme", "gc", "a-leaf-added-tomorrow"] {
assert_eq!(
scope_of(&get, &format!("/api/repos/nordisk/gunnar/{leaf}")),
Some(Scope::InRepo { namespace: "nordisk".into(), name: "gunnar".into() }),
"{leaf}"
);
}
assert_eq!(
scope_of(&get, "/api/namespaces/n%C3%B6rd/usage"),
Some(Scope::NamespaceUsage { namespace: "nörd".into() })
);
assert_eq!(
scope_of(&get, "/api/namespaces/nordisk/activity"),
Some(Scope::InNamespace { namespace: "nordisk".into() })
);
// Not namespace-scoped reads.
for path in ["/api/repos", "/api/repos/deleted", "/api/namespaces", "/api/namespaces/x", "/api/namespaces/x/increase/offer", "/api/machine"] {
assert_eq!(scope_of(&get, path), None, "{path}");
}
// A mutation keeps gunnar's words.
assert_eq!(scope_of(&Method::DELETE, "/api/repos/nordisk/gunnar"), None);
assert_eq!(scope_of(&Method::POST, "/api/repos/nordisk/gunnar/gc"), None);
}
}