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/api/mirrors.rs

//! **Pull mirrors**: where a repository on this box came from, and the job that
//! is fetching it.
//!
//! [`super::forges`] answers "what repositories does this account have"; this is
//! what happens to the answer. Per chosen repository the import makes exactly
//! three calls, in this order:
//!
//! ```text
//!   Mirror.Add { create_repo: true }        the registration, and the repository
//!   Mirror.SetCredential { kind: TOKEN }    only for an https:// remote
//!   Mirror.Sync                              enqueue; the job is what comes back
//! ```
//!
//! # `Sync` ENQUEUES, and the response is a promise
//!
//! A fetch is minutes of somebody else's network, so `Mirror.Sync` returns as
//! soon as the sync is on the queue. Its `refs_changed` and `updates` fields are
//! **0 and empty on purpose** — gunnar's own proto says so on the wire — and
//! this module never reads them. It reads `job.state`, and the panel polls
//! [`status_one`] while that says `queued` or `running`, exactly as the
//! Maintenance panel polls `GcStatus`. The server runs one sync at a time and
//! dedups on the repository, so pressing "Sync now" twice is one job; the
//! console needs no guard beyond not firing two requests at once.
//!
//! # `has_progress` before the pair
//!
//! `MirrorJob` carries `progress_done` / `progress_total` and, in front of them,
//! `has_progress`. A job that has not reached the packfile header yet has no
//! denominator, and `(0, 0)` rendered as a progress bar would be a bar claiming
//! zero per cent instead of admitting it does not know. Both are passed through
//! faithfully and the DECISION lives in `gunnar_ui_web::mirror`, on the host
//! side of the wasm boundary, where `cargo test` can reach it.
//!
//! # The credential goes one way
//!
//! `SetCredential` is the only call this console makes that carries a secret
//! INTO gunnar. Nothing brings one back: no RPC on the service returns a
//! credential, `MirrorConfig` has a `credential` **kind** and no value, and the
//! BFF drops its own copy the moment an import finishes. What this module
//! renders is a word — `none`, `ssh-key`, `token` — and never a value.
//!
//! # Who may: gunnar, asked AS THE PERSON
//!
//! gunnar gates `Mirror.Add` (with `create_repo`: Admin over the namespace),
//! `Mirror.SetCredential` and `Mirror.Remove` at `Admin` on the repository and
//! `Mirror.Sync` at `Write`, and refuses a gated account. Since 2026-10-04
//! (thin layer) every one of those calls is signed AS THE PERSON
//! ([`crate::api::Actor`]) — the box's administrator with the console's key —
//! so gunnar's answer IS the gate, and the person gets exactly what
//! `gunnar control mirror …` with their own key would give them. Until then the
//! console decided with its own copy of the grant rule (looser than gunnar's:
//! write was enough to add, re-point and remove) and signed with its
//! server-wide key.
//!
//! What stays here is not a permission: the room check ("needs more room", read
//! as the person), the never-over-an-existing-repository guard and the
//! per-person limits ([`super::mirror_gate`]), and every refusal goes through
//! its one door.
//!
//! Reading one mirror's state is a reader's, by the same rule that makes
//! `GcStatus` one, and it is asked with the READER's credential
//! ([`crate::api::Reader`]): gunnar decides whether a tenant may read that
//! repository, and one they may not is answered as "no mirror".

use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::api::mirror_gate::{self, code, Fits, Room};
use crate::api::{repo_path, Actor, ApiError, ApiResult, Importer, Reader};
use crate::app::AppState;
use crate::forges;
use crate::grpc::Fail;
use crate::pb;

/// One reference a sync moved.
#[derive(Debug, Clone, Serialize)]
pub struct RefUpdate {
    pub ref_name: String,
    /// Empty for a reference the sync created.
    pub old_oid: String,
    pub new_oid: String,
}

/// ★ **A sync job, as the console shows it.**
///
/// Deliberately `GcJob`'s shape plus the two things a gc has no use for: a
/// progress pair, and a list of what moved. The withholding rules are the ones
/// `GcJob` established and they are not cosmetic — a zero-initialised field
/// rendered as a measurement is a claim the server did not make.
#[derive(Debug, Clone, Serialize)]
pub struct MirrorJob {
    /// Stable for the life of the job, so a poller can tell its own run from
    /// somebody else's that started after it.
    pub job_id: String,
    pub path: String,
    /// `queued` | `running` | `done` | `failed` | `unknown`.
    pub state: &'static str,
    /// How many jobs are ahead of this one. Only meaningful while `queued`.
    pub queue_position: u32,
    /// **Read this before the pair.** False means the fetch has not reached the
    /// packfile header, so there is no denominator — which is a different
    /// answer from "nought per cent".
    pub has_progress: bool,
    pub progress_done: u64,
    pub progress_total: u64,
    /// Packfile bytes off the wire. **The live signal**: this is the figure
    /// that moves continuously while a fetch runs, which is why it is passed
    /// through in every state rather than withheld until `done`. Zero on a
    /// queued job is the truth — nothing has been taken off the wire yet.
    pub bytes: u64,
    /// Objects the fetch brought in. `None` unless `done`: the pair above is
    /// what a running job has to say about progress, and a zero here would read
    /// as "it fetched nothing", which is a finished job's answer.
    pub objects: Option<u64>,
    /// What moved, ref by ref. Empty until `done` — gunnar's own rule.
    pub updates: Vec<RefUpdate>,
    /// gunnar's words, verbatim. Only ever set when `failed`.
    pub error: Option<String>,
    pub enqueued_at_unix_ms: Option<i64>,
    pub started_at_unix_ms: Option<i64>,
    pub finished_at_unix_ms: Option<i64>,
    /// True when gunnar stopped this sync at the namespace's cap — its own
    /// pack-quota sentence in `error`. The panel shows [`MirrorJob::words`]
    /// and the way to more room instead of an engineer's line.
    pub needs_room: bool,
    /// The customer's sentence for a job that needs more room. `None`
    /// otherwise: every other failure keeps gunnar's own words in `error`.
    pub words: Option<String>,
    /// Where to buy more room, on a build that sells it.
    pub room_href: Option<&'static str>,
}

/// One registration, as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Mirror {
    /// `<namespace>/<repo>`.
    pub path: String,
    pub namespace: String,
    pub name: String,
    /// Where it pulls from, **with any userinfo removed** — see [`scrub`].
    pub remote: String,
    /// `ssh` | `https` | `other`, which is what decides which credential kind
    /// is admissible.
    pub transport: &'static str,
    pub prune: bool,
    /// `idle` | `syncing` | `failed` | `unknown`.
    pub state: &'static str,
    /// `none` | `ssh-key` | `token`. **A kind, never a value.**
    pub credential: &'static str,
    /// The Basic-auth username a token is paired with. Not a secret — gunnar's
    /// proto says so, and every forge documents its value.
    pub credential_username: String,
    pub last_sync_at_unix_ms: Option<i64>,
    /// The last failure, from the registration rather than from a job. `None`
    /// when empty, so the panel can tell "never failed" from "failed with an
    /// empty message".
    pub last_error: Option<String>,
    pub sync_count: u64,
    /// The job this repository has on the queue, or its last one. `None` means
    /// the server is holding none: never asked for, or finished long enough ago
    /// to have been dropped. Absence and "it did nothing" are different
    /// answers, and this is which.
    pub job: Option<MirrorJob>,
    /// **Whether THIS session may sync, forget or stop it** — the server's
    /// answer, so the page draws the buttons the server would honour and no
    /// others. Always true for an administrator; for anybody else, write on
    /// the namespace (or on this one repository) in gunnar's grant table.
    pub may_tend: bool,
    /// **The repository is made by the first sync that reaches the upstream**
    /// (gunnar 2026-10-18): until then there is a registration and no
    /// repository — nothing to link to, and the grant that tends it is the
    /// namespace's, as the import's own was.
    pub repo_pending: bool,
    /// The upstream keeps files in Git LFS and their content was not copied.
    pub lfs_not_copied: bool,
}

