git-ents.gitmain
⌘K
foforge
commit 62347f9
feat: edit repository settings from the browser via an authenticated web key

A signed-in member can edit a repository’s description in the web UI. The edit is landed as a real git push --signed into the repo — staged on a throwaway ref, then pushed onto refs/meta/config — so it passes the same pre-receive gate a command-line push does rather than writing the ref directly. The web key is a member key whose public half is added through a normal push; its private half is held in memory for the session only and is never persisted. Editing is offered only when the server runs with the signed-push gate configured, so a web edit is never a way around it.

feat: add in-memory web sessions holding a member’s web key feat: land browser settings edits through a signed push to refs/meta/config feat: add a sign-in page and account strip to the web UI feat: make the repository description editable on the settings page feat: add git_ents::config::store_to_ref to stage a config edit on a ref Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents-server/src/http.rs @@ -38,7 +38,8 @@ // UI rather than handed to the CGI backend. if is_web_get(&path_info, &query_string) { let host = header_value(&headers, "Host"); - return crate::web::render(&state, &path_info, host.as_deref()).await; + let cookie = header_value(&headers, "Cookie"); + return crate::web::render(&state, &path_info, host.as_deref(), cookie.as_deref()).await; } backend( @@ -68,6 +69,12 @@ return (StatusCode::BAD_REQUEST, "bad request").into_response(); } + // The browser UI POSTs to sign in, sign out, and save edits; only the two + // git smart-HTTP RPCs go to the backend. + if !is_git_post(&path_info) { + return crate::web::handle_post(&state, &path_info, &headers, body).await; + } + backend( &state, Method::POST, @@ -79,6 +86,11 @@ .await } +/// Whether a POST is a git smart-HTTP RPC rather than a browser form submission. +fn is_git_post(path_info: &str) -> bool { + path_info.ends_with("/git-upload-pack") || path_info.ends_with("/git-receive-pack") +} + /// Hand a git wire-protocol request to `git http-backend` and reply with its /// output. A receive-pack request (push) auto-creates its bare repository before /// the backend runs and reconciles `HEAD` after a successful push. @@ -549,6 +561,7 @@ cert_nonce_seed: cert_nonce_seed.map(str::to_owned), hooks_dir: hooks_dir.map(PathBuf::from), checks_queue: PathBuf::from("/data/checks-queue"), + sessions: crate::web::new_sessions(), } }
crates/git-ents-server/src/main.rs @@ -83,6 +83,9 @@ /// Directory the `post-receive` hook queues pushes into and the check /// worker drains; passed down to the hook via [`checks::QUEUE_ENV`]. pub(crate) checks_queue: PathBuf, + /// In-memory web sessions: a browser's signed-in web key, held for the life + /// of the process and never persisted. + pub(crate) sessions: web::Sessions, } fn main() -> ExitCode { @@ -134,6 +137,7 @@ cert_nonce_seed: args.cert_nonce_seed, hooks_dir: args.hooks_dir, checks_queue: args.checks_queue, + sessions: web::new_sessions(), }; // Drain queued pushes and run their checks for the life of the server.
crates/git-ents/src/config.rs @@ -41,7 +41,18 @@ /// Write `config` to [`CONFIG_REF`], replacing any existing value, as a new /// commit. pub fn store(repo: &Path, config: &Config) -> Result<(), git_store::Error> { - git_store::Store::open(repo)?.store(CONFIG_REF, config, "Update configuration")?; + store_to_ref(repo, CONFIG_REF, config) +} + +/// Build the configuration commit on `refname` — chaining on that ref's own tip +/// — without touching [`CONFIG_REF`]. +/// +/// The web write path stages an edit on a throwaway ref pointed at the current +/// config tip, then lands it onto [`CONFIG_REF`] through a signed push, so the +/// `pre-receive` gate judges the change rather than this writing the live ref +/// directly. +pub fn store_to_ref(repo: &Path, refname: &str, config: &Config) -> Result<(), git_store::Error> { + git_store::Store::open(repo)?.store(refname, config, "Update configuration")?; Ok(()) }
crates/git-ents-server/src/web/mod.rs @@ -13,16 +13,31 @@ mod icons; mod pages; mod render; +mod write; use std::path::{Path, PathBuf}; -use axum::http::StatusCode; +use axum::body::Bytes; +use axum::http::header::{LOCATION, SET_COOKIE}; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use maud::{DOCTYPE, Markup, PreEscaped, html}; use crate::AppState; use crate::http::{MAX_REPO_DEPTH, is_bare_repo, valid_segment}; +pub(crate) use self::write::{Sessions, new_sessions}; + +/// Who is signed in for the current request, resolved per repository: a member's +/// web key authorizes edits only on a repo whose member list contains it. +pub(super) struct Auth { + /// The session key's display label. + label: String, + /// The member username this key maps to in the current repo, when it is a + /// member there — the gate for showing edit controls. + username: Option<String>, +} + use self::assets::{COPY_SCRIPT, FONTS, STYLE}; use self::git::{discover_repos, git_output}; use self::icons::{icon_branch, icon_chevron, icon_folder, icon_logo, icon_repo, icon_search}; @@ -30,41 +45,164 @@ /// Render the page for `path`: the repository index at the root, a repository /// overview, or one of its browse views (`tree`, `blob`, `commit`). `host` is /// the request's `Host` header, used to build a copy-pasteable clone URL. -pub(crate) async fn render(state: &AppState, path: &str, host: Option<&str>) -> Response { +pub(crate) async fn render( + state: &AppState, + path: &str, + host: Option<&str>, + cookie: Option<&str>, +) -> Response { let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let session = write::snapshot(&state.sessions, cookie); if segments.is_empty() { - return index(state).into_response(); + return index(state, session.as_ref()).into_response(); + } + if segments == ["login"] { + return login_page(session.as_ref(), None).into_response(); } - // The repository is the shortest valid prefix (up to `MAX_REPO_DEPTH` - // segments) that names a bare repo on disk; anything after it selects a - // browse view. Resolving the boundary this way keeps a repo named - // `tree`/`blob`/`commit` distinct from the route markers of the same name. - let depth_limit = segments.len().min(MAX_REPO_DEPTH); - for depth in 1..=depth_limit { - let Some(repo_segs) = segments.get(..depth) else { - break; - }; - if !repo_segs.iter().all(|s| valid_segment(s)) { - break; - } - let relative: PathBuf = repo_segs.iter().collect(); - let repo = state.data_dir.join(&relative); - if !is_bare_repo(&repo) { - continue; - } - let rel = repo_segs.join("/"); - let rest = segments.get(depth..).unwrap_or_default(); - return route(&repo, &rel, rest, host).await; + if let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) { + return route(&repo, &rel, rest, host, session).await; } not_found().into_response() } +/// Resolve the leading path segments to a repository: the shortest valid prefix +/// (up to [`MAX_REPO_DEPTH`] segments) that names a bare repo on disk, with the +/// rest of the path selecting a view. Resolving the boundary this way keeps a +/// repo named `tree`/`blob`/`commit` distinct from the route markers of the same +/// name. +fn resolve_repo<'a>( + data_dir: &Path, + segments: &'a [&'a str], +) -> Option<(PathBuf, String, &'a [&'a str])> { + let depth_limit = segments.len().min(MAX_REPO_DEPTH); + for depth in 1..=depth_limit { + let repo_segs = segments.get(..depth)?; + if !repo_segs.iter().all(|s| valid_segment(s)) { + break; + } + let relative: PathBuf = repo_segs.iter().collect(); + let repo = data_dir.join(&relative); + if !is_bare_repo(&repo) { + continue; + } + let rel = repo_segs.join("/"); + let rest = segments.get(depth..).unwrap_or_default(); + return Some((repo, rel, rest)); + } + None +} + +/// Handle a browser POST: signing in, signing out, or saving a settings edit. +/// Git wire POSTs never reach here — [`crate::http`] routes those to the backend. +pub(crate) async fn handle_post( + state: &AppState, + path: &str, + headers: &HeaderMap, + body: Bytes, +) -> Response { + let cookie = headers + .get(axum::http::header::COOKIE) + .and_then(|value| value.to_str().ok()); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + + if segments == ["login"] { + return match write::login(&state.sessions, &body) { + Ok(token) => redirect("/login", Some(session_cookie(&token))), + Err(error) => login_page(None, Some(&error)).into_response(), + }; + } + if segments == ["logout"] { + write::logout(&state.sessions, cookie); + return redirect("/login", Some(cleared_cookie())); + } + + let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) else { + return not_found().into_response(); + }; + if rest != ["settings"] { + return not_found().into_response(); + } + save_settings(state, &repo, &rel, cookie, body).await +} + +/// Apply a settings edit, then redirect back to the settings page on success or +/// render the reason it was rejected. +async fn save_settings( + state: &AppState, + repo: &Path, + rel: &str, + cookie: Option<&str>, + body: Bytes, +) -> Response { + let (Some(seed), Some(hooks)) = (state.cert_nonce_seed.clone(), state.hooks_dir.clone()) else { + return edit_error( + rel, + "Editing is disabled: this server is not enforcing the signed-push gate.", + ) + .into_response(); + }; + let description = write::field(&body, "description").unwrap_or_default(); + + let sessions = state.sessions.clone(); + let cookie = cookie.map(str::to_owned); + let repo = repo.to_owned(); + let result = tokio::task::spawn_blocking(move || { + write::edit_description( + &sessions, + cookie.as_deref(), + &repo, + &description, + &seed, + &hooks, + ) + }) + .await; + + match result { + Ok(Ok(())) => redirect(&format!("/{rel}/settings"), None), + Ok(Err(error)) => edit_error(rel, &error).into_response(), + Err(_join) => edit_error(rel, "the edit did not complete").into_response(), + } +} + +/// A `303 See Other` redirect to `location`, optionally setting a cookie. +fn redirect(location: &str, set_cookie: Option<String>) -> Response { + let mut builder = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(LOCATION, location); + if let Some(cookie) = set_cookie { + builder = builder.header(SET_COOKIE, cookie); + } + builder + .body(axum::body::Body::empty()) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +/// The `Set-Cookie` value that opens a session. +fn session_cookie(token: &str) -> String { + format!("{}={token}; Path=/; HttpOnly; SameSite=Lax", write::COOKIE) +} + +/// The `Set-Cookie` value that clears a session. +fn cleared_cookie() -> String { + format!( + "{}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax", + write::COOKIE + ) +} + /// Dispatch the part of the path that follows the repository to a browse view. /// Each top-level tab is its own route, since the product is server-rendered /// with no client JavaScript. -async fn route(repo: &Path, rel: &str, rest: &[&str], host: Option<&str>) -> Response { +async fn route( + repo: &Path, + rel: &str, + rest: &[&str], + host: Option<&str>, + session: Option<write::SessionSnapshot>, +) -> Response { let meta = gather_meta(repo, rel).await; match rest.split_first() { None => pages::repo_page(repo, &meta, host).await.into_response(), @@ -75,11 +213,32 @@ Some((&"releases", &[])) => pages::releases_page(repo, &meta).await.into_response(), Some((&"checks", &[])) => pages::checks_page(repo, &meta).await.into_response(), Some((&"issues", &[])) => pages::issues_page(repo, &meta).await.into_response(), - Some((&"settings", &[])) => pages::settings_page(repo, &meta).await.into_response(), + Some((&"settings", &[])) => { + let auth = resolve_auth(repo, session).await; + pages::settings_page(repo, &meta, auth.as_ref()) + .await + .into_response() + } _ => not_found().into_response(), } } +/// Resolve the request's session into per-repo [`Auth`]: whether the session's +/// web key is a member of `repo`, and under which username. +async fn resolve_auth(repo: &Path, session: Option<write::SessionSnapshot>) -> Option<Auth> { + let session = session?; + let repo = repo.to_owned(); + let key = session.public_key.clone(); + let username = tokio::task::spawn_blocking(move || write::member_for_public_key(&repo, &key)) + .await + .ok() + .flatten(); + Some(Auth { + label: session.label, + username, + }) +} + /// The top-level tabs of a repository page. #[derive(Clone, Copy, PartialEq, Eq)] enum Tab { @@ -231,11 +390,12 @@ } /// The repository listing shown at `/`. -fn index(state: &AppState) -> Markup { +fn index(state: &AppState, session: Option<&write::SessionSnapshot>) -> Markup { let repos = discover_repos(&state.data_dir); page( "Repositories", html! { + (account_strip(session)) div.page-header { h1.page-title { (icon_repo()) "Repositories" } @if !repos.is_empty() { @@ -283,6 +443,69 @@ ) } +/// A small right-aligned strip showing who is signed in, with a sign-in or +/// sign-out control. +fn account_strip(session: Option<&write::SessionSnapshot>) -> Markup { + html! { + div.account-strip { + @match session { + Some(s) => { + span.muted { "Signed in · " (s.label) } + form method="post" action="/logout" { + button.btn.btn-quiet type="submit" { "Sign out" } + } + } + None => a.btn.btn-quiet href="/login" { "Sign in" } + } + } + } +} + +/// The sign-in page: paste a web key to open a session. `error` shows a failed +/// attempt's reason. +fn login_page(session: Option<&write::SessionSnapshot>, error: Option<&str>) -> Markup { + page( + "Sign in", + html! { + (account_strip(session)) + div.page-header { h1.page-title { "Sign in" } } + @if let Some(s) = session { + p { "Signed in as " strong { (s.label) } "." } + p.muted { "Your web key signs edits made in the browser." } + } @else { + p.shell-note { + "Paste the " strong { "private" } " half of a web key whose public half you " + "have added to your member ref. It is held in memory for this session only " + "and is never written to disk." + } + @if let Some(error) = error { + div.card-row.muted { "Could not sign in: " (error) } + } + form.edit-form method="post" action="/login" { + label { "Key name (optional)" } + input type="text" name="label" placeholder="laptop web key"; + label { "Private key" } + textarea name="private_key" rows="8" spellcheck="false" + placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" {} + button.btn type="submit" { "Sign in" } + } + } + }, + ) +} + +/// A page reporting why a settings edit was rejected, with a way back. +fn edit_error(rel: &str, error: &str) -> Markup { + page( + "Edit rejected", + html! { + div.page-header { h1.page-title { "Edit rejected" } } + div.card-row.muted { (error) } + p { a.btn href={ "/" (rel) "/settings" } { "Back to settings" } } + }, + ) +} + /// Wrap page `body` in the shared HTML shell, navigation, and styling. fn page(title: &str, body: Markup) -> Markup { html! {
crates/git-ents-server/src/web/pages.rs @@ -818,7 +818,11 @@ /// refs — `refs/meta/config` (General), `refs/meta/members` (Members), and the /// derived feature and check status. Editing is a members-gated write path that /// does not exist yet, so the values are presented as the current configuration. -pub(super) async fn settings_page(repo: &Path, meta: &RepoMeta) -> Markup { +pub(super) async fn settings_page( + repo: &Path, + meta: &RepoMeta, + auth: Option<&super::Auth>, +) -> Markup { let members = load_members(repo).await; let checks = load_checks(repo).await; repo_shell( @@ -832,11 +836,12 @@ "The repository's configuration on " code { "refs/meta/config" } " and " code { "refs/meta/members" } "." } + (settings_auth_banner(auth)) div.card { div.card-header { "General" } (setting_row("Repository name", meta.name())) - (setting_row("Description", meta.description.as_deref().unwrap_or("—"))) + (description_setting(meta, auth)) (setting_row("Homepage", meta.homepage.as_deref().unwrap_or("—"))) (setting_row("Default branch", meta.branch.as_deref().unwrap_or("—"))) div.card-row { @@ -920,6 +925,44 @@ .map_err(|err| err.to_string()) } +/// The settings authorization banner: who is signed in and whether they may +/// edit this repository. +fn settings_auth_banner(auth: Option<&super::Auth>) -> Markup { + html! { + @match auth { + None => p.shell-note { + a href="/login" { "Sign in" } " with a member web key to edit these settings." + } + Some(auth) if auth.username.is_some() => p.shell-note.can-edit { + "Signed in as " strong { (auth.label) } " — you can edit this repository." + } + Some(auth) => p.shell-note { + "Signed in as " strong { (auth.label) } ", but this key is not a member of this " + "repository, so settings are read-only." + } + } + } +} + +/// The Description row: an inline edit form when the signed-in key is a member, +/// otherwise the read-only value. +fn description_setting(meta: &RepoMeta, auth: Option<&super::Auth>) -> Markup { + let value = meta.description.as_deref().unwrap_or_default(); + if auth.and_then(|a| a.username.as_deref()).is_none() { + return setting_row("Description", meta.description.as_deref().unwrap_or("—")); + } + html! { + div.card-row { + span.setting-label { "Description" } + form.inline-edit method="post" action={ "/" (meta.rel) "/settings" } { + input type="text" name="description" value=(value) + placeholder="A short description"; + button.btn type="submit" { "Save" } + } + } + } +} + /// A read-only setting row: a label and its current value. fn setting_row(label: &str, value: &str) -> Markup { html! {
crates/git-ents-server/src/web/style.css @@ -349,3 +349,19 @@ .issues-head, .filter-row { flex-wrap: wrap; } .btn-primary { margin-left: 0; } } + +/* Authenticated browser edits: the account strip, sign-in form, and inline edits. */ +.account-strip { display: flex; align-items: center; justify-content: flex-end; gap: .75rem; margin-bottom: 1rem; font-size: .85rem; } +.account-strip form { margin: 0; } +.btn-quiet { margin-top: 0; padding: .3rem .75rem; font-size: .82rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); text-decoration: none; cursor: pointer; } +.btn-quiet:hover { border-color: var(--color-accent); color: var(--color-accent); } +.shell-note.can-edit { color: var(--color-accent); } +.edit-form { display: flex; flex-direction: column; gap: .4rem; max-width: 40rem; } +.edit-form label { font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); } +.edit-form input, .edit-form textarea, .inline-edit input { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem; } +.edit-form input:focus, .edit-form textarea:focus, .inline-edit input:focus { outline: none; border-color: var(--color-accent); } +.edit-form textarea { resize: vertical; } +.edit-form .btn { align-self: flex-start; } +.inline-edit { display: flex; gap: .5rem; flex: 1; align-items: center; } +.inline-edit input { flex: 1; min-width: 0; } +.inline-edit .btn { margin-top: 0; padding: .35rem .85rem; }
crates/git-ents-server/src/web/write.rs @@ -1,0 +1,430 @@ +//! Authenticated browser writes. +//! +//! A web session holds one member's *web key* — a key whose public half they +//! have already added to their member ref through a normal signed push. An edit +//! made in the browser is performed as a real `git push --signed` into the repo, +//! so it travels through the very same `pre-receive` gate a command-line push +//! does. Nothing here is a second trust path: the gate alone decides whether a +//! change lands; this module only stages the change and produces a signed push +//! for it to judge. +//! +//! The web key lives in memory for the life of the process and is never written +//! to disk. A server restart drops every session. + +use std::collections::HashMap; +use std::io::Read as _; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; + +/// The cookie that carries a session token. +pub(super) const COOKIE: &str = "ents_session"; + +/// In-memory session table, shared by every handler. +pub(crate) type Sessions = Arc<Mutex<HashMap<String, Session>>>; + +/// One browser session: the web key it signs edits with and a display label. +pub(crate) struct Session { + /// The PEM private key the session signs pushes with. In memory only. + private_key: String, + /// The derived public key line (`type base64`), matched against members. + public_key: String, + /// A human label for the key — its given name, or its type. + label: String, +} + +/// A cheap, cloneable view of a session for rendering and authorization, without +/// the private key. +#[derive(Clone)] +pub(super) struct SessionSnapshot { + pub(super) label: String, + pub(super) public_key: String, +} + +/// Create an empty session table. +pub(crate) fn new_sessions() -> Sessions { + Arc::new(Mutex::new(HashMap::new())) +} + +/// The session a `Cookie` header points at, as a snapshot, if any. +pub(super) fn snapshot(sessions: &Sessions, cookie: Option<&str>) -> Option<SessionSnapshot> { + let token = token(cookie?)?; + let table = sessions.lock().ok()?; + let session = table.get(&token)?; + Some(SessionSnapshot { + label: session.label.clone(), + public_key: session.public_key.clone(), + }) +} + +/// Open a session for the web key in `body` (a `private_key` form field), set its +/// cookie, and return the token. The key is accepted as long as it parses; an +/// edit is authorized per-repository against the live member list, so holding a +/// session grants nothing on its own. +pub(super) fn login(sessions: &Sessions, body: &[u8]) -> Result<String, String> { + let fields = form(body); + let private_key = fields + .get("private_key") + .map(String::as_str) + .unwrap_or_default() + .trim() + .to_owned(); + if private_key.is_empty() { + return Err("paste a private key to sign in".to_owned()); + } + let public_key = derive_public_key(&private_key)?; + let label = fields + .get("label") + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| key_type(&public_key)); + + let token = random_token()?; + let mut table = sessions + .lock() + .map_err(|_poisoned| "session store unavailable".to_owned())?; + table.insert( + token.clone(), + Session { + private_key, + public_key, + label, + }, + ); + Ok(token) +} + +/// Drop the session a `Cookie` header points at, if any. +pub(super) fn logout(sessions: &Sessions, cookie: Option<&str>) { + let Some(token) = cookie.and_then(token) else { + return; + }; + if let Ok(mut table) = sessions.lock() { + table.remove(&token); + } +} + +/// Land a new repository description by staging it on a throwaway ref and pushing +/// it, signed with the session's web key, onto `refs/meta/config` — through the +/// `pre-receive` gate. Returns `Ok` only when the gate accepts the push. +/// +/// `seed` and `hooks` are the server's signed-push nonce seed and hooks +/// directory; both are required, so a web edit is never a way around a server +/// that is not enforcing the gate. +pub(super) fn edit_description( + sessions: &Sessions, + cookie: Option<&str>, + repo: &Path, + new_description: &str, + seed: &str, + hooks: &Path, +) -> Result<(), String> { + let token = cookie + .and_then(token) + .ok_or_else(|| "sign in to edit settings".to_owned())?; + let (private_key, public_key) = { + let table = sessions + .lock() + .map_err(|_poisoned| "session store unavailable".to_owned())?; + let session = table + .get(&token) + .ok_or_else(|| "sign in to edit settings".to_owned())?; + (session.private_key.clone(), session.public_key.clone()) + }; + + let username = member_for_public_key(repo, &public_key) + .ok_or_else(|| "your web key is not a member of this repository".to_owned())?; + + let mut config = + git_ents::config::load(repo).map_err(|e| format!("could not read config: {e}"))?; + config.description = new_description.to_owned(); + + let staging = format!("refs/web-staging/{}", random_token()?); + let result = stage_and_push( + repo, + &staging, + &config, + &private_key, + &username, + seed, + hooks, + ); + // Clean up the staging ref whether or not the push was accepted. + let _cleanup = git(repo, &["update-ref", "-d", &staging]); + result +} + +/// Point `staging` at the current config tip, build the new config commit on it, +/// then push it signed onto `refs/meta/config`. +fn stage_and_push( + repo: &Path, + staging: &str, + config: &git_ents::config::Config, + private_key: &str, + username: &str, + seed: &str, + hooks: &Path, +) -> Result<(), String> { + if let Some(tip) = rev_parse(repo, git_ents::config::CONFIG_REF) { + git(repo, &["update-ref", staging, &tip]) + .map_err(|e| format!("could not stage the edit: {e}"))?; + } + git_ents::config::store_to_ref(repo, staging, config) + .map_err(|e| format!("could not build the edit: {e}"))?; + + let keydir = tempfile::tempdir().map_err(|e| format!("could not create temp dir: {e}"))?; + let keyfile = keydir.path().join("web-key"); + write_private_key(&keyfile, private_key)?; + + let hooks = hooks + .to_str() + .ok_or_else(|| "hooks path is not UTF-8".to_owned())?; + let receive_pack = format!( + "git -c receive.certNonceSeed={seed} -c receive.certNonceSlop=60 -c core.hooksPath={hooks} receive-pack" + ); + let url = format!("file://{}", repo.display()); + let refspec = format!("{staging}:{}", git_ents::config::CONFIG_REF); + + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["-c", "gpg.format=ssh"]) + .arg("-c") + .arg(format!("user.signingkey={}", keyfile.display())) + .arg("-c") + .arg(format!("user.name={username}")) + .arg("-c") + .arg(format!("user.email={username}@web")) + .arg("push") + .arg("--signed") + .arg(format!("--receive-pack={receive_pack}")) + .arg(&url) + .arg(&refspec) + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("could not run git push: {e}"))?; + if output.status.success() { + Ok(()) + } else { + Err(push_error(&output.stderr)) + } +} + +/// The username of the member whose web key matches `public_key`, if any. The +/// match is on the key type and body, ignoring any trailing comment. +pub(super) fn member_for_public_key(repo: &Path, public_key: &str) -> Option<String> { + let wanted = normalize_key(public_key); + let members = git_ents::members::load_all(repo).ok()?; + members.into_iter().find_map(|member| { + member + .keys() + .iter() + .any(|(_fingerprint, key)| normalize_key(key) == wanted) + .then(|| member.principal.clone()) + }) +} + +/// A public key reduced to its type and body, dropping the comment so two lines +/// for the same key compare equal. +fn normalize_key(line: &str) -> String { + let mut parts = line.split_whitespace(); + let kind = parts.next().unwrap_or_default(); + let body = parts.next().unwrap_or_default(); + format!("{kind} {body}") +} + +/// The key's type word, used as a fallback label. +fn key_type(public_key: &str) -> String { + public_key + .split_whitespace() + .next() + .unwrap_or("key") + .to_owned() +} + +/// Derive the public key line from a PEM private key, validating it parses. +fn derive_public_key(private_key: &str) -> Result<String, String> { + let dir = tempfile::tempdir().map_err(|e| format!("could not create temp dir: {e}"))?; + let keyfile = dir.path().join("web-key"); + write_private_key(&keyfile, private_key)?; + let output = Command::new("ssh-keygen") + .arg("-y") + .arg("-f") + .arg(&keyfile) + .output() + .map_err(|e| format!("could not run ssh-keygen: {e}"))?; + if !output.status.success() { + return Err("that does not look like a usable private key".to_owned()); + } + let line = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if line.is_empty() { + return Err("could not derive a public key".to_owned()); + } + Ok(line) +} + +/// Write a private key to `path` with `0600` permissions and a trailing newline, +/// alongside no public key — ssh derives the public half when signing. +fn write_private_key(path: &Path, private_key: &str) -> Result<(), String> { + let mut contents = private_key.trim_end().to_owned(); + contents.push('\n'); + std::fs::write(path, &contents).map_err(|e| format!("could not write key: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("could not secure key file: {e}"))?; + } + Ok(()) +} + +/// The pre-receive rejection reason from git's stderr, or a generic message. +fn push_error(stderr: &[u8]) -> String { + let text = String::from_utf8_lossy(stderr); + text.lines() + .find_map(|line| line.trim().strip_prefix("remote: error: ")) + .or_else(|| { + text.lines() + .find_map(|line| line.trim().strip_prefix("remote: ")) + .filter(|l| !l.is_empty()) + }) + .map(str::to_owned) + .unwrap_or_else(|| "the push was rejected".to_owned()) +} + +/// The committed tip of `refname`, or `None` when the ref is absent. +fn rev_parse(repo: &Path, refname: &str) -> Option<String> { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", "--quiet", refname]) + .stdin(Stdio::null()) + .output() + .ok()?; + let oid = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + (output.status.success() && !oid.is_empty()).then_some(oid) +} + +/// Run `git <args>` in `repo`, returning trimmed stdout or an error message. +fn git(repo: &Path, args: &[&str]) -> Result<String, String> { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("could not run git: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()) + } +} + +/// The session token in a `Cookie` header value, if present. +fn token(cookie: &str) -> Option<String> { + cookie.split(';').find_map(|pair| { + let (name, value) = pair.trim().split_once('=')?; + (name == COOKIE).then(|| value.to_owned()) + }) +} + +/// A fresh, unguessable session token: 32 random bytes from the OS, hex-encoded. +fn random_token() -> Result<String, String> { + let mut bytes = [0u8; 32]; + std::fs::File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(|e| format!("could not read randomness: {e}"))?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +/// One field's decoded value from an `application/x-www-form-urlencoded` body. +pub(super) fn field(body: &[u8], name: &str) -> Option<String> { + form(body).remove(name) +} + +/// Parse an `application/x-www-form-urlencoded` body into its fields. +fn form(body: &[u8]) -> HashMap<String, String> { + let text = String::from_utf8_lossy(body); + text.split('&') + .filter_map(|pair| { + let (key, value) = pair.split_once('=')?; + Some((percent_decode(key), percent_decode(value))) + }) + .collect() +} + +/// Decode one form field: `+` to space and `%XX` to its byte. +fn percent_decode(input: &str) -> String { + let spaced = input.replace('+', " "); + let mut parts = spaced.split('%'); + let mut out: Vec<u8> = parts.next().unwrap_or_default().as_bytes().to_vec(); + for part in parts { + let bytes = part.as_bytes(); + match ( + bytes.first().copied().and_then(hex_value), + bytes.get(1).copied().and_then(hex_value), + ) { + (Some(hi), Some(lo)) => { + out.push((hi << 4) | lo); + out.extend_from_slice(part.get(2..).unwrap_or_default().as_bytes()); + } + _ => { + out.push(b'%'); + out.extend_from_slice(bytes); + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A single hex digit's value, `0..=15`. +fn hex_value(byte: u8) -> Option<u8> { + (byte as char) + .to_digit(16) + .and_then(|d| u8::try_from(d).ok()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, reason = "unit test")] + use super::*; + + #[test] + fn normalizes_keys_by_dropping_the_comment() { + assert_eq!( + normalize_key("ssh-ed25519 AAAABODY laptop@host"), + normalize_key("ssh-ed25519 AAAABODY web"), + ); + } + + #[test] + fn decodes_form_fields() { + assert_eq!( + field(b"label=my+web+key&private_key=line1%0Aline2", "label").as_deref(), + Some("my web key"), + ); + assert_eq!( + field(b"label=my+web+key&private_key=line1%0Aline2", "private_key").as_deref(), + Some("line1\nline2"), + ); + } + + #[test] + fn reads_the_session_cookie() { + assert_eq!( + token("other=1; ents_session=abc123; x=2").as_deref(), + Some("abc123"), + ); + assert_eq!(token("other=1").as_deref(), None); + } + + #[test] + fn random_tokens_are_long_and_distinct() { + let a = random_token().unwrap(); + let b = random_token().unwrap(); + assert_eq!(a.len(), 64); + assert_ne!(a, b); + } +}