src/util.rs
//! Pure helpers — no web APIs, so they compile and unit-test on the native
//! target too (the rest of the crate is wasm-only; hence the allow — native
//! builds see no callers).
#![allow(dead_code)]
/// `namespace/name` when a namespace exists, else just the name.
pub fn display_name(namespace: &str, name: &str) -> String {
if namespace.is_empty() {
name.to_string()
} else {
format!("{namespace}/{name}")
}
}
/// Percent-encode one URL query component (RFC 3986 unreserved kept) — raw
/// artifact names carry `/`, so every coordinate that rides a query string
/// goes through here. Pure Rust: testable natively, no js interop.
pub fn enc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// The shareable address of an artifact's detail view (query-addressed —
/// names contain slashes).
pub fn artifact_href(repo: &str, namespace: &str, name: &str, version: &str) -> String {
format!(
"/artifact?repo={}&ns={}&name={}&version={}",
enc(repo),
enc(namespace),
enc(name),
enc(version)
)
}
/// `1234567` → `1.2 MB` — sizes are for scanning, not accounting.
pub fn fmt_bytes(n: i64) -> String {
let n = n.max(0) as f64;
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
let mut v = n;
let mut u = 0;
while v >= 1000.0 && u < UNITS.len() - 1 {
v /= 1000.0;
u += 1;
}
if u == 0 {
format!("{} {}", v as i64, UNITS[u])
} else {
format!("{v:.1} {}", UNITS[u])
}
}
/// One property filter as it rides the search URL: `key` alone (presence) or
/// `key=value` (exact value among the key's values) — the FIRST `=` splits,
/// values may contain more.
pub fn parse_prop_param(raw: &str) -> (String, String) {
match raw.split_once('=') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (raw.to_string(), String::new()),
}
}
pub fn prop_param(key: &str, value: &str) -> String {
if value.is_empty() {
key.to_string()
} else {
format!("{key}={value}")
}
}
/// Does the typed confirmation match the phrase the dialog demands?
///
/// Exact match after trimming the ends — a paste that picked up a trailing
/// newline should not block a legitimate confirmation, but a DIFFERENT name
/// must never pass, and an empty requirement can never be satisfied (a
/// type-to-confirm gate with nothing to type would be no gate at all).
pub fn confirm_phrase_matches(typed: &str, required: &str) -> bool {
let required = required.trim();
!required.is_empty() && typed.trim() == required
}
// ─── Shared vocabulary ───────────────────────────────────────────────────────
/// Semantic tone — never color alone: every badge carries its text. Lives here
/// rather than in `components::feedback` (which re-exports it) so the pages'
/// state→tone decisions are pure functions a native test can pin.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tone {
Good,
Warn,
Bad,
Neutral,
}
impl Tone {
/// The CSS classes a toned badge carries: the shared `badge` base plus the
/// tone's own modifier. The base is what gives the badge its box; a tone
/// that forgot it would render as bare text.
pub fn class(self) -> &'static str {
match self {
Tone::Good => "badge badge-good",
Tone::Warn => "badge badge-warn",
Tone::Bad => "badge badge-bad",
Tone::Neutral => "badge badge-neutral",
}
}
}
// ─── Search addresses ────────────────────────────────────────────────────────
//
// The URL *is* the search state: running a search is navigating, a shared
// address auto-runs, and removing a chip is re-navigating without it. So every
// address the search view builds goes through these two functions — an axis
// value that escapes wrong would silently search for something else.
/// The named axes of the search URL, in the order the console writes them.
/// Both the address builder and the chip list walk this, so a new axis lands
/// in the URL, the chips and the removal path at once.
pub const SEARCH_AXES: [&str; 6] = ["name", "namespace", "version", "path", "checksum", "repo"];
/// `/search?…` from the axis values as typed and the property-filter rows.
/// Blank axes and blank property keys are left out entirely — an axis present
/// but empty would be a filter on the empty string, not "no filter". Values
/// are trimmed (paste noise) and percent-encoded (a name may carry `/`, `&`,
/// `=` or non-ASCII, and must not be able to invent a second parameter).
pub fn search_href(axes: &[(&str, String)], props: &[(String, String)]) -> String {
let mut parts: Vec<String> = Vec::new();
for (key, value) in axes {
let value = value.trim();
if !value.is_empty() {
parts.push(format!("{key}={}", enc(value)));
}
}
for (key, value) in props {
let key = key.trim();
if !key.is_empty() {
parts.push(format!("prop={}", enc(&prop_param(key, value.trim()))));
}
}
search_query_href(parts)
}
/// `/search?…` rebuilt from the active filter chips — the address behind each
/// chip's ✕ is this list minus that one. Each chip is (kind, raw value) where
/// kind is an axis name or `prop`, and the value is re-encoded on the way out,
/// so a value that arrived decoded (`a/b`, `k=v`) survives the round trip.
pub fn search_href_from_chips(chips: &[(String, String)]) -> String {
search_query_href(
chips
.iter()
.map(|(kind, value)| format!("{kind}={}", enc(value)))
.collect(),
)
}
fn search_query_href(parts: Vec<String>) -> String {
if parts.is_empty() {
// No filters left is the bare route: `/search?` would be the same
// empty query with a stray `?` in the address bar.
"/search".to_string()
} else {
format!("/search?{}", parts.join("&"))
}
}
// ─── Repository list ─────────────────────────────────────────────────────────
/// `/repositories?…` — the filter and sort that make a listing a shareable
/// address. Both values are percent-encoded: `sort` comes back off the URL and
/// is re-emitted on the next click, so an injected `sort=x&q=y` must ride as
/// ONE parameter, not become two.
pub fn repositories_href(filter: &str, sort: &str) -> String {
let mut parts: Vec<String> = Vec::new();
if !filter.is_empty() {
parts.push(format!("q={}", enc(filter)));
}
if !sort.is_empty() {
parts.push(format!("sort={}", enc(sort)));
}
if parts.is_empty() {
"/repositories".to_string()
} else {
format!("/repositories?{}", parts.join("&"))
}
}
/// Clicking a column sorts by it; clicking the column already sorted by clears
/// the sort (back to the server's configuration order). Returns the `sort=`
/// value the next address should carry — empty means "no sort parameter".
pub fn toggled_sort(current: &str, clicked: &'static str) -> &'static str {
if current == clicked {
""
} else {
clicked
}
}
/// The repository filter: case-insensitive substring. An EMPTY needle matches
/// everything — no filter is not a filter that excludes everything.
pub fn matches_name_filter(name: &str, needle: &str) -> bool {
needle.is_empty() || name.to_lowercase().contains(&needle.to_lowercase())
}
/// Row order under the URL's `sort=` key, over `(name, repo_type)`. An unknown
/// (or absent) key compares every row equal, which a STABLE sort leaves in the
/// server's configuration order — the console never invents an order the
/// operator did not ask for. Type-sorting breaks ties by name so a repeated
/// sort is deterministic.
pub fn repo_order(a: (&str, &str), b: (&str, &str), sort: &str) -> std::cmp::Ordering {
match sort {
"name" => a.0.cmp(b.0),
"type" => a.1.cmp(b.1).then(a.0.cmp(b.0)),
_ => std::cmp::Ordering::Equal,
}
}
// ─── Repository facts off the wire ───────────────────────────────────────────
/// The group text beside a repository type: `group · N members`, or nothing
/// for a repository that owns its own storage. Both facts are holger's
/// (`RepositoryInfo.is_group` / `members`), never inferred from the type.
pub fn group_label(is_group: bool, members: usize) -> Option<String> {
is_group.then(|| {
let noun = if members == 1 { "member" } else { "members" };
format!("group · {members} {noun}")
})
}
/// What an EMPTY browse listing means, decided by
/// `RepositoryInfo.supports_listing` and not by a guess over the repo type.
/// `None` is "the facts have not arrived (or failed)", which is neither claim.
pub fn empty_listing_note(supports_listing: Option<bool>) -> &'static str {
match supports_listing {
Some(true) => "nothing listed — the repository is empty (this type enumerates for real)",
Some(false) => {
"nothing listed — this repository does not support generic listing, so an empty
list is NOT proof of an empty repository; search and the native package
protocol can still reach it"
}
None => "nothing listed — whether this repository supports listing is unknown until its facts load",
}
}
// ─── Overview counts ─────────────────────────────────────────────────────────
/// The repositories card's second line. Read-only is derived, not counted:
/// `total - writable` (saturating — a count that outran its total is a bug, not
/// a panic on the operator's screen).
pub fn repo_totals_line(total: usize, writable: usize, archived: usize) -> String {
format!(
"{writable} writable · {} read-only · {archived} archive-backed",
total.saturating_sub(writable)
)
}
/// Pure: **an uptime in words** (HCON-4), the ONE formatter every page uses: seconds under a
/// minute, minutes under an hour, hours and minutes under a day, then days and hours.
/// Takes either proto's integer (Health's `i64`, the admin snapshot's); a negative is 0.
pub fn uptime_words(secs: impl Into<i128>) -> String {
let secs = secs.into().max(0) as u128;
let (d, h, m) = (secs / 86_400, secs % 86_400 / 3600, secs % 3600 / 60);
match secs {
0..=59 => format!("{secs} s"),
60..=3599 => format!("{m} min"),
3600..=86_399 => format!("{h} h {m} min"),
_ => format!("{d} d {h} h"),
}
}
/// Pure: **the session in plain words** (HCON-2): how this person signed in and their role
/// here, as holger answered WhoAmI. The raw subject stays out of it.
pub fn session_words(method: &str, role: &str) -> String {
let how = match method {
"passkey" => "a passkey",
"oidc" | "oauth2" => "your organisation's sign-in",
"mtls" => "a client certificate",
_ => "a token",
};
format!("You are signed in with {how}. Your role here: {role}.")
}
/// Pure: **the read gate in one sentence** (HCON-5): what it does for this Holger's packages.
pub fn read_gate_words(on: bool) -> &'static str {
if on {
"Reading packages needs a sign-in: package clients and visitors need a token."
} else {
"Anyone who can reach this Holger can read its packages without signing in. That is meant for an airgapped downstream only."
}
}
/// Pure: **what is open on this Holger** (HCON-5): the server's own words for its RON's
/// `access` block and public repositories; a Holger too old to say them is drawn from its read
/// gate alone.
pub fn access_words(server_words: &str, read_gate: bool) -> String {
match server_words.trim() {
"" => read_gate_words(read_gate).to_string(),
words => words.to_string(),
}
}
// ─── Artifact detail ─────────────────────────────────────────────────────────
/// The file name a downloaded artifact is saved under: the last path segment
/// of its name (raw coordinates carry `/`). Never empty — an empty `download`
/// attribute silently hands the browser its own guess, so a name that ends in
/// a separator, or is empty, falls back to `artifact`.
pub fn download_file_name(artifact_name: &str) -> String {
match artifact_name.rsplit('/').next() {
Some(last) if !last.is_empty() => last.to_string(),
_ => "artifact".to_string(),
}
}
/// The property editor's comma-separated values. Whitespace around each value
/// is paste noise; an empty value is not a value. An all-empty input yields no
/// values at all, which is what makes the editor demand at least one — empty
/// `values` is the proto's REMOVE idiom, and a stray comma must not delete a
/// property the operator meant to edit.
pub fn parse_property_values(raw: &str) -> Vec<String> {
raw.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect()
}
// ─── Replication ─────────────────────────────────────────────────────────────
/// The two refusals that never need the server: nothing chosen, and a pair
/// that would copy a repository onto itself. `None` = the pair is worth sending.
pub fn replication_pair_error(from: &str, to: &str) -> Option<&'static str> {
if from.is_empty() || to.is_empty() {
return Some("choose both repositories");
}
if from == to {
return Some("source and destination must differ");
}
None
}
/// May the Execute button be armed? Only while the CURRENT pair is the pair a
/// preview was actually seen for. No preview (including a run that consumed
/// its own) never arms; changing either end disarms; and an empty pair — which
/// the preview path refuses anyway — can never arm, so the gate cannot be
/// satisfied by two blanks.
pub fn execute_armed(previewed: Option<(&str, &str)>, from: &str, to: &str) -> bool {
match previewed {
Some((f, t)) => !from.is_empty() && !to.is_empty() && f == from && t == to,
None => false,
}
}
// ─── Transport ───────────────────────────────────────────────────────────────
/// The gRPC-web door's base URL: the build-time `HOLGER_GRPC_WEB_URL` when one
/// was compiled in, else the page's own origin under `/grpc` (same-origin, so
/// no CORS ceremony). Trailing slashes are trimmed off both — `origin//grpc`
/// and `base//` are 404s that look like configuration.
pub fn grpc_base_url(override_url: Option<&str>, origin: Option<&str>) -> String {
if let Some(url) = override_url {
return url.trim_end_matches('/').to_string();
}
let origin = origin.unwrap_or("http://127.0.0.1:8090");
format!("{}/grpc", origin.trim_end_matches('/'))
}
/// The topbar's environment chip: which server this console talks to, with the
/// scheme and the door's `/grpc` suffix stripped — the operator recognizes the
/// HOST, and a chip that reads the same for dev and prod is the whole risk.
/// Pure: **the topbar's domain chip** (lane HOL-CDS): holger's `X-Holger-Domain`, trimmed,
/// at most 64 characters (holger's own limit); nothing when holger names none.
pub fn domain_label(header: Option<&str>) -> Option<String> {
let d = header?.trim();
(!d.is_empty()).then(|| d.chars().take(64).collect())
}
pub fn environment_label(base_url: &str) -> String {
base_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches("/grpc")
.to_string()
}
/// What a failed call MEANS, by status code — the sentence a FailBox leads
/// with, before the server's verbatim message. These are different situations
/// demanding different next steps (log in · ask for a role · this build cannot
/// · the backend is down), so they must not collapse into one "error". Any
/// other code gets the honest, non-committal "request failed".
/// What every pane says when holger answers UNIMPLEMENTED: the service is
/// not compiled into this holger (the lean default build leaves supply-chain,
/// mirror, migration and admission out). Said, never hidden, never a crash.
pub const NOT_IN_BUILD: &str = "not in this build";
/// Pure: the sentence for a gated service holger does not serve.
pub fn not_in_build(service: &str) -> String {
format!("{service}: {NOT_IN_BUILD} — this holger was built without it")
}
pub fn fail_summary(code: tonic::Code) -> &'static str {
match code {
tonic::Code::Unauthenticated => "authentication required",
tonic::Code::PermissionDenied => {
"permission denied — the server's role mapping does not allow this"
}
tonic::Code::Unimplemented => NOT_IN_BUILD,
tonic::Code::Unavailable => "backend unavailable",
tonic::Code::NotFound => "not found",
_ => "request failed",
}
}
/// Does this failure have a login link next to its summary? Exactly the code
/// that says authentication is required — offering "log in" for a permission
/// denial would send an already-authenticated operator round a loop that
/// cannot help them.
pub fn fail_suggests_login(code: tonic::Code) -> bool {
code == tonic::Code::Unauthenticated
}
// ─── Migration (Operations → Migrate) ────────────────────────────────────────
//
// Everything the Migrate page DECIDES lives here, as pure functions a native
// test can pin — and, above all, as the only place a source credential is ever
// turned into words. The page renders these strings and no others about the
// source, so `a_source_password_never_reaches_anything_the_page_renders` covers
// the real renderers and not a copy of them.
/// The source form as the page holds it: what an operator typed into Operations
/// → Migrate before pressing Discover.
///
/// # ⚠ The password
///
/// It is a PARAMETER of one call. It is never written to storage, never put in
/// a URL, never logged, and never left in this struct after the call it was
/// typed for — [`forgotten`] is what the page applies the moment a request has
/// been built. `Debug` is implemented by hand for the same reason: the derived
/// one printed the secret, and that exact class of leak is live elsewhere in
/// this codebase (`ConnectorAuth`), so it is not a hypothetical.
#[derive(Clone, PartialEq, Eq)]
pub struct SourceForm {
/// `MigrationSource.Kind`: 1 = Nexus, 2 = Artifactory, 0 = nothing chosen.
pub kind: i32,
pub base_url: String,
pub username: String,
pub password: String,
/// Empty = the whole registry. Non-empty = that one source repository.
pub repository: String,
}
impl Default for SourceForm {
fn default() -> Self {
Self {
kind: MIGRATION_KIND_NEXUS,
base_url: String::new(),
username: String::new(),
password: String::new(),
repository: String::new(),
}
}
}
impl std::fmt::Debug for SourceForm {
/// Redacted BY CONSTRUCTION. A derived `Debug` would print the password in
/// full the first time anything formatted this struct — a panic message, a
/// future log line, an `assert_eq!` failure. There is nothing to remember
/// not to do, because the secret is not reachable through this impl.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SourceForm")
.field("kind", &self.kind)
.field("host", &source_host(&self.base_url))
.field("username", &self.username)
.field("password", &"<redacted>")
.field("repository", &self.repository)
.finish()
}
}
pub const MIGRATION_KIND_NEXUS: i32 = 1;
pub const MIGRATION_KIND_ARTIFACTORY: i32 = 2;
/// The operator's word for a `MigrationSource.Kind`.
pub fn source_kind_label(kind: i32) -> &'static str {
match kind {
MIGRATION_KIND_NEXUS => "Nexus",
MIGRATION_KIND_ARTIFACTORY => "Artifactory",
_ => "unchosen",
}
}
/// The host a source URL names, with **any userinfo stripped**.
///
/// `https://alice:hunter2@nexus.example/` is a URL a person will paste, and
/// echoing it back into the page would put the password on the screen, into the
/// DOM, and into anything that later copies the DOM. Everything before the last
/// `@` of the authority is a credential and is dropped here.
pub fn source_host(base_url: &str) -> String {
let rest = base_url
.split_once("://")
.map(|(_, r)| r)
.unwrap_or(base_url);
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
match authority.rsplit_once('@') {
Some((_, host)) => host.to_string(),
None => authority.to_string(),
}
}
/// True when the URL's authority carries `user[:password]@`.
pub fn source_url_carries_a_credential(base_url: &str) -> bool {
let rest = base_url
.split_once("://")
.map(|(_, r)| r)
.unwrap_or(base_url);
rest.split(['/', '?', '#'])
.next()
.is_some_and(|a| a.contains('@'))
}
/// What the page says it is about to talk to: the kind, the host, and which
/// identity — never the URL verbatim, and never the password.
pub fn source_summary(form: &SourceForm) -> String {
let host = source_host(&form.base_url);
let host = if host.is_empty() { "—" } else { host.as_str() };
let who = if form.username.trim().is_empty() {
"anonymous".to_string()
} else {
format!("as {}", form.username.trim())
};
let scope = if form.repository.trim().is_empty() {
"every repository it can find".to_string()
} else {
format!("only '{}'", form.repository.trim())
};
format!(
"{} at {host} · {who} · {scope}",
source_kind_label(form.kind)
)
}
/// The refusals that never need the server. `None` = worth sending.
///
/// None of these sentences echoes the URL or the password: an error message is
/// the most-copied text on any page, and a credential that reaches one has
/// escaped.
pub fn source_form_error(form: &SourceForm) -> Option<String> {
if form.kind != MIGRATION_KIND_NEXUS && form.kind != MIGRATION_KIND_ARTIFACTORY {
return Some("choose the source registry: Nexus or Artifactory".into());
}
let url = form.base_url.trim();
if url.is_empty() {
return Some("the source URL is required".into());
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Some("the source URL must start with http:// or https://".into());
}
if source_url_carries_a_credential(url) {
return Some(
"that URL carries a credential inside it — put the user and password in the \
fields instead. A URL is echoed, copied and logged; these fields are not."
.into(),
);
}
None
}
/// The form as it must be left the instant a request has been built: same
/// everything, no password. The page applies this BEFORE the call is awaited,
/// so the secret is alive only inside the request that needs it.
pub fn forgotten(form: &SourceForm) -> SourceForm {
SourceForm {
password: String::new(),
..form.clone()
}
}
/// May this planned repository be filled by pressing Start *now*?
///
/// **Only `existing`.** A `create` row is the one holger cannot do anything
/// about while it runs: the route table is built from the config at boot and is
/// immutable for the process lifetime, so a Start that included it would refuse
/// that repository at run time, asset by asset. The page therefore does not
/// offer it — it shows the declaration to paste instead. `collision` and
/// `unmapped_format` refuse in the plan itself.
pub fn migration_row_selectable(disposition: &str) -> bool {
disposition == "existing"
}
/// The badge tone for a planned repository's disposition.
pub fn disposition_tone(disposition: &str) -> Tone {
match disposition {
"existing" => Tone::Good,
"create" => Tone::Warn,
"collision" | "unmapped_format" => Tone::Bad,
_ => Tone::Neutral,
}
}
/// What a disposition MEANS, in the operator's terms and in the imperative
/// where there is something to do.
pub fn disposition_note(disposition: &str) -> &'static str {
match disposition {
"existing" => "declared here already — Start fills it",
"create" => "NOT declared here. holger cannot create it while it runs: \
paste the declaration below into the config, restart, then discover again",
"collision" => "the same name is declared here in a different format — nothing will run \
until that is resolved",
"unmapped_format" => "holger has no repository format for what the source calls this",
_ => "unknown disposition",
}
}
/// The one-line verdict above the repository table.
pub fn migration_headline(repos: usize, fillable: usize, to_declare: usize, refused: usize) -> String {
let mut s = format!("{repos} repositories found · {fillable} fillable now");
if to_declare > 0 {
s.push_str(&format!(" · {to_declare} must be declared first"));
}
if refused > 0 {
s.push_str(&format!(" · {refused} refused"));
}
s
}
/// The banner that makes the real operator flow impossible to miss. `None`
/// when nothing needs declaring.
pub fn declare_first_note(to_declare: usize) -> Option<String> {
(to_declare > 0).then(|| {
format!(
"{to_declare} of these repositories do not exist on this holger, and holger CANNOT \
create them while it is running — its route table is built from the config at boot \
and is immutable thereafter. Copy each declaration below into the wired config, \
restart holger, then run Discover again: they will come back as fillable.",
)
})
}
/// What pressing Start will actually do — decided here, so the page never has
/// to guess and a test can pin every case.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum StartPlan {
/// Nothing is selected. Start is not offered.
Nothing,
/// Every repository in the plan is selected: ONE job, whole registry, which
/// is what `MigrationSource.repository = ""` means on the wire.
WholeRegistry,
/// A strict subset: one job per repository, run in turn. The contract has no
/// "these five" — it has "all" and "this one" — so the page spells the
/// subset out as the jobs it really is rather than pretending otherwise.
PerRepository(Vec<String>),
}
pub fn start_plan(all: &[String], selected: &[String]) -> StartPlan {
if selected.is_empty() {
return StartPlan::Nothing;
}
if !all.is_empty() && all.iter().all(|r| selected.contains(r)) {
return StartPlan::WholeRegistry;
}
let mut chosen: Vec<String> = all
.iter()
.filter(|r| selected.contains(r))
.cloned()
.collect();
if chosen.is_empty() {
chosen = selected.to_vec();
}
StartPlan::PerRepository(chosen)
}
/// How many jobs a start plan is, for the sentence on the button's right.
pub fn start_plan_note(plan: &StartPlan) -> String {
match plan {
StartPlan::Nothing => "nothing selected".to_string(),
StartPlan::WholeRegistry => {
"one job, the whole registry — every repository the source lists".to_string()
}
StartPlan::PerRepository(v) if v.len() == 1 => {
format!("one job for '{}'", v[0])
}
StartPlan::PerRepository(v) => {
format!("{} jobs, one per repository, run in turn", v.len())
}
}
}
/// One repository's progress row, as a sentence.
///
/// The server sends `done: report.moved` and `skipped: report.skipped` — two
/// disjoint counts, not a total and a part of it. So `done` IS the moved count,
/// and the numerator against `total` is everything settled: moved plus already
/// present. Subtracting `skipped` from `done` erased moved assets on any run
/// that both moved something and found something already there.
pub fn migration_progress_line(done: i64, skipped: i64, failed: i64, total: i64, bytes: i64) -> String {
let moved = done.max(0);
let settled = moved.saturating_add(skipped.max(0));
format!(
"{settled}/{total} · {moved} moved · {skipped} already present · {failed} failed · {}",
fmt_bytes(bytes)
)
}
/// Percent complete for a progress bar, clamped. A zero denominator is 0%, not
/// a division by zero and not a finished-looking 100%.
///
/// `done` counts assets MOVED only; an asset the destination already held is
/// counted in `skipped`. Both are settled work against the same denominator, so
/// an idempotent re-run — where every asset is already present — reads 100% and
/// not the 0% a moved-only numerator would show beside a complete verdict.
pub fn migration_percent(done: i64, skipped: i64, total: i64) -> u32 {
if total <= 0 {
return 0;
}
let settled = done.max(0).saturating_add(skipped.max(0));
let pct = (settled as f64 / total as f64 * 100.0).round();
pct.clamp(0.0, 100.0) as u32
}
/// The terminal verdict of the WHOLE JOB. An Ok stream is NOT success: a run
/// that finished with failures is one an operator would wrongly believe is done.
///
/// `complete` is only meaningful on the row that carries `job_finished`, so this
/// is the job row's verdict and nobody else's. A per-repository row must use
/// `migration_repo_outcome`, which never reads `complete`.
pub fn migration_outcome(complete: bool, cancelled: bool, failed: i64) -> (String, Tone) {
if cancelled {
return ("cancelled — a partial migration is on disk".to_string(), Tone::Warn);
}
if failed > 0 || !complete {
return (
format!("finished INCOMPLETE — {failed} assets failed; nothing may be assumed moved"),
Tone::Bad,
);
}
("complete".to_string(), Tone::Good)
}
/// The verdict of ONE REPOSITORY's row. The per-repo event never sets
/// `complete` — the server leaves it false until the job's terminal row — so a
/// repository is judged on its own counters: nothing failed, nothing was cut
/// short, and every asset the source listed is settled, whether it was moved or
/// was already present.
pub fn migration_repo_outcome(
done: i64,
skipped: i64,
failed: i64,
total: i64,
cancelled: bool,
) -> (String, Tone) {
if cancelled {
return ("cancelled — a partial migration is on disk".to_string(), Tone::Warn);
}
let settled = done.max(0).saturating_add(skipped.max(0));
if failed > 0 || settled < total.max(0) {
return (
format!("finished INCOMPLETE — {failed} assets failed; nothing may be assumed moved"),
Tone::Bad,
);
}
("complete".to_string(), Tone::Good)
}
/// The sentence for "this session cannot call the migration door". `None` when
/// the role is admin, which is the only role the server accepts here.
pub fn migration_role_refusal(authenticated: bool, global_role: &str) -> Option<String> {
if global_role == "admin" {
return None;
}
if !authenticated {
return Some(
"this session is anonymous. Every migration RPC is ADMIN-gated and fails closed — \
log in with an admin credential before Discover."
.to_string(),
);
}
Some(format!(
"this session's global role is '{global_role}'. Every migration RPC is ADMIN-gated and \
fails closed, so Discover and Start will be refused until an admin credential is used."
))
}
/// **What the Disk panel says** — holger's own `disk_bytes` reading of one
/// repository (`UsageService.ReadUsage`): what it OCCUPIES on holger's disk, the
/// number a paid limit is measured against, and WHERE (owner 2026-09-27: "the
/// customer can see where their bytes are").
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskView {
/// Counted: the bytes, and each place with its own share, as holger said it.
Counted { bytes: u64, places: Vec<String> },
/// holger refused to count it, in its own words.
Refused(String),
/// This holger does not report `disk_bytes` (an older server).
NotReported,
}
/// Pure over `(name, repository-scoped, counted, value, source, refusal_reason)`
/// of each reading,
/// so it is tested without a proto. Only the REPOSITORY-scoped reading counts;
/// holger joins the places with `"; "`.
pub fn disk_view<'a>(readings: impl IntoIterator<Item = (&'a str, bool, bool, u64, &'a str, &'a str)>) -> DiskView {
for (name, repository_scope, counted, value, source, refusal) in readings {
if name != "disk_bytes" || !repository_scope {
continue;
}
return if counted {
DiskView::Counted {
bytes: value,
places: source.split("; ").filter(|s| !s.trim().is_empty()).map(str::to_string).collect(),
}
} else {
DiskView::Refused(if refusal.is_empty() { "not counted".to_string() } else { refusal.to_string() })
};
}
DiskView::NotReported
}
// ─── The menu (lane HOL-UI-FIX) ──────────────────────────────────────────────
/// **The console's menu, one entry per job** (owner 2026-10-31, "3 menus doing the same flow"):
/// the estate at a glance, the flow picture, the repositories (and what works on them: search,
/// replicate, migrate), the security gateway, and the person's own account (their session,
/// repositories, tokens and plan). Admin is offered beside it to an admin's session only.
pub const MENU: [(&str, &str); 5] = [("/overview", "Overview"), ("/flow", "Flow"), ("/repositories", "Repositories"), ("/security", "Security"), ("/account", "Account")];
/// The admin's page, the menu's sixth entry for an admin's session.
pub const ADMIN_ENTRY: &str = "/admin";
/// Pure: **which menu entry a page belongs to**, so the menu marks it: every page sits under
/// exactly one entry. `None` for an address no entry owns.
pub fn menu_entry(path: &str) -> Option<&'static str> {
let under = |p: &str| path == p || path.starts_with(&format!("{p}/"));
Some(match path {
"/" | "/overview" => "/overview",
_ if under("/flow") => "/flow",
_ if under("/repositories") || under("/repo") || under("/artifact") || under("/search") || under("/operations") || under("/replicate") || under("/migrate") => "/repositories",
_ if under("/security") => "/security",
// The Account entry's tabs (`mine::TABS`): the session, the person's repositories and
// tokens, and a metered console's plan.
_ if crate::mine::TABS.iter().any(|(p, _)| under(p)) => "/account",
_ if under(ADMIN_ENTRY) => ADMIN_ENTRY,
_ => return None,
})
}
#[cfg(test)]
mod menu_tests {
use super::*;
/// ★ HOL-UI-FIX: every page sits under one menu entry, and the entries are five jobs: no
/// second Overview, no Operations and no Mine of their own.
#[test]
fn every_page_sits_under_exactly_one_menu_entry() {
let labels: Vec<&str> = MENU.iter().map(|(_, l)| *l).collect();
assert_eq!(labels, ["Overview", "Flow", "Repositories", "Security", "Account"]);
for (path, entry) in [
("/", "/overview"),
("/overview", "/overview"),
("/flow", "/flow"),
("/repositories", "/repositories"),
("/repo/incoming", "/repositories"),
("/artifact", "/repositories"),
("/search", "/repositories"),
("/operations", "/repositories"),
("/operations/migrate", "/repositories"),
("/replicate", "/repositories"),
("/migrate", "/repositories"),
("/security", "/security"),
("/security/artifact", "/security"),
("/account", "/account"),
("/mine", "/account"),
("/tokens", "/account"),
("/admin", "/admin"),
] {
assert_eq!(menu_entry(path), Some(entry), "{path}");
}
#[cfg(feature = "metered")]
assert_eq!(menu_entry(crate::mine::TABS[2].0), Some("/account"), "a metered console's plan");
assert_eq!(menu_entry("/flowers"), None, "a prefix is not a page of the entry");
assert_eq!(menu_entry("/no-such-page"), None);
}
}
#[cfg(test)]
mod domain_tests {
use super::domain_label;
#[test]
fn a_domain_label_is_holgers_trimmed_or_nothing() {
assert_eq!(domain_label(Some(" onprem-lab ")), Some("onprem-lab".to_string()));
assert_eq!(domain_label(Some("")), None);
assert_eq!(domain_label(Some(" ")), None);
assert_eq!(domain_label(None), None);
assert_eq!(domain_label(Some(&"x".repeat(80))).map(|s| s.len()), Some(64), "cut, never grown");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_disk_panel_shows_holgers_own_reading_and_each_place() {
let v = disk_view([
("artifacts", true, true, 3, "crates", ""),
("disk_bytes", false, true, 999, "server", ""),
("disk_bytes", true, true, 61440, "archive (writable) /d/crates.znippy (57344 B); files /d/crates (4096 B)", ""),
]);
assert_eq!(
v,
DiskView::Counted {
bytes: 61440,
places: vec!["archive (writable) /d/crates.znippy (57344 B)".into(), "files /d/crates (4096 B)".into()],
}
);
assert_eq!(disk_view([("disk_bytes", true, false, 0, "", "no storage")]), DiskView::Refused("no storage".into()));
assert_eq!(disk_view([("artifacts", true, true, 1, "", "")]), DiskView::NotReported);
}
#[test]
fn display_name_joins_namespace_only_when_present() {
assert_eq!(display_name("", "uploads/a.txt"), "uploads/a.txt");
assert_eq!(display_name("org.acme", "drive"), "org.acme/drive");
}
#[test]
fn enc_keeps_unreserved_and_escapes_the_rest() {
assert_eq!(enc("abc-XYZ_0.9~"), "abc-XYZ_0.9~");
assert_eq!(enc("a/b c+å"), "a%2Fb%20c%2B%C3%A5");
}
#[test]
fn artifact_href_is_query_addressed_and_escaped() {
assert_eq!(
artifact_href("bundles", "", "acme/drive/2.0.0/firmware.bin", ""),
"/artifact?repo=bundles&ns=&name=acme%2Fdrive%2F2.0.0%2Ffirmware.bin&version="
);
}
#[test]
fn fmt_bytes_scans_well() {
assert_eq!(fmt_bytes(0), "0 B");
assert_eq!(fmt_bytes(999), "999 B");
assert_eq!(fmt_bytes(1000), "1.0 kB");
assert_eq!(fmt_bytes(1_250_646), "1.3 MB");
assert_eq!(fmt_bytes(-5), "0 B");
}
#[test]
fn prop_param_round_trips_first_equals_split() {
assert_eq!(parse_prop_param("env=prod"), ("env".into(), "prod".into()));
assert_eq!(parse_prop_param("env"), ("env".into(), String::new()));
assert_eq!(parse_prop_param("k=v=w"), ("k".into(), "v=w".into()));
assert_eq!(prop_param("env", ""), "env");
assert_eq!(prop_param("env", "prod"), "env=prod");
}
#[test]
fn confirm_phrase_demands_the_exact_name() {
assert!(confirm_phrase_matches("prod-bundles", "prod-bundles"));
// Paste noise at the ends is forgiven…
assert!(confirm_phrase_matches(" prod-bundles\n", "prod-bundles"));
// …but a different, prefix, or near-miss name is NOT the name.
assert!(!confirm_phrase_matches("prod-bundle", "prod-bundles"));
assert!(!confirm_phrase_matches("prod-bundles-2", "prod-bundles"));
assert!(!confirm_phrase_matches("PROD-BUNDLES", "prod-bundles"));
assert!(!confirm_phrase_matches("prod bundles", "prod-bundles"));
assert!(!confirm_phrase_matches("", "prod-bundles"));
}
#[test]
fn an_empty_requirement_is_never_satisfied() {
// A gate with nothing to type would silently arm the button.
assert!(!confirm_phrase_matches("", ""));
assert!(!confirm_phrase_matches(" ", " "));
assert!(!confirm_phrase_matches("anything", ""));
}
// ─── Tone ────────────────────────────────────────────────────────────────
#[test]
fn every_tone_carries_the_shared_badge_class() {
// The modifier alone would render as bare text — and two tones sharing
// a class would make two different states look identical.
let all = [Tone::Good, Tone::Warn, Tone::Bad, Tone::Neutral];
for t in all {
assert!(
t.class().starts_with("badge badge-"),
"{t:?} lost the base class: {}",
t.class()
);
}
let mut classes: Vec<&str> = all.iter().map(|t| t.class()).collect();
classes.sort_unstable();
classes.dedup();
assert_eq!(classes.len(), 4, "two tones share one class");
}
// ─── Search addresses ────────────────────────────────────────────────────
#[test]
fn the_search_axes_are_the_six_the_proto_offers() {
// Order is the address's order; a seventh axis must be a decision here,
// in the chips and in the removal path at once.
assert_eq!(
SEARCH_AXES,
["name", "namespace", "version", "path", "checksum", "repo"]
);
}
fn axes(pairs: &[(&'static str, &str)]) -> Vec<(&'static str, String)> {
pairs.iter().map(|(k, v)| (*k, v.to_string())).collect()
}
#[test]
fn search_href_omits_blank_axes_and_escapes_the_rest() {
assert_eq!(
search_href(&axes(&[("name", " firmware "), ("namespace", "")]), &[]),
"/search?name=firmware",
"a blank axis is no filter at all, and paste noise is not part of the term"
);
// A term may carry anything a name can: separators, URL syntax, non-ASCII.
assert_eq!(
search_href(&axes(&[("name", "acme/drive&v=2"), ("repo", "caché")]), &[]),
"/search?name=acme%2Fdrive%26v%3D2&repo=cach%C3%A9",
"an unescaped term could invent a second parameter"
);
}
#[test]
fn search_href_carries_each_property_filter_as_one_parameter() {
let props = vec![
("env".to_string(), " prod ".to_string()),
("signed".to_string(), String::new()),
// A blank key is not a filter — the editor's empty row must not
// become `prop=` and match everything (or nothing) server-side.
(" ".to_string(), "orphan".to_string()),
];
assert_eq!(
search_href(&axes(&[]), &props),
"/search?prop=env%3Dprod&prop=signed"
);
// The value's own `=` rides inside the encoded parameter, not as syntax.
let tricky = vec![("k".to_string(), "v=w&x".to_string())];
assert_eq!(
search_href(&axes(&[]), &tricky),
"/search?prop=k%3Dv%3Dw%26x"
);
// …and it survives the round trip the server sees.
assert_eq!(
parse_prop_param("k=v=w&x"),
("k".into(), "v=w&x".into()),
"the FIRST = splits; the value keeps the rest"
);
}
#[test]
fn a_search_with_nothing_in_it_is_the_bare_route() {
assert_eq!(search_href(&axes(&[("name", " ")]), &[]), "/search");
assert_eq!(search_href_from_chips(&[]), "/search");
}
#[test]
fn removing_a_chip_leaves_every_other_filter_intact_and_escaped() {
let chips = vec![
("name".to_string(), "acme/drive".to_string()),
("repo".to_string(), "bundles".to_string()),
("prop".to_string(), "env=prod".to_string()),
("prop".to_string(), "signed".to_string()),
];
assert_eq!(
search_href_from_chips(&chips),
"/search?name=acme%2Fdrive&repo=bundles&prop=env%3Dprod&prop=signed"
);
// Removing one property filter keeps the OTHER one — a chip is removed
// by identity (kind AND value), never by kind alone.
let kept: Vec<(String, String)> = chips
.iter()
.filter(|(k, v)| !(k == "prop" && v == "env=prod"))
.cloned()
.collect();
assert_eq!(
search_href_from_chips(&kept),
"/search?name=acme%2Fdrive&repo=bundles&prop=signed"
);
}
// ─── Repository list ─────────────────────────────────────────────────────
#[test]
fn repositories_href_carries_filter_and_sort() {
assert_eq!(repositories_href("", ""), "/repositories");
assert_eq!(repositories_href("dev", ""), "/repositories?q=dev");
assert_eq!(repositories_href("", "name"), "/repositories?sort=name");
assert_eq!(
repositories_href("crates dev", "type"),
"/repositories?q=crates%20dev&sort=type"
);
}
#[test]
fn a_sort_value_off_the_url_cannot_invent_a_second_parameter() {
// `sort` is read from the address and written back on the next click,
// so a crafted link must ride as ONE opaque (and simply unknown) value.
assert_eq!(
repositories_href("", "name&q=evil"),
"/repositories?sort=name%26q%3Devil"
);
assert_eq!(
repositories_href("a&b", "name"),
"/repositories?q=a%26b&sort=name"
);
}
#[test]
fn clicking_the_active_sort_column_clears_it() {
assert_eq!(toggled_sort("", "name"), "name");
assert_eq!(toggled_sort("type", "name"), "name");
assert_eq!(
toggled_sort("name", "name"),
"",
"a second click returns to the server's configuration order"
);
}
#[test]
fn the_repository_filter_is_case_insensitive_and_empty_means_all() {
assert!(matches_name_filter("crates-dev", "DEV"));
assert!(matches_name_filter("CRATES-DEV", "dev"));
assert!(matches_name_filter("anything", ""), "no filter excludes nothing");
assert!(!matches_name_filter("crates-dev", "prod"));
// Substring, not prefix — the filter is a search, not a namespace.
assert!(matches_name_filter("prod-bundles", "bundle"));
}
#[test]
fn repo_order_sorts_by_name_by_type_and_otherwise_not_at_all() {
let rows = [("zeta", "maven"), ("alpha", "pip"), ("mid", "maven")];
let sorted = |key: &str| {
let mut v = rows.to_vec();
v.sort_by(|a, b| repo_order((a.0, a.1), (b.0, b.1), key));
v.iter().map(|r| r.0).collect::<Vec<_>>()
};
assert_eq!(sorted("name"), ["alpha", "mid", "zeta"]);
// Type first, then name — a repeated sort must be deterministic.
assert_eq!(sorted("type"), ["mid", "zeta", "alpha"]);
// An absent or unknown key compares equal: a STABLE sort then leaves
// the server's configuration order exactly as it arrived.
assert_eq!(sorted(""), ["zeta", "alpha", "mid"]);
assert_eq!(sorted("size"), ["zeta", "alpha", "mid"]);
}
// ─── Repository facts off the wire ───────────────────────────────────
#[test]
fn a_group_is_named_with_its_member_count_and_a_plain_repo_is_not() {
assert_eq!(group_label(false, 3), None);
assert_eq!(group_label(true, 0).as_deref(), Some("group · 0 members"));
assert_eq!(group_label(true, 1).as_deref(), Some("group · 1 member"));
assert_eq!(group_label(true, 4).as_deref(), Some("group · 4 members"));
}
#[test]
fn the_empty_listing_note_comes_from_supports_listing_not_the_type() {
assert!(empty_listing_note(Some(true)).contains("is empty"));
assert!(empty_listing_note(Some(false)).contains("does not support generic listing"));
assert!(empty_listing_note(None).contains("unknown"));
}
// ─── Overview counts ─────────────────────────────────────────────────────
#[test]
fn repo_totals_line_derives_read_only_from_the_total() {
assert_eq!(
repo_totals_line(7, 2, 3),
"2 writable · 5 read-only · 3 archive-backed"
);
assert_eq!(
repo_totals_line(0, 0, 0),
"0 writable · 0 read-only · 0 archive-backed"
);
// Impossible input is a wrong number, never a panic on the operator's
// overview.
assert_eq!(
repo_totals_line(1, 4, 0),
"4 writable · 0 read-only · 0 archive-backed"
);
}
/// ★ HCON-4: **one formatter for every uptime**, in words a person reads.
#[test]
fn an_uptime_reads_in_hours_and_minutes() {
assert_eq!(uptime_words(0), "0 s");
assert_eq!(uptime_words(59), "59 s");
assert_eq!(uptime_words(60), "1 min");
assert_eq!(uptime_words(3599), "59 min");
assert_eq!(uptime_words(3600), "1 h 0 min");
assert_eq!(uptime_words(56_305), "15 h 38 min", "the owner's case");
assert_eq!(uptime_words(86_399), "23 h 59 min");
assert_eq!(uptime_words(86_400), "1 d 0 h");
assert_eq!(uptime_words(3 * 86_400 + 5 * 3600 + 59), "3 d 5 h");
}
/// ★ HCON-2/HCON-5: **the session and the read gate in plain words**: who, how, the role,
/// and one sentence on what the read gate does; never a raw subject or a field name.
#[test]
fn what_is_open_is_the_servers_words() {
let said = "Reading packages needs a sign-in. Public, readable with no sign-in: crates-nordisk. The front page and the sign-up are open.";
assert_eq!(access_words(said, true), said, "the server's words, as it says them");
assert_eq!(access_words("", true), read_gate_words(true), "an older Holger: its read gate alone");
assert_eq!(access_words(" ", false), read_gate_words(false));
}
#[test]
fn the_session_and_the_read_gate_read_in_plain_words() {
assert_eq!(session_words("passkey", "admin"), "You are signed in with a passkey. Your role here: admin.");
assert_eq!(session_words("oidc", "reader"), "You are signed in with your organisation's sign-in. Your role here: reader.");
assert_eq!(session_words("static-token", "writer"), "You are signed in with a token. Your role here: writer.");
assert_eq!(read_gate_words(true), "Reading packages needs a sign-in: package clients and visitors need a token.");
assert_eq!(
read_gate_words(false),
"Anyone who can reach this Holger can read its packages without signing in. That is meant for an airgapped downstream only."
);
for w in [session_words("passkey", "admin"), read_gate_words(true).into(), read_gate_words(false).into()] {
assert!(!w.contains("passkey:") && !w.contains("read gate") && !w.contains("global role"), "no internals: {w}");
}
}
// ─── Artifact detail ─────────────────────────────────────────────────────
#[test]
fn download_file_name_is_the_last_segment_and_never_empty() {
assert_eq!(download_file_name("firmware.bin"), "firmware.bin");
assert_eq!(
download_file_name("acme/drive/2.0.0/firmware.bin"),
"firmware.bin"
);
// An empty `download` attribute silently hands the browser its own
// guess — the fallback name is the honest one.
assert_eq!(download_file_name(""), "artifact");
assert_eq!(download_file_name("dir/"), "artifact");
assert_eq!(download_file_name("/"), "artifact");
assert_eq!(download_file_name("a/b/.hidden"), ".hidden");
}
#[test]
fn property_values_are_trimmed_and_blanks_dropped() {
assert_eq!(
parse_property_values(" prod , staging "),
vec!["prod".to_string(), "staging".to_string()]
);
assert_eq!(parse_property_values("solo"), vec!["solo".to_string()]);
// Nothing typable that means "no values" may reach the wire: empty
// values is the proto's REMOVE idiom, and the editor demands one.
assert!(parse_property_values("").is_empty());
assert!(parse_property_values(" ").is_empty());
assert!(parse_property_values(",, ,").is_empty());
// An inner comma is a separator, never part of a value.
assert_eq!(
parse_property_values("a,,b"),
vec!["a".to_string(), "b".to_string()]
);
}
// ─── Replication ─────────────────────────────────────────────────────────
#[test]
fn replication_refuses_an_incomplete_or_self_directed_pair() {
assert_eq!(
replication_pair_error("", "prod"),
Some("choose both repositories")
);
assert_eq!(
replication_pair_error("dev", ""),
Some("choose both repositories")
);
assert_eq!(
replication_pair_error("dev", "dev"),
Some("source and destination must differ")
);
assert_eq!(replication_pair_error("dev", "prod"), None);
}
#[test]
fn execute_is_armed_only_by_a_preview_of_the_very_same_pair() {
assert!(execute_armed(Some(("dev", "prod")), "dev", "prod"));
// No preview — including the `None` an executed run leaves behind, so
// one preview can never authorize two copies.
assert!(!execute_armed(None, "dev", "prod"));
// Either end moving disarms: the preview described a different copy.
assert!(!execute_armed(Some(("dev", "prod")), "dev", "staging"));
assert!(!execute_armed(Some(("dev", "prod")), "staging", "prod"));
// …and the pair reversed is a copy in the other direction.
assert!(!execute_armed(Some(("dev", "prod")), "prod", "dev"));
// Two blanks are not a pair, however they got there.
assert!(!execute_armed(Some(("", "")), "", ""));
}
// ─── Transport ───────────────────────────────────────────────────────────
#[test]
fn grpc_base_url_prefers_the_build_time_override() {
assert_eq!(
grpc_base_url(Some("https://holger.example/grpc/"), Some("https://console.example")),
"https://holger.example/grpc",
"the override wins, and a trailing slash would double up on every path"
);
}
#[test]
fn grpc_base_url_defaults_to_the_page_origin_under_grpc() {
assert_eq!(
grpc_base_url(None, Some("https://pve.example.ts.net")),
"https://pve.example.ts.net/grpc"
);
assert_eq!(
grpc_base_url(None, Some("https://pve.example.ts.net/")),
"https://pve.example.ts.net/grpc",
"origin//grpc is a 404 that reads as a broken server"
);
// No window, no origin: the dev loopback, never a URL with a hole.
assert_eq!(grpc_base_url(None, None), "http://127.0.0.1:8090/grpc");
}
#[test]
fn the_environment_chip_names_the_host_not_the_door() {
assert_eq!(
environment_label("https://pve.example.ts.net/grpc"),
"pve.example.ts.net"
);
assert_eq!(
environment_label("http://127.0.0.1:8090/grpc"),
"127.0.0.1:8090"
);
// An override that is not the conventional door keeps its path — the
// chip's whole job is that dev and prod cannot read the same.
assert_eq!(
environment_label("https://holger.example/api"),
"holger.example/api"
);
assert_eq!(environment_label(""), "");
}
/// Every gRPC status code tonic can hand the console — the vocabulary the
/// summary map must be total over.
const ALL_CODES: [tonic::Code; 17] = [
tonic::Code::Ok,
tonic::Code::Cancelled,
tonic::Code::Unknown,
tonic::Code::InvalidArgument,
tonic::Code::DeadlineExceeded,
tonic::Code::NotFound,
tonic::Code::AlreadyExists,
tonic::Code::PermissionDenied,
tonic::Code::ResourceExhausted,
tonic::Code::FailedPrecondition,
tonic::Code::Aborted,
tonic::Code::OutOfRange,
tonic::Code::Unimplemented,
tonic::Code::Internal,
tonic::Code::Unavailable,
tonic::Code::DataLoss,
tonic::Code::Unauthenticated,
];
#[test]
fn fail_summary_separates_the_situations_that_need_different_next_steps() {
assert_eq!(
fail_summary(tonic::Code::Unauthenticated),
"authentication required"
);
assert!(fail_summary(tonic::Code::PermissionDenied).contains("role mapping"));
assert_eq!(fail_summary(tonic::Code::Unimplemented), "not in this build");
assert_eq!(not_in_build("Promotion"), "Promotion: not in this build — this holger was built without it");
assert_eq!(fail_summary(tonic::Code::Unavailable), "backend unavailable");
assert_eq!(fail_summary(tonic::Code::NotFound), "not found");
// Anything else is honestly non-committal rather than guessed at.
for code in [
tonic::Code::Internal,
tonic::Code::ResourceExhausted,
tonic::Code::DataLoss,
tonic::Code::Cancelled,
] {
assert_eq!(fail_summary(code), "request failed", "{code:?}");
}
}
#[test]
fn every_status_code_has_a_summary_and_only_one_offers_login() {
for code in ALL_CODES {
assert!(!fail_summary(code).is_empty(), "{code:?} renders no sentence");
assert_eq!(
fail_suggests_login(code),
code == tonic::Code::Unauthenticated,
"{code:?} sends the operator to the wrong next step"
);
}
// A permission denial must NOT offer a login link: the operator is
// already signed in and looping through the IdP cannot grant a role.
assert!(!fail_suggests_login(tonic::Code::PermissionDenied));
}
// ─── Migration ───────────────────────────────────────────────────────────
/// A canary that is not a word, so a substring match cannot be a false
/// positive, and so a `contains` over any rendered string is conclusive.
const CANARY: &str = "zzCANARYpw7913zz";
fn canary_form() -> SourceForm {
SourceForm {
kind: MIGRATION_KIND_NEXUS,
base_url: "https://nexus.example.test".into(),
username: "migrator".into(),
password: CANARY.into(),
repository: "libs-release".into(),
}
}
/// **The password-leak test.**
///
/// Every string the Migrate page can render, store or copy about its source
/// is produced by one of the functions exercised here — the page renders
/// these and no others — so this is the page's whole text surface, checked
/// against a canary password.
///
/// It covers, in order: the summary line under the form; the `Debug` impl
/// (the derived one leaked, which is how `ConnectorAuth` leaks today); every
/// branch of the form validator, including the one that FIRES on a URL with
/// the credential embedded; and the state the form is left in after a call.
#[test]
fn a_source_password_never_reaches_anything_the_page_renders() {
let form = canary_form();
let mut rendered = vec![
source_summary(&form),
format!("{form:?}"),
source_host(&form.base_url),
start_plan_note(&start_plan(
&["libs-release".to_string()],
&["libs-release".to_string()],
)),
];
// Every refusal branch, including a URL that carries the canary inside
// it — the case where the secret arrives through the URL field.
for bad in [
SourceForm { kind: 0, ..form.clone() },
SourceForm { base_url: String::new(), ..form.clone() },
SourceForm { base_url: format!("nexus.example.test/{CANARY}"), ..form.clone() },
SourceForm {
base_url: format!("https://migrator:{CANARY}@nexus.example.test"),
..form.clone()
},
] {
if let Some(message) = source_form_error(&bad) {
rendered.push(message);
}
rendered.push(source_summary(&bad));
rendered.push(format!("{bad:?}"));
rendered.push(source_host(&bad.base_url));
}
for text in &rendered {
assert!(
!text.contains(CANARY),
"a source password reached rendered text: {text}"
);
}
// …and it is gone from the page's own state the moment a request has
// been built.
let after = forgotten(&form);
assert!(after.password.is_empty(), "the form still holds the password");
assert_eq!(after.base_url, form.base_url, "only the password is dropped");
assert_eq!(after.username, form.username);
assert_eq!(after.repository, form.repository);
}
/// A URL with `user:pass@` in it must be REFUSED, not quietly sent: it is
/// the one way a credential gets into a field the page echoes.
#[test]
fn a_url_that_carries_a_credential_is_refused_before_the_call() {
assert!(source_url_carries_a_credential("https://a:b@nexus.test/x"));
assert!(!source_url_carries_a_credential("https://nexus.test/x?q=a@b"));
let form = SourceForm {
base_url: "https://a:b@nexus.test".into(),
..canary_form()
};
let message = source_form_error(&form).expect("must refuse");
assert!(message.contains("credential inside it"), "{message}");
assert_eq!(source_host(&form.base_url), "nexus.test");
}
#[test]
fn the_form_refuses_what_never_needs_the_server() {
assert!(source_form_error(&canary_form()).is_none());
assert!(source_form_error(&SourceForm { kind: 0, ..canary_form() })
.unwrap()
.contains("Nexus or Artifactory"));
assert!(
source_form_error(&SourceForm { base_url: " ".into(), ..canary_form() })
.unwrap()
.contains("required")
);
assert!(
source_form_error(&SourceForm { base_url: "nexus.test".into(), ..canary_form() })
.unwrap()
.contains("http://")
);
}
/// The whole point of the page: a repository holger has not been told about
/// is NOT something Start can do, so it is not offered.
#[test]
fn only_a_declared_repository_can_be_started() {
assert!(migration_row_selectable("existing"));
assert!(!migration_row_selectable("create"));
assert!(!migration_row_selectable("collision"));
assert!(!migration_row_selectable("unmapped_format"));
assert!(!migration_row_selectable(""));
assert_eq!(disposition_tone("existing"), Tone::Good);
assert_eq!(disposition_tone("create"), Tone::Warn);
assert_eq!(disposition_tone("collision"), Tone::Bad);
assert!(disposition_note("create").contains("restart"));
}
#[test]
fn the_declare_first_banner_appears_exactly_when_something_must_be_declared() {
assert!(declare_first_note(0).is_none());
let note = declare_first_note(3).unwrap();
assert!(note.starts_with("3 of these"), "{note}");
assert!(note.contains("restart holger"), "{note}");
assert!(
migration_headline(5, 2, 3, 0).contains("3 must be declared first"),
"{}",
migration_headline(5, 2, 3, 0)
);
assert!(migration_headline(5, 5, 0, 0).ends_with("5 fillable now"));
assert!(migration_headline(5, 1, 3, 1).contains("1 refused"));
}
#[test]
fn start_plan_says_whether_it_is_one_job_or_many() {
let all = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert_eq!(start_plan(&all, &[]), StartPlan::Nothing);
assert_eq!(start_plan(&all, &all), StartPlan::WholeRegistry);
assert_eq!(
start_plan(&all, &["b".to_string()]),
StartPlan::PerRepository(vec!["b".to_string()])
);
// Selection order never changes the jobs: the plan's own order wins.
assert_eq!(
start_plan(&all, &["c".to_string(), "a".to_string()]),
StartPlan::PerRepository(vec!["a".to_string(), "c".to_string()])
);
assert!(start_plan_note(&StartPlan::WholeRegistry).contains("whole registry"));
assert!(start_plan_note(&start_plan(&all, &["b".to_string()])).contains("one job for 'b'"));
assert!(start_plan_note(&start_plan(&all, &["a".into(), "c".into()])).contains("2 jobs"));
}
/// A run that ended with failures is not a success, and the console must not
/// render it as one.
#[test]
fn a_job_that_failed_anything_never_reads_as_complete() {
assert_eq!(migration_outcome(true, false, 0).1, Tone::Good);
let (text, tone) = migration_outcome(true, false, 2);
assert_eq!(tone, Tone::Bad);
assert!(text.contains("INCOMPLETE"), "{text}");
assert!(!text.contains("complete —"), "{text}");
assert_eq!(migration_outcome(false, true, 0).1, Tone::Warn);
assert!(migration_outcome(false, false, 0).0.contains("INCOMPLETE"));
}
#[test]
fn progress_survives_a_source_that_advertised_no_totals() {
assert_eq!(migration_percent(0, 0, 0), 0);
assert_eq!(migration_percent(5, 0, 0), 0);
assert_eq!(migration_percent(5, 0, 10), 50);
assert_eq!(migration_percent(99, 0, 10), 100);
// `done` and `skipped` are DISJOINT counts from the server, so 10 moved
// beside 4 already present is 14 of 20 settled — not "10/20 · 6 moved",
// which is what this test asserted while the code subtracted one count
// from the other and erased four moved assets.
let line = migration_progress_line(10, 4, 1, 20, 2048);
assert!(line.starts_with("14/20 · 10 moved · 4 already present · 1 failed"), "{line}");
}
/// MEASURED shape: a repository that moved 2 assets and found 1 already
/// present printed "1 moved" — the sentence ate a moved asset, because
/// `moved` was computed as `done - skipped` when `done` is already the
/// moved count. Everything settled is the numerator, and the bar agrees
/// with the sentence.
#[test]
fn a_row_that_both_moved_and_skipped_counts_every_asset_once() {
let line = migration_progress_line(2, 1, 0, 3, 53);
assert!(line.starts_with("3/3 · 2 moved · 1 already present · 0 failed"), "{line}");
// The sentence and the bar tell the same story, and the verdict agrees.
assert_eq!(migration_percent(2, 1, 3), 100);
assert_eq!(migration_repo_outcome(2, 1, 0, 3, false).1, Tone::Good);
// Negative or absent counts never invent work.
let none = migration_progress_line(0, 0, 0, 0, 0);
assert!(none.starts_with("0/0 · 0 moved · 0 already present · 0 failed"), "{none}");
}
/// MEASURED, in a browser against the deployed console: a perfect run's
/// per-repository row read `100% | 2/2 · 2 moved · 0 already present ·
/// 0 failed | finished INCOMPLETE` — because the row was judged on
/// `complete`, a field the per-repo event never sets. A repository row is
/// judged on its own counters, and the job row keeps `complete`.
#[test]
fn a_perfect_repository_row_does_not_call_itself_incomplete() {
let (text, tone) = migration_repo_outcome(2, 0, 0, 2, false);
assert_eq!(tone, Tone::Good, "{text}");
assert_eq!(text, "complete");
// The old reading, kept as the thing that must never come back: the
// per-repo event carries `complete: false` and this must not be fed it.
assert!(migration_outcome(false, false, 0).0.contains("INCOMPLETE"));
// A repository that is genuinely short, or that failed anything, still
// refuses to read as done.
assert_eq!(migration_repo_outcome(1, 0, 0, 2, false).1, Tone::Bad);
assert_eq!(migration_repo_outcome(2, 0, 1, 3, false).1, Tone::Bad);
assert_eq!(migration_repo_outcome(2, 0, 0, 2, true).1, Tone::Warn);
}
/// MEASURED: the idempotent second run read `0% | 0/3 · 0 moved ·
/// 3 already present · 0 failed | complete` — 0% beside a complete verdict,
/// because the bar counted moved assets only. An asset the destination
/// already holds is settled work; the bar counts it.
#[test]
fn an_idempotent_rerun_reads_a_hundred_percent_and_complete() {
assert_eq!(migration_percent(0, 3, 3), 100, "every asset already present");
let (text, tone) = migration_repo_outcome(0, 3, 0, 3, false);
assert_eq!(tone, Tone::Good, "{text}");
assert_eq!(text, "complete");
// Half moved, half already present is still a full bar.
assert_eq!(migration_percent(1, 1, 2), 100);
// Skipped work never inflates past the denominator, and a short run
// still reads short.
assert_eq!(migration_percent(0, 1, 4), 25);
assert_eq!(migration_percent(9, 9, 10), 100);
}
#[test]
fn the_admin_gate_is_stated_before_the_call_is_made() {
assert!(migration_role_refusal(true, "admin").is_none());
assert!(migration_role_refusal(false, "").unwrap().contains("anonymous"));
let writer = migration_role_refusal(true, "writer").unwrap();
assert!(writer.contains("'writer'"), "{writer}");
assert!(writer.contains("ADMIN-gated"), "{writer}");
}
}