fn job_state_name(state: i32) -> &'static str {
    match pb::MirrorJobState::try_from(state) {
        Ok(pb::MirrorJobState::Queued) => "queued",
        Ok(pb::MirrorJobState::Running) => "running",
        Ok(pb::MirrorJobState::Done) => "done",
        Ok(pb::MirrorJobState::Failed) => "failed",
        // An enum value this build does not know is not the same as "no job",
        // and rendering it as `queued` would make a console poll for ever.
        _ => "unknown",
    }
}

fn mirror_state_name(state: i32) -> &'static str {
    match pb::MirrorState::try_from(state) {
        Ok(pb::MirrorState::Idle) => "idle",
        Ok(pb::MirrorState::Syncing) => "syncing",
        Ok(pb::MirrorState::Failed) => "failed",
        _ => "unknown",
    }
}

/// `none` | `ssh-key` | `token`, from `MirrorConfig.credential`.
fn credential_name(kind: i32) -> &'static str {
    match pb::MirrorCredentialKind::try_from(kind) {
        Ok(pb::MirrorCredentialKind::SshKey) => "ssh-key",
        Ok(pb::MirrorCredentialKind::Token) => "token",
        _ => "none",
    }
}

/// Which transport a remote URL names, which decides which credential kind
/// gunnar will accept for it.
///
/// gunnar refuses an SSH key against an `https://` remote and a token against
/// an `ssh://` one, **by name**, because a credential that cannot be offered is
/// a mirror that fails on authentication for ever with nothing naming the
/// cause. The console shows the same word so the mismatch is visible before the
/// refusal.
pub fn transport_of(remote: &str) -> &'static str {
    let remote = remote.trim();
    if remote.starts_with("https://") || remote.starts_with("http://") {
        "https"
    } else if remote.starts_with("ssh://") || (remote.contains('@') && remote.contains(':')) {
        // The scp-like form — `git@codeberg.org:u/r.git` — is SSH and has no
        // scheme to test, which is why the second arm exists.
        "ssh"
    } else {
        "other"
    }
}

/// ★ **Take the userinfo out of a remote URL before it is rendered.**
///
/// `https://x-access-token:ghp_live@github.com/u/r.git` is a perfectly ordinary
/// thing to find in `refs/gunnar/mirrors` — it is what `gunnar mirror add`
/// accepts from a shell, and a person doing it from a terminal has every reason
/// to think it stays there. Printing it in a browser puts a live credential on
/// a screen, into a screenshot, and into whatever the page is copied to.
///
/// The console never writes such a URL itself — the credential goes through
/// `SetCredential` and is sealed — so this exists entirely for the ones that
/// arrived some other way.
///
/// **The TRANSPORT decides what a bare `user@` is, and that is the whole of
/// the rule.**
///
/// Over ssh, `git@host:path` and `ssh://git@host/path` are how every SSH remote
/// in the world is spelled: that `git` is a user name, it is not a secret, and
/// stripping it would print a URL that does not work — a console showing an
/// operator a remote they cannot paste back is worse than one showing a name
/// they already knew.
///
/// Over **http(s) it is the opposite**, and the first draft of this function
/// had it wrong. `https://<token>@github.com/u/r.git` is the form GitHub's own
/// documentation gives for a personal access token, and GitLab, Forgejo and
/// every CI system that writes a remote by hand do the same — a bare userinfo
/// over https is a credential far more often than it is a user name. The
/// earlier rule looked only for a colon, so exactly the documented shape went
/// out to the browser intact, and the test below pinned that as correct.
/// Over https the name is dropped with it: a username with no password is not
/// needed to clone, and guessing which bare userinfo is a token and which is a
/// name is not a guess to make with a live credential.
pub fn scrub(remote: &str) -> String {
    let Some((scheme, rest)) = remote.split_once("://") else {
        // scp-like, or something else with no scheme. No `://` means no
        // userinfo syntax, so there is nothing here a password could be in.
        return remote.to_owned();
    };
    // Userinfo is everything before the LAST `@` of the authority — a password
    // may contain an `@` — and the authority ends at the first `/`.
    let (authority, path) = match rest.split_once('/') {
        Some((authority, path)) => (authority, Some(path)),
        None => (rest, None),
    };
    // `ssh` and `git` are the two schemes where a bare userinfo is a user name.
    // Anything else — https, http, and whatever arrives next — is treated as a
    // place a token can be, which is the side of the line to be wrong on.
    let names_a_user = matches!(scheme, "ssh" | "git");
    let host = match authority.rsplit_once('@') {
        Some((userinfo, host)) if userinfo.contains(':') || !names_a_user => host,
        _ => authority,
    };
    match path {
        Some(path) => format!("{scheme}://{host}/{path}"),
        None => format!("{scheme}://{host}"),
    }
}

fn render_job(job: pb::MirrorJob) -> MirrorJob {
    let room_stop = mirror_gate::stopped_at_the_cap(&job);
    let path = job.path.unwrap_or_default();
    let state = job_state_name(job.state);
    let done = state == "done";
    MirrorJob {
        job_id: job.job_id,
        path: format!("{}/{}", path.namespace, path.repo),
        state,
        queue_position: job.queue_position,
        has_progress: job.has_progress,
        progress_done: job.progress_done,
        progress_total: job.progress_total,
        bytes: job.bytes,
        objects: done.then_some(job.objects),
        // Empty until DONE, and not merely passed through: gunnar promises to
        // fill this in only then, and a console that rendered whatever arrived
        // would be making a claim about references that have not moved.
        updates: if done {
            job.updates
                .into_iter()
                .map(|u| RefUpdate {
                    ref_name: u.ref_name,
                    old_oid: u.old_oid,
                    new_oid: u.new_oid,
                })
                .collect()
        } else {
            Vec::new()
        },
        needs_room: state == "failed" && room_stop,
        // Round 5.3: no cap named; the way on is the console's to draw for
        // this session (`mirror::room_way`).
        words: (state == "failed" && room_stop).then(|| {
            format!(
                "This sync was stopped because {} needs more room, and nothing from it was kept.{}",
                path.namespace,
                mirror_gate::room_then()
            )
        }),
        room_href: (state == "failed" && room_stop)
            .then(mirror_gate::room_href)
            .flatten(),
        error: (!job.error.is_empty()).then_some(job.error),
        enqueued_at_unix_ms: crate::api::recorded(job.enqueued_at_unix_ms),
        started_at_unix_ms: crate::api::recorded(job.started_at_unix_ms),
        finished_at_unix_ms: crate::api::recorded(job.finished_at_unix_ms),
    }
}

fn render(config: pb::MirrorConfig) -> Mirror {
    let path = config.repo.unwrap_or_default();
    Mirror {
        path: format!("{}/{}", path.namespace, path.repo),
        namespace: path.namespace,
        name: path.repo,
        transport: transport_of(&config.remote_url),
        remote: scrub(&config.remote_url),
        prune: config.prune,
        state: mirror_state_name(config.state),
        credential: credential_name(config.credential),
        credential_username: config.credential_username,
        last_sync_at_unix_ms: config.last_sync_at_unix_ms.and_then(crate::api::recorded),
        last_error: (!config.last_error.is_empty()).then_some(config.last_error),
        sync_count: config.sync_count,
        job: config.job.map(render_job),
        may_tend: false,
        repo_pending: config.repo_pending,
        lfs_not_copied: config.lfs_not_copied,
    }
}

