feat: edit repository settings from the browser via an authenticated web key
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
No reviews of this commit yet — record a verdict below.
Start a review
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/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);
+ }
+}