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