/// **May the person tend this mirror** — gunnar's answer for them. A
/// pending import has no repository for a repository-scoped check to find, so
/// it is asked of the namespace, which is what gunnar itself asks then.
async fn may_tend(gunnar: &crate::grpc::Gunnar, mirror: &Mirror) -> bool {
    let repo = if mirror.repo_pending { "" } else { mirror.name.as_str() };
    crate::api::may(gunnar, &mirror.namespace, repo, pb::GrantLevel::Write)
        .await
        .is_ok_and(|(allowed, _)| allowed)
}

/// The registrations in ONE namespace, asked with `gunnar` — the acting
/// person's own key (gunnar answers under Read on the namespace) or the
/// console's for the box's administrator.
async fn status_in(gunnar: &crate::grpc::Gunnar, namespace: &str) -> Result<Vec<Mirror>, ApiError> {
    let response = gunnar
        .mirror()
        .status(pb::MirrorStatusRequest {
            namespace: namespace.to_owned(),
            repo: None,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(response.mirrors.into_iter().map(render).collect())
}

/// Ask gunnar for every registration, or for one, with `gunnar`.
async fn status(gunnar: &crate::grpc::Gunnar, one: Option<pb::RepoPath>) -> Result<Vec<Mirror>, ApiError> {
    let response = gunnar
        .mirror()
        .status(pb::MirrorStatusRequest {
            // Empty namespace and no repo reports every mirror on the server,
            // which is what the console's window on the box wants: a
            // per-namespace filter with nothing to filter by would only hide
            // rows.
            namespace: String::new(),
            repo: one,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(response.mirrors.into_iter().map(render).collect())
}

// ───────────────────────────────────────────────────────────────────────────
// The gate, in the handlers' words
// ───────────────────────────────────────────────────────────────────────────

/// **The namespace's room, or the refusal.** `Ok(None)` is "the appliance
/// could not say", which an administrator proceeds through (gunnar still holds
/// the fetch to the cap) and anybody else is refused on — a customer's import
/// is not queued on a guess.
async fn room_for(
    actor: &Actor,
    namespace: &str,
    what: &str,
) -> Result<Option<Room>, ApiError> {
    let who = actor;
    let room = match mirror_gate::room(&actor.gunnar, namespace).await {
        Ok(room) => room,
        Err(why) if actor.console => {
            tracing::info!(
                target: crate::auth::AUDIT_TARGET,
                event = "mirror.room_unknown",
                user = %who.who,
                namespace = %namespace,
                reason = %why.0.message,
                "the room question was not answered; an administrator's import goes ahead \
                 and gunnar holds the fetch to the cap"
            );
            return Ok(None);
        }
        // gunnar refused the PERSON the namespace's meters: they may not read
        // it at all, so they may not import into it either. gunnar's words,
        // not a guess about the room.
        Err(why) if matches!(why.0.code.as_str(), "permissiondenied" | "notfound") => {
            return Err(why);
        }
        Err(_) => {
            return Err(mirror_gate::refused(
                &who.who,
                namespace,
                "unavailable",
                "room-unknown",
                format!(
                    "This box could not tell how much room {namespace} has left, so nothing \
                     was imported. Try again in a moment."
                ),
            ))
        }
    };
    if room.admits(None) == Fits::Full {
        return Err(mirror_gate::refused(
            &who.who,
            namespace,
            code::NEEDS_ROOM,
            "full",
            mirror_gate::room_words(namespace, &room, Fits::Full, what, None),
        ));
    }
    Ok(Some(room))
}

/// **Take a slot for `path`, or the refusal.** Administrators are not held to
/// the per-person limits.
async fn reserve(state: &AppState, who: &Actor, path: &str) -> Result<(), String> {
    if who.console {
        return Ok(());
    }
    mirror_gate::refresh(state, &who.gunnar, &who.who).await;
    state
        .imports
        .reserve(&who.who, path, std::time::Instant::now())
        .map_err(|busy| busy.words())
}

fn release(state: &AppState, who: &Actor, path: &str) {
    if !who.console {
        state.imports.release(&who.who, path);
    }
}

// ───────────────────────────────────────────────────────────────────────────
// Reading
// ───────────────────────────────────────────────────────────────────────────

/// `GET /api/mirrors` — the mirrors this caller tends.
///
/// An administrator sees every registration on the box. Anybody else is
/// answered AS THEMSELVES (thin layer, owner 2026-10-04): the namespaces gunnar
/// lists for them, each one's registrations as gunnar shows them to them, and
/// of those the ones gunnar says they may sync (`Access.Check{repo, WRITE}`) —
/// their own mirrors. A session bound to no principal sees none. Never
/// anonymous: [`Importer`] has no arm for a request with no session, because a
/// mirror's remote names a host somebody chose to pull from and the list of
/// them is not a stranger's business.
pub async fn list(who: Importer, State(state): State<AppState>) -> ApiResult<Vec<Mirror>> {
    if who.admin {
        // The box's administrator asks as THEMSELVES (their role is their
        // server-wide grant); only the break-glass token and the open console,
        // which are nobody at gunnar, ask with the console's key.
        let gunnar = match who.principal {
            Some(_) => Actor::person_named(&state, &who.who).await?.gunnar,
            None => state.gunnar.clone(),
        };
        let mut all = status(&gunnar, None).await?;
        for mirror in &mut all {
            mirror.may_tend = true;
        }
        return Ok(Json(all));
    }
    if who.principal.is_none() {
        return Ok(Json(Vec::new()));
    }
    let actor = Actor::person_named(&state, &who.who).await?;
    let mut mine = Vec::new();
    for namespace in namespaces_of(&actor.gunnar).await? {
        // A namespace gunnar lists but will not show the mirrors of is simply
        // not one of theirs.
        let Ok(rows) = status_in(&actor.gunnar, &namespace).await else {
            continue;
        };
        for mut mirror in rows {
            let tends = may_tend(&actor.gunnar, &mirror).await;
            if tends {
                mirror.may_tend = true;
                mine.push(mirror);
            }
        }
    }
    Ok(Json(mine))
}

/// Every namespace gunnar lists for the caller of `gunnar`.
async fn namespaces_of(gunnar: &crate::grpc::Gunnar) -> Result<Vec<String>, ApiError> {
    let mut names = Vec::new();
    let mut page = String::new();
    for _ in 0..64 {
        let answer = gunnar
            .repos()
            .list_namespaces(pb::ListNamespacesRequest {
                page_token: page.clone(),
                ..Default::default()
            })
            .await
            .map_err(ApiError::from)?
            .into_inner();
        names.extend(answer.namespaces.into_iter().map(|ns| ns.name));
        if answer.next_page_token.is_empty() {
            break;
        }
        page = answer.next_page_token;
    }
    Ok(names)
}

/// `GET /api/mirrors/{namespace}/{name}` — one registration, and its job.
///
/// **This is the poller.** The panel reads it every 2 s while the job says
/// `queued` or `running` and stops when it settles, and reads it once on mount
/// so a sync started from the CLI or another operator's console is visible
/// without pressing anything.
///
/// Asked with the READER's credential: the console's key for an operator or
/// admin, the person's OWN key for a viewer. gunnar's `Mirror.Status` wants
/// `Read` on the repository, so gunnar decides whether a tenant may see it, and
/// a repository they may not read is answered `null` — the same answer as a
/// repository with no mirror, so the panel cannot be used to learn which
/// private repositories are mirrors.
///
/// `null` otherwise means this repository has no mirror registered — a
/// different answer from a mirror that has never synced, which comes back as a
/// registration with `job: null`.
pub async fn status_one(
    who: Importer,
    State(state): State<AppState>,
    reader: Reader,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Option<Mirror>> {
    let one = repo_path(&namespace, &name);
    let answer = reader
        .gunnar
        .mirror()
        .status(pb::MirrorStatusRequest {
            namespace: String::new(),
            repo: Some(one),
        })
        .await;
    let mut found: Vec<Mirror> = match answer {
        Ok(answer) => answer
            .into_inner()
            .mirrors
            .into_iter()
            .map(render)
            .collect(),
        Err(status)
            if reader.narrowed()
                && matches!(
                    status.code(),
                    tonic::Code::PermissionDenied | tonic::Code::NotFound
                ) =>
        {
            Vec::new()
        }
        Err(status) => return Err(ApiError::from(status)),
    };
    let Some(mut mirror) = found.pop() else {
        return Ok(Json(None));
    };
    // Asked only when there is something to tend — gunnar's own answer for
    // this person (`Access.Check{repo, WRITE}`, what `Mirror.Sync` needs) — and
    // a check gunnar does not answer is "no buttons", never a failed read: the
    // panel is a reader's, and gunnar decides again on every press.
    mirror.may_tend = who.admin
        || (who.principal.is_some()
            && match Actor::person_named(&state, &who.who).await {
                Ok(actor) => may_tend(&actor.gunnar, &mirror).await,
                Err(_) => false,
            });
    Ok(Json(Some(mirror)))
}

// ───────────────────────────────────────────────────────────────────────────
// Tending one mirror
// ───────────────────────────────────────────────────────────────────────────

/// `POST /api/mirrors/{namespace}/{name}/sync` — the "Sync now" button.
///
/// **This ENQUEUES.** It returns with the job still live, not after a fetch: a
/// unary RPC that waited for one would time out on exactly the repositories
/// worth mirroring. `refs_changed` and `updates` on gunnar's response are 0 and
/// empty here by design and are not read.
///
/// Pressing twice is one job — the server dedups on the repository and hands
/// back the one already queued or running. That is the server's guarantee and
/// not this handler's: a console can only serialise itself, and the CLI holds
/// the same credential.
///
/// Before it is enqueued: the namespace must be one the caller tends, it must
/// not already be full (the room is re-checked on EVERY sync, not only an
/// import's first), and the caller must be under their limits.
pub async fn sync(
    who: Actor,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Mirror> {
    let full = format!("{namespace}/{name}");
    room_for(&who, &namespace, &full).await?;
    if let Err(words) = reserve(&state, &who, &full).await {
        return Err(mirror_gate::refused(
            &who.who,
            &namespace,
            code::BUSY,
            "busy",
            words,
        ));
    }
    let path = repo_path(&namespace, &name);
    let answer = match who
        .gunnar
        .mirror()
        .sync(pb::SyncMirrorRequest {
            repo: Some(path.clone()),
        })
        .await
    {
        Ok(answer) => answer.into_inner(),
        Err(e) => {
            release(&state, &who, &full);
            return Err(ApiError::from(e));
        }
    };
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "mirror.sync",
        user = %who.who,
        repo = %full,
        job = %answer.job.as_ref().map(|j| j.job_id.clone()).unwrap_or_default(),
        "a mirror sync was enqueued"
    );
    let mut mirror = answer.mirror.map(render).ok_or_else(|| {
        ApiError(Fail {
            code: "internal".to_owned(),
            message: "the server enqueued a sync but reported no mirror".to_owned(),
        })
    })?;
    // `SyncMirrorResponse.job` is the authority on which job this call got —
    // the one it created, or the one already there. `MirrorConfig.job` is read
    // from the registration and may be a moment behind it.
    if let Some(job) = answer.job {
        mirror.job = Some(render_job(job));
    }
    Ok(Json(mirror))
}

/// What the console sends to change a mirror's credential.
#[derive(Debug, Clone, Deserialize)]
pub struct Credential {
    /// Only `none` is accepted. See [`credential`].
    #[serde(default)]
    pub kind: String,
}

/// `POST /api/mirrors/{namespace}/{name}/credential` — "Forget credential".
///
/// **`none` is the only kind this route accepts, and that is a decision rather
/// than an unfinished feature.** Setting a credential means handing this
/// console a secret to forward, and the two doors that do that are the import
/// itself: a forge connection's token, verified by being used, or a token typed
/// beside a URL on [`import_url`] — both handed over ONCE, at registration, and
/// dropped here. An **SSH private key** would mean asking somebody to paste a
/// private key into a web form. This console will not ask for that. A deploy
/// key belongs on the appliance, and `gunnar mirror` at the control plane is
/// where it is set.
///
/// So what is left is forgetting one, which is `kind: UNSPECIFIED` with an
/// empty secret — gunnar's own spelling, and the reason `SetCredential` exists
/// as a verb instead of two more fields on `Add`.
pub async fn credential(
    who: Actor,
    Path((namespace, name)): Path<(String, String)>,
    Json(body): Json<Credential>,
) -> ApiResult<Mirror> {
    if body.kind.trim() != "none" {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: "this console only forgets a mirror credential. A token is set by \
                      importing — from a connected forge, or with the token field beside a \
                      URL; an SSH deploy key is set at the control plane, because a console \
                      must not ask anybody to paste a private key into a web form."
                .to_owned(),
        }));
    }
    let answer = who
        .gunnar
        .mirror()
        .set_credential(pb::SetMirrorCredentialRequest {
            repo: Some(repo_path(&namespace, &name)),
            kind: pb::MirrorCredentialKind::Unspecified as i32,
            // Must be empty when the kind is UNSPECIFIED, and gunnar refuses a
            // request that says otherwise by name — "I set a credential" and "I
            // cleared one" must never be one request read two ways.
            secret: String::new(),
            username: String::new(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "mirror.credential_forgotten",
        user = %who.who,
        repo = %format!("{namespace}/{name}"),
        "a mirror credential was forgotten"
    );
    answer.mirror.map(render).map(Json).ok_or_else(|| {
        ApiError(Fail {
            code: "internal".to_owned(),
            message: "the server forgot the credential and reported no mirror".to_owned(),
        })
    })
}

/// `?confirm=<namespace>/<name>` on a remove.
#[derive(Debug, Clone, Deserialize)]
pub struct Confirm {
    #[serde(default)]
    pub confirm: String,
}

/// `DELETE /api/mirrors/{namespace}/{name}?confirm=<ns>/<name>` — stop
/// mirroring.
///
/// **The repository stays.** This deletes the REGISTRATION in
/// `refs/gunnar/mirrors`, so the box keeps every object it fetched and simply
/// stops pulling; the panel says so, because "Remove mirror" beside a
/// repository page reads like a delete to anybody who has not read this.
///
/// The typed-back name is checked HERE and not only in the page, for the reason
/// `accounts::purge` gives: a fetch from a console tab is one line, so a guard
/// that lived in the Leptos component would protect the button and not the
/// verb. It rides in the query string rather than a body because a DELETE with
/// a body is a request half the HTTP stack in a browser will not send.
pub async fn remove(
    who: Actor,
    Path((namespace, name)): Path<(String, String)>,
    Query(confirm): Query<Confirm>,
) -> ApiResult<Removed> {
    let path = format!("{namespace}/{name}");
    if confirm.confirm.trim() != path {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: format!(
                "type the repository's path ({path}) to confirm. This removes the mirror \
                 registration; the repository and everything already fetched into it stay."
            ),
        }));
    }
    who.gunnar
        .mirror()
        .remove(pb::RemoveMirrorRequest {
            repo: Some(repo_path(&namespace, &name)),
        })
        .await
        .map_err(ApiError::from)?;
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "mirror.removed",
        user = %who.who,
        repo = %path,
        "a mirror registration was removed"
    );
    Ok(Json(Removed { ok: true }))
}

/// What a remove answered.
#[derive(Debug, Clone, Serialize)]
pub struct Removed {
    pub ok: bool,
}

// ───────────────────────────────────────────────────────────────────────────
// The import
// ───────────────────────────────────────────────────────────────────────────

/// What the picker sends.
#[derive(Debug, Clone, Deserialize)]
pub struct Import {
    /// Which connected forge the chosen repositories come from.
    pub forge: String,
    /// The gunnar namespace they land in.
    pub namespace: String,
    /// `https` or `ssh`. It decides which clone URL is registered and whether a
    /// credential is handed over at all.
    pub transport: String,
    /// `full_name`s, from the listing this console is holding. A name that is
    /// not in that listing is refused: the clone URL is resolved HERE, so a
    /// browser cannot name the host a mirror pulls from.
    pub repos: Vec<String>,
    /// Delete local refs the remote no longer has.
    #[serde(default)]
    pub prune: bool,
    /// Import archived repositories too. Off by default, and an archived
    /// repository named without it is **skipped with a reason** rather than
    /// silently dropped or silently imported.
    #[serde(default)]
    pub include_archived: bool,
}

/// What happened to one chosen repository.
#[derive(Debug, Clone, Serialize)]
pub struct Outcome {
    /// The forge's name for it.
    pub full_name: String,
    /// `<namespace>/<name>` on this box, empty when nothing was attempted.
    pub path: String,
    pub ok: bool,
    /// Which step this is about: `resolve` | `skipped` | `room` | `exists` |
    /// `busy` | `add` | `credential` | `sync`. On a success it is the last step
    /// reached, so a row reading `sync` is a row with a job.
    pub step: &'static str,
    /// gunnar's own words on a failure, or the console's on a skip or a
    /// refusal. Empty on success.
    pub message: String,
    /// True when a registration already existed. Not a failure: `Mirror.Add`
    /// replaces, which is what makes the picker safe to press twice.
    pub already_mirrored: bool,
    /// The job, when one was enqueued.
    pub job: Option<MirrorJob>,
    /// Where to buy more room, on a row refused at `room` by a build that
    /// sells it.
    pub room_href: Option<&'static str>,
    /// gunnar's own refusal, when gunnar refused the person's call — kept so a
    /// one-repository import answers with gunnar's status rather than a 200
    /// carrying a failed row. Not sent: the row's `message` already says it.
    #[serde(skip)]
    pub refused: Option<Fail>,
}

/// The whole import, summarised.
#[derive(Debug, Clone, Serialize)]
pub struct Imported {
    pub namespace: String,
    pub transport: String,
    /// One row per repository the picker named, in the order it named them.
    pub results: Vec<Outcome>,
    pub registered: usize,
    pub queued: usize,
    pub skipped: usize,
    pub failed: usize,
    /// True when a token was handed to gunnar and dropped here. The picker
    /// tells the person, because it means a second HTTPS import needs the forge
    /// connected again.
    pub token_handed_over: bool,
}

impl Imported {
    fn of(namespace: String, transport: &str, results: Vec<Outcome>, token: bool) -> Imported {
        Imported {
            namespace,
            transport: transport.to_owned(),
            registered: results
                .iter()
                .filter(|r| r.ok && r.step != "skipped")
                .count(),
            queued: results.iter().filter(|r| r.job.is_some()).count(),
            skipped: results.iter().filter(|r| r.step == "skipped").count(),
            failed: results.iter().filter(|r| !r.ok).count(),
            results,
            token_handed_over: token,
        }
    }
}

/// A row that was refused before anything was registered.
#[cfg(feature = "forges")]
fn refused_row(
    who: &Actor,
    namespace: &str,
    full_name: &str,
    path: String,
    step: &'static str,
    message: String,
) -> Outcome {
    // Logged once, here, exactly as a whole-request refusal is.
    let _ = mirror_gate::refused(&who.who, namespace, step, step, message.clone());
    Outcome {
        full_name: full_name.to_owned(),
        path,
        ok: false,
        step,
        message,
        already_mirrored: false,
        job: None,
        room_href: (step == "room").then(mirror_gate::room_href).flatten(),
        refused: None,
    }
}

/// `POST /api/mirrors/import` — the picker's button.
///
/// # It continues past a failure
///
/// One repository refusing must not strand the other nine. Every row carries
/// its own outcome and gunnar's own words, and the summary counts them; a
/// partial import is a normal result and is reported as one rather than as an
/// error with an unknown amount of work already done.
///
/// # What is refused before anything is queued
///
/// * a namespace that is already full — the whole import (whether the caller
///   may import into it at all is gunnar's: `Mirror.Add` is signed AS THE
///   PERSON and is Admin over the namespace there);
/// * a forge that is not connected, or a repository that is not in the listing
///   this console is holding — the clone URL is resolved here, so a browser
///   naming a URL cannot point a mirror at a host of its choosing;
/// * `transport: https` with no token held. The whole import is refused, and
///   **nothing is registered**: a mirror pointed at a private `https://` remote
///   with no credential is a mirror that fails on authentication for ever, and
///   registering ten of them before saying so is worse than saying so first;
/// * per row: a repository whose stated size does not fit in the room left
///   (`room`, "needs more room" — the estimates of one import add up), and one
///   past the caller's N-at-once or per-hour limit (`busy`).
///
/// # The token is handed over once
///
/// It is taken out of [`crate::forges::ForgeSessions`] at the top of the
/// import, given to `Mirror.SetCredential` for each HTTPS repository, and gone
/// from this process when the call returns — `take_token` leaves nothing
/// behind. gunnar seals it; the console has no further use for it and keeps no
/// copy.
#[cfg(not(feature = "forges"))]
pub async fn import(_who: Actor, State(_state): State<AppState>, Json(_body): Json<Import>) -> ApiResult<Imported> {
    Err(forges::not_built())
}

/// The import itself, in a `forges` build, once `GUNNAR_UI_FORGES=on` (AIRGAP).
#[cfg(feature = "forges")]
pub async fn import(
    who: Actor,
    State(state): State<AppState>,
    Json(body): Json<Import>,
) -> ApiResult<Imported> {
    state.forge_apps.refuse_unless_on()?;
    let kind = forges::kind_from_name(body.forge.trim()).ok_or_else(|| {
        ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: format!("{:?} is not a forge this console knows", body.forge),
        })
    })?;
    let namespace = body.namespace.trim().to_owned();
    if namespace.is_empty() {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: "an import needs a namespace to put the repositories in".to_owned(),
        }));
    }
    let over_https = match body.transport.trim() {
        "https" => true,
        "ssh" => false,
        other => {
            return Err(ApiError(Fail {
                code: "invalidargument".to_owned(),
                message: format!("{other:?} is not a transport; it is https or ssh"),
            }))
        }
    };
    // The room first, and before the token is taken: a refusal here must cost
    // the person nothing, not their connection. Whether they may import into
    // the namespace at all is gunnar's (`Mirror.Add` is Admin over it), asked
    // as them below.
    let room = room_for(&who, &namespace, "these repositories").await?;
    let (connection, held) = state.forges.listing(&who.who, kind).ok_or_else(|| {
        ApiError(Fail {
            code: "notfound".to_owned(),
            message: format!(
                "this console is not holding a {} connection for you; connect one and pick \
                 again",
                kind.as_str()
            ),
        })
    })?;

    // Taken before anything is registered, so the refusal below costs nothing.
    let token = over_https
        .then(|| state.forges.take_token(&who.who, kind))
        .flatten();
    if over_https && token.is_none() {
        return Err(ApiError(Fail {
            code: "failedprecondition".to_owned(),
            message: format!(
                "importing over https hands your {} token to the appliance, and this console \
                 is no longer holding one — it forgets the token after each import. Connect \
                 the forge again, or import over ssh.",
                kind.as_str()
            ),
        }));
    }
    let username = forges::credential_username(kind, &connection.account);

    // One read up front, so a row can say "already mirrored" without a second
    // round trip per repository. A registration that appears between this and
    // the `Add` below is not a problem: `Add` replaces.
    let existing: std::collections::BTreeSet<String> = status_in(&who.gunnar, &namespace)
        .await
        .map(|mirrors| mirrors.into_iter().map(|m| m.path).collect())
        .unwrap_or_default();

    let mut spent: u64 = 0;
    let mut results = Vec::with_capacity(body.repos.len());
    for wanted in &body.repos {
        let Some(repo) = held.iter().find(|r| &r.full_name == wanted) else {
            results.push(Outcome {
                full_name: wanted.clone(),
                path: String::new(),
                ok: false,
                step: "resolve",
                message: format!(
                    "{wanted:?} is not in the listing this console is holding for {}; the \
                     clone URL is resolved here and never taken from the browser",
                    kind.as_str()
                ),
                already_mirrored: false,
                job: None,
                room_href: None,
                refused: None,
            });
            continue;
        };
        if repo.archived && !body.include_archived {
            results.push(Outcome {
                full_name: wanted.clone(),
                path: String::new(),
                ok: true,
                step: "skipped",
                message: "archived upstream, and archived repositories are not imported \
                          unless asked for"
                    .to_owned(),
                already_mirrored: false,
                job: None,
                room_href: None,
                refused: None,
            });
            continue;
        }
        let name = repo.full_name.rsplit('/').next().unwrap_or(&repo.full_name);
        let path = format!("{namespace}/{name}");
        // **The forge's stated size, against the room left after the rows
        // already admitted by this import.** A size the forge does not state is
        // admitted while there is room at all; gunnar holds the fetch itself to
        // the cap as it streams.
        let estimate = repo.size_kib.map(|kib| kib.saturating_mul(1024));
        if let Some(room) = room {
            let fits = room.after(spent).admits(estimate);
            if fits != Fits::Yes {
                let words =
                    mirror_gate::room_words(&namespace, &room.after(spent), fits, wanted, estimate);
                results.push(refused_row(&who, &namespace, wanted, path, "room", words));
                continue;
            }
        }
        if let Err(words) = reserve(&state, &who, &path).await {
            results.push(refused_row(&who, &namespace, wanted, path, "busy", words));
            continue;
        }
        let remote = if over_https {
            repo.clone_url_https.clone()
        } else {
            repo.clone_url_ssh.clone()
        };
        let outcome = one(
            &who.gunnar,
            &repo_path(&namespace, name),
            &path,
            wanted,
            remote,
            body.prune,
            token.as_deref(),
            &username,
            None,
            existing.contains(&path),
        )
        .await;
        if outcome.job.is_none() {
            release(&state, &who, &path);
        } else {
            spent = spent.saturating_add(estimate.unwrap_or(0));
        }
        results.push(outcome);
    }

    let summary = Imported::of(
        namespace.clone(),
        if over_https { "https" } else { "ssh" },
        results,
        token.is_some(),
    );
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "mirror.import",
        user = %who.who,
        forge = kind.as_str(),
        namespace = %namespace,
        transport = %summary.transport,
        registered = summary.registered,
        queued = summary.queued,
        skipped = summary.skipped,
        failed = summary.failed,
        "repositories were imported as mirrors"
    );
    // The token goes out of scope here and nothing else holds it.
    drop(token);
    state.caches.invalidate_repos().await;
    Ok(Json(summary))
}

/// What the "Import from a URL" form sends.
///
/// # `Debug` prints no token, by hand
///
/// Derived would print one, and this is the one request body in the console
/// that carries a secret the person typed. A `tracing` span that captured it,
/// or a panic message, prints a kind and a length here and nothing else.
#[derive(Clone, Deserialize)]
pub struct ImportUrl {
    /// `https://host/owner/repo.git`, `ssh://git@host/owner/repo.git` or
    /// `git@host:owner/repo.git`.
    pub url: String,
    /// The namespace it lands in.
    pub namespace: String,
    /// The repository's name on this box. Empty takes the last segment of the
    /// URL, without `.git`.
    #[serde(default)]
    pub name: String,
    /// A personal access token for a private `https://` remote. Handed to
    /// `Mirror.SetCredential` once and dropped; never shown again.
    #[serde(default)]
    pub token: String,
    /// The user name the token is paired with. Empty picks the forge's
    /// documented one for github.com and gitlab.com, and otherwise sends the
    /// token as a bearer.
    #[serde(default)]
    pub username: String,
    /// Delete local refs the remote no longer has.
    #[serde(default)]
    pub prune: bool,
    /// **The private CA an `https://` remote is issued under** (GUN-MIRROR-CA), PEM.
    /// Handed to `Mirror.SetCa` as typed; gunnar judges it (a CA, or refused by name)
    /// and trusts it for this mirror alone. Empty: the public roots.
    #[serde(default)]
    pub ca_pem: String,
}

impl std::fmt::Debug for ImportUrl {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ImportUrl")
            .field("url", &scrub(&self.url))
            .field("namespace", &self.namespace)
            .field("name", &self.name)
            .field(
                "token",
                &if self.token.is_empty() {
                    "none"
                } else {
                    "<given>"
                },
            )
            .field("username", &self.username)
            .field("prune", &self.prune)
            .field("ca_pem", &if self.ca_pem.trim().is_empty() { "none" } else { "<given>" })
            .finish()
    }
}

/// The repository name a URL implies: its last path segment, without `.git`.
pub fn name_from_url(url: &str) -> String {
    let url = url.trim().trim_end_matches('/');
    // `git@host:owner/repo.git` and `https://host/owner/repo.git` both end in
    // the name after the last `/` (or `:` when there is no `/` after it).
    let tail = url.rsplit(['/', ':']).next().unwrap_or_default();
    tail.strip_suffix(".git").unwrap_or(tail).to_owned()
}

/// The documented Basic-auth user name for a token at a well-known host.
fn username_for(url: &str) -> &'static str {
    let host = url
        .split_once("://")
        .map(|(_, rest)| rest)
        .unwrap_or(url)
        .split('/')
        .next()
        .unwrap_or_default()
        .rsplit('@')
        .next()
        .unwrap_or_default()
        .to_ascii_lowercase();
    match host.as_str() {
        "github.com" => "x-access-token",
        "gitlab.com" => "oauth2",
        _ => "",
    }
}

fn invalid(message: impl Into<String>) -> ApiError {
    ApiError(Fail {
        code: "invalidargument".to_owned(),
        message: message.into(),
    })
}

/// `POST /api/mirrors/import-url` — **import one repository from any git
/// server**, by its address.
///
/// The picker's door needs a forge API to list an account; this one needs only
/// a URL, which is what a Gitea, a plain `git http-backend` or a friend's box
/// offers. It runs the same three calls the picker does, behind the same gate:
///
/// * the namespace must not be full and the caller must be under their limits
///   — before anything is registered; whether they may import into it at all
///   is gunnar's answer to their own `Mirror.Add`;
/// * the URL is refused here if it carries a credential (`user:token@host`):
///   that would store the secret in the registration where every reader of
///   `refs/gunnar/mirrors` finds it. The token field is the door for one;
/// * a token is accepted for an `https://` remote only — gunnar refuses one
///   against `ssh://` by name, and this says so before anything exists;
/// * gunnar itself refuses a destination on a private network
///   (`mirror_fetch::check_destination`), so this console is not a way to make
///   the appliance dial its own neighbours.
///
/// # The token goes one way
///
/// It arrives in the body, is handed to `Mirror.SetCredential` once, and is
/// dropped when this returns. No answer carries it, no log line names it
/// ([`ImportUrl`]'s `Debug` is by hand), and `MirrorConfig` reports a kind.
pub async fn import_url(
    who: Actor,
    State(state): State<AppState>,
    Json(body): Json<ImportUrl>,
) -> ApiResult<Imported> {
    let url = body.url.trim().to_owned();
    let namespace = body.namespace.trim().to_owned();
    if namespace.is_empty() {
        return Err(invalid(
            "an import needs a namespace to put the repository in",
        ));
    }
    let transport = transport_of(&url);
    if transport == "other" {
        return Err(invalid(format!(
            "{:?} is not an address this box can mirror from. Write it as \
             https://host/owner/repo.git or ssh://git@host/owner/repo.git",
            scrub(&url)
        )));
    }
    if scrub(&url) != url {
        return Err(invalid(
            "the address carries a user name or a token. Put the token in the token field \
             instead: an address is stored and shown, a token is sealed and never shown again",
        ));
    }
    let token = body.token.trim().to_owned();
    if !token.is_empty() && transport != "https" {
        return Err(invalid(
            "a token can only be offered to an https:// address. For an ssh address the box \
             needs a deploy key, which is set at the control plane, not in a web form",
        ));
    }
    let name = match body.name.trim() {
        "" => name_from_url(&url),
        given => given.to_owned(),
    };
    if name.is_empty() || name.contains('/') {
        return Err(invalid(format!(
            "{name:?} is not a repository name; give the name it should have on this box"
        )));
    }
    let path = format!("{namespace}/{name}");

    room_for(&who, &namespace, &path).await?;
    // Whether it is already a mirror, for the row's "already mirrored": the
    // namespace's list, asked as the person.
    let already = status_in(&who.gunnar, &namespace)
        .await
        .map(|found| found.iter().any(|m| m.path == path))
        .unwrap_or(false);
    if let Err(words) = reserve(&state, &who, &path).await {
        return Err(mirror_gate::refused(
            &who.who,
            &namespace,
            code::BUSY,
            "busy",
            words,
        ));
    }
    let username = match body.username.trim() {
        "" => username_for(&url).to_owned(),
        given => given.to_owned(),
    };
    let outcome = one(
        &who.gunnar,
        &repo_path(&namespace, &name),
        &path,
        &scrub(&url),
        url.clone(),
        body.prune,
        (!token.is_empty()).then_some(token.as_str()),
        &username,
        (!body.ca_pem.trim().is_empty()).then_some(body.ca_pem.as_str()),
        already,
    )
    .await;
    if outcome.job.is_none() {
        release(&state, &who, &path);
    }
    // **gunnar said no to the person's `Mirror.Add`** — they may not import
    // into this namespace. One repository, so the answer IS the refusal, in
    // gunnar's words and with gunnar's status; nothing was registered.
    if let (false, "add", Some(fail)) = (outcome.ok, outcome.step, outcome.refused.as_ref()) {
        if matches!(fail.code.as_str(), "permissiondenied" | "notfound" | "failedprecondition") {
            tracing::info!(
                target: crate::auth::AUDIT_TARGET,
                event = "mirror.refused",
                user = %who.who,
                namespace = %namespace,
                reason = %fail.message,
                "gunnar refused an import asked as the person"
            );
            return Err(ApiError(fail.clone()));
        }
    }
    let handed = !token.is_empty();
    // The person's token is not kept: both copies go here.
    drop(token);
    drop(body);
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "mirror.import_url",
        user = %who.who,
        namespace = %namespace,
        repo = %path,
        remote = %scrub(&url),
        transport,
        token_handed_over = handed,
        ok = outcome.ok,
        step = outcome.step,
        "a repository was imported from an address"
    );
    state.caches.invalidate_repos().await;
    Ok(Json(Imported::of(
        namespace,
        transport,
        vec![outcome],
        handed,
    )))
}

/// One repository's three calls, and the first refusal that stops them.
///
/// Broken out so the loop above reads as the loop it is, and so the ORDER —
/// `Add`, then `SetCredential`, then `Sync` — is stated in one place. It is not
/// arbitrary: `SetCredential` needs a registration to attach to, and `Sync`
/// would fail on authentication without the credential.
#[allow(clippy::too_many_arguments)]
async fn one(
    gunnar: &crate::grpc::Gunnar,
    repo: &pb::RepoPath,
    path: &str,
    full_name: &str,
    remote: String,
    prune: bool,
    token: Option<&str>,
    username: &str,
    ca_pem: Option<&str>,
    already_mirrored: bool,
) -> Outcome {
    let failure = |step: &'static str, fail: Fail| Outcome {
        full_name: full_name.to_owned(),
        path: path.to_owned(),
        ok: false,
        step,
        message: fail.message.clone(),
        already_mirrored,
        job: None,
        room_href: None,
        refused: Some(fail),
    };

    if let Err(e) = gunnar
        .mirror()
        .add(pb::AddMirrorRequest {
            repo: Some(repo.clone()),
            remote_url: remote,
            prune,
            // The console never creates the repository itself first, and never
            // passes a hash kind: gunnar takes it from the remote's advertised
            // object-format. A console that guessed sha1 would make a
            // sha256 upstream unmirrorable with an error about objects rather
            // than about the guess.
            create_repo: true,
            // **Never a silent take-over** (gunnar 739b5f1): over an existing
            // repository that is not already a mirror gunnar refuses, by name,
            // and the person's import says so. Until then this console held
            // its own "not over a repository" guard, which failed open.
            adopt: false,
        })
        .await
    {
        return failure("add", Fail::from(e));
    }

    if let Some(token) = token {
        if let Err(e) = gunnar
            .mirror()
            .set_credential(pb::SetMirrorCredentialRequest {
                repo: Some(repo.clone()),
                kind: pb::MirrorCredentialKind::Token as i32,
                secret: token.to_owned(),
                username: username.to_owned(),
            })
            .await
        {
            // gunnar's refusal, verbatim — including the one that matters here,
            // "a token cannot be offered to an ssh:// remote", which names the
            // mismatch instead of leaving a mirror to fail on authentication
            // for ever.
            return failure("credential", Fail::from(e));
        }
    }

    // GUN-MIRROR-CA: the private CA, before the first sync dials. gunnar's refusal
    // (not a CA, a key pasted, no certificate) is the person's answer, verbatim.
    if let Some(ca_pem) = ca_pem {
        if let Err(e) = gunnar
            .mirror()
            .set_ca(pb::SetMirrorCaRequest {
                repo: Some(repo.clone()),
                ca_pem: ca_pem.to_owned(),
            })
            .await
        {
            return failure("ca", Fail::from(e));
        }
    }

    match gunnar
        .mirror()
        .sync(pb::SyncMirrorRequest {
            repo: Some(repo.clone()),
        })
        .await
    {
        Ok(answer) => {
            let answer = answer.into_inner();
            Outcome {
                full_name: full_name.to_owned(),
                path: path.to_owned(),
                ok: true,
                step: "sync",
                message: String::new(),
                already_mirrored,
                job: answer.job.map(render_job),
                room_href: None,
                refused: None,
            }
        }
        Err(e) => failure("sync", Fail::from(e)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_credential_in_a_remote_url_is_never_rendered() {
        // `gunnar mirror add` accepts this from a shell and stores it verbatim,
        // and a person doing it from a terminal has every reason to think it
        // stays there. Printing it in a browser puts a live credential on a
        // screen and into every screenshot of it.
        assert_eq!(
            scrub("https://x-access-token:ghp_livetoken@github.com/nordisk/gunnar.git"),
            "https://github.com/nordisk/gunnar.git"
        );
        assert_eq!(
            scrub("https://user:pw@git.example.com:8443/u/r.git"),
            "https://git.example.com:8443/u/r.git"
        );
        // A password containing an `@` — legal, and why the split is from the
        // RIGHT.
        assert_eq!(scrub("https://u:p@ss@host/r.git"), "https://host/r.git");
    }

    /// ★ **A bare `token@host` over https is a credential, and this test used
    /// to say the opposite.**
    ///
    /// **Seen red.** The previous rule was "a colon is the whole test", and the
    /// list below sat in `an_ordinary_url_survives_scrubbing_unchanged` as a
    /// URL that must NOT be touched. But `https://<token>@github.com/u/r.git`
    /// is the form GitHub documents for a personal access token, and GitLab and
    /// Forgejo accept the same — so the one shape the documentation tells
    /// people to use was the one shape that went out to the browser whole.
    #[test]
    fn a_bare_userinfo_over_https_is_taken_to_be_a_token() {
        for (url, scrubbed) in [
            (
                "https://ghp_livetoken@github.com/nordisk/gunnar.git",
                "https://github.com/nordisk/gunnar.git",
            ),
            (
                "https://glpat-livetoken@gitlab.com/u/r.git",
                "https://gitlab.com/u/r.git",
            ),
            // A plain user name over https goes too: it is not needed to clone,
            // and telling it from a token is a guess made with a live
            // credential.
            (
                "https://alice@git.example.com/u/r.git",
                "https://git.example.com/u/r.git",
            ),
            (
                "http://token@git.example.com:3000/u/r.git",
                "http://git.example.com:3000/u/r.git",
            ),
        ] {
            assert_eq!(scrub(url), scrubbed, "{url} kept its userinfo");
        }
    }

    #[test]
    fn an_ordinary_url_survives_scrubbing_unchanged() {
        // **Seen red.** The first draft split on the last `@` unconditionally
        // and rendered `ssh://git@git.example.com:2222/u/r.git` as
        // `ssh://git.example.com:2222/u/r.git` — a remote an operator cannot
        // paste back, in the name of hiding a credential that was not there.
        // A bare `user@` is a user name; only `user:password@` is a secret.
        for url in [
            "https://github.com/nordisk/gunnar.git",
            "git@github.com:vetra-automation/holger-ui.git",
            "ssh://git@git.example.com:2222/u/r.git",
            "https://github.com",
        ] {
            assert_eq!(
                scrub(url),
                url,
                "{url} was changed and should not have been"
            );
        }
    }

    #[test]
    fn the_transport_decides_which_credential_is_admissible() {
        assert_eq!(transport_of("https://github.com/u/r.git"), "https");
        assert_eq!(transport_of("ssh://git@host/u/r.git"), "ssh");
        assert_eq!(transport_of("git@codeberg.org:u/r.git"), "ssh");
        assert_eq!(transport_of("file:///srv/r.git"), "other");
    }

    #[test]
    fn a_running_job_reports_bytes_and_withholds_the_object_count() {
        // `bytes` is the figure that moves while a fetch runs, so it is the
        // live signal. `objects` is what the fetch BROUGHT IN, and a zero on a
        // running job would read as "it fetched nothing" — which is a finished
        // job's answer, not a running one's.
        let running = render_job(pb::MirrorJob {
            job_id: "mirror-1".into(),
            path: Some(repo_path("nordisk", "gunnar")),
            state: pb::MirrorJobState::Running as i32,
            queue_position: 0,
            has_progress: true,
            progress_done: 40,
            progress_total: 100,
            objects: 0,
            bytes: 4_096,
            updates: vec![pb::RefUpdate {
                ref_name: "refs/heads/main".into(),
                old_oid: "a".into(),
                new_oid: "b".into(),
            }],
            error: String::new(),
            enqueued_at_unix_ms: 1_700_000_000_000,
            started_at_unix_ms: 1_700_000_001_000,
            finished_at_unix_ms: 0,
            refused_reason: 0,
        });

        assert_eq!(running.state, "running");
        assert_eq!(running.bytes, 4_096);
        assert_eq!(running.objects, None);
        assert!(
            running.updates.is_empty(),
            "gunnar fills `updates` in only when the job is DONE; anything here before that \
             is a claim about references that have not moved"
        );
        assert_eq!(running.finished_at_unix_ms, None, "0 is not 1970");
    }

    #[test]
    fn a_job_with_no_denominator_says_so_rather_than_reporting_nought_per_cent() {
        let early = render_job(pb::MirrorJob {
            job_id: "mirror-2".into(),
            path: Some(repo_path("nordisk", "gunnar")),
            state: pb::MirrorJobState::Running as i32,
            has_progress: false,
            progress_done: 0,
            progress_total: 0,
            bytes: 512,
            ..Default::default()
        });

        assert!(!early.has_progress);
        assert_eq!((early.progress_done, early.progress_total), (0, 0));
        assert_eq!(
            early.bytes, 512,
            "the byte counter moves before the pack header arrives, which is exactly why it \
             is the live signal and the pair is not"
        );
    }

    #[test]
    fn a_finished_job_carries_what_moved() {
        let done = render_job(pb::MirrorJob {
            job_id: "mirror-3".into(),
            path: Some(repo_path("nordisk", "gunnar")),
            state: pb::MirrorJobState::Done as i32,
            has_progress: true,
            progress_done: 100,
            progress_total: 100,
            objects: 100,
            bytes: 1_048_576,
            updates: vec![pb::RefUpdate {
                ref_name: "refs/heads/main".into(),
                old_oid: String::new(),
                new_oid: "cafe".into(),
            }],
            finished_at_unix_ms: 1_700_000_060_000,
            ..Default::default()
        });

        assert_eq!(done.objects, Some(100));
        assert_eq!(done.updates.len(), 1);
        assert_eq!(
            done.updates[0].old_oid, "",
            "a created reference has no old oid"
        );
        assert_eq!(done.error, None);
    }

    #[test]
    fn a_failed_job_keeps_gunnars_words() {
        let failed = render_job(pb::MirrorJob {
            job_id: "mirror-4".into(),
            path: Some(repo_path("nordisk", "gunnar")),
            state: pb::MirrorJobState::Failed as i32,
            error: "the remote refused the credential".into(),
            ..Default::default()
        });

        assert_eq!(failed.state, "failed");
        assert_eq!(
            failed.error.as_deref(),
            Some("the remote refused the credential")
        );
        assert_eq!(failed.objects, None, "a failed job moved nothing");
    }

    #[test]
    fn an_enum_value_this_build_does_not_know_settles_rather_than_polls_for_ever() {
        let unknown = render_job(pb::MirrorJob {
            state: 99,
            ..Default::default()
        });

        assert_eq!(unknown.state, "unknown");
        assert_eq!(mirror_state_name(99), "unknown");
        assert_eq!(credential_name(99), "none");
    }

    #[test]
    fn a_registration_renders_a_credential_kind_and_never_a_value() {
        let mirror = render(pb::MirrorConfig {
            repo: Some(repo_path("nordisk", "gunnar")),
            remote_url: "https://x-access-token:ghp_live@github.com/nordisk/gunnar.git".into(),
            prune: true,
            state: pb::MirrorState::Idle as i32,
            added_at_unix_ms: 1_700_000_000_000,
            last_sync_at_unix_ms: None,
            last_error: String::new(),
            sync_count: 3,
            credential: pb::MirrorCredentialKind::Token as i32,
            credential_username: "x-access-token".into(),
            job: None,
            ..Default::default()
        });

        assert_eq!(mirror.credential, "token");
        assert_eq!(mirror.credential_username, "x-access-token");
        assert_eq!(mirror.transport, "https");
        assert!(
            !serde_json::to_string(&mirror)
                .expect("serialises")
                .contains("ghp_live"),
            "the whole rendered row went out with a live credential in it"
        );
        assert_eq!(mirror.last_error, None, "empty is not a failure");
        assert_eq!(mirror.last_sync_at_unix_ms, None);
    }

    /// ★ gunnar's two facts about an import (2026-10-18) reach the page: its
    /// repository is still to come, and its upstream's LFS files were not
    /// copied.
    #[test]
    fn a_pending_import_and_an_lfs_upstream_are_rendered() {
        let mirror = render(pb::MirrorConfig {
            repo: Some(repo_path("kim", "tool")),
            remote_url: "https://codeberg.org/kim/tool.git".into(),
            repo_pending: true,
            lfs_not_copied: true,
            ..Default::default()
        });
        assert!(mirror.repo_pending);
        assert!(mirror.lfs_not_copied);
        let plain = render(pb::MirrorConfig {
            repo: Some(repo_path("kim", "tool")),
            ..Default::default()
        });
        assert!(!plain.repo_pending && !plain.lfs_not_copied);
    }
}