crates/gunnar-ui-web/src/visibility.rs
//! **What the visibility control decides**, on the host side of the wasm
//! boundary. Same argument as [`crate::room`] and [`crate::mirror`]: the panel
//! that draws this lives in `pages::repo`, which is `#[cfg(target_arch =
//! "wasm32")]`, so nothing inside it is compiled — let alone tested — by `cargo
//! test`. That is fine for a view and not fine for this.
//!
//! # Why this one is not a toggle
//!
//! Until now the console could only ever READ a repository's visibility: the
//! word sat in the Settings panel as a metric and a badge, and the single way
//! to change it was `gunnar control repo set-visibility` in a terminal. The
//! obvious fix — a switch beside the metric — is the wrong one, and the reason
//! is a fact about gunnar rather than about taste:
//!
//! > **`public` publishes every reference, not the default branch.**
//!
//! There is no per-branch visibility in the protocol. A repository is readable
//! or it is not, and when it is, an anonymous `git clone` followed by `git
//! branch -r` hands over every branch that was ever pushed. On this box that is
//! not hypothetical: `alice/gunnar` carries `sec/gunnar-review`,
//! `sec/gunnar-mirror` and `sec/gunnar-remaining` beside its lane branches, and
//! a one-click switch would have published a security review of the forge to
//! the open internet in the time it takes to mis-click.
//!
//! So the two directions are deliberately NOT symmetric:
//!
//! | direction | ceremony | why |
//! |---|---|---|
//! | private → public | [`Press::Arm`] first, then [`Press::Send`] | the act is irreversible in the only sense that matters — a copy taken is a copy kept — and the confirmation has to NAME the branches it is about to hand over |
//! | public → private | [`Press::Send`] at once | narrowing access needs no guard, and a second click in front of it would only teach people to click through the first one |
//!
//! # What the confirmation is allowed to claim
//!
//! [`Exposure`] is built from the reference list the repository page has
//! **already fetched** — `GET /api/repos/{ns}/{name}/refs`, whose answer the
//! page renders in its References panel — so naming the branches costs no
//! request. That list can be short of the truth in two ways, and both are
//! carried in the type rather than smoothed over, because a confirmation that
//! under-counts what it is publishing is worse than one that admits it does not
//! know:
//!
//! * the page's read was **refused or has not landed** — [`Exposure::Unknown`];
//! * the server **truncated** the list (`Page::truncated`), so the names in
//! hand are a prefix of the real set — [`Exposure::Known::truncated`].
/// Which way the control would move this repository.
///
/// Built from gunnar's own word for the current state, and deliberately
/// `Option`: gunnar has exactly two visibilities and refuses `INTERNAL` by
/// name, but a build of this console can outlive a change to that, and
/// offering "make it public" against a state this code does not recognise
/// would be guessing with somebody else's repository.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Turn {
/// private → public. The one that needs the ceremony.
Publish,
/// public → private.
Retract,
}
impl Turn {
/// What gunnar reports today → what the button would do.
pub fn from_current(visibility: &str) -> Option<Turn> {
match visibility.trim().to_ascii_lowercase().as_str() {
"private" => Some(Turn::Publish),
"public" => Some(Turn::Retract),
_ => None,
}
}
/// The value the PATCH body carries. gunnar's own spelling, lower case,
/// because `api::repos::visibility_from_name` matches on it.
pub fn target(self) -> &'static str {
match self {
Turn::Publish => "public",
Turn::Retract => "private",
}
}
/// True for the direction that widens access.
pub fn widens(self) -> bool {
matches!(self, Turn::Publish)
}
}
/// What publishing would hand over, as far as the page can honestly say.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Exposure {
/// The reference list has not landed, or was refused. The console can still
/// state the RULE — every branch, not just the default — but it cannot
/// name a single one, and it says so rather than implying an empty set.
Unknown,
/// The reference list is in hand.
Known {
/// Short branch names (`refs/heads/` stripped), in the order the server
/// listed them.
branches: Vec<String>,
/// Tags are published too. Counted separately because the sentence is
/// about branches and a tag count folded into it would be a lie about
/// which number is which.
tags: usize,
/// True when the server said it left references out. The names in hand
/// are then a prefix, and the count is a FLOOR rather than a total.
truncated: bool,
},
}
/// How many branch names the confirmation spells out before it starts counting.
///
/// Twelve is not a design preference: it is the point past which a reader stops
/// reading the list and starts trusting it, which is the failure this whole
/// control exists to avoid. Past twelve the sentence gives the number and the
/// first twelve, and the References panel directly below holds the rest.
const NAMED: usize = 12;
impl Exposure {
/// Build from the reference list the page already holds.
///
/// Takes ref NAMES rather than the page's `RefTarget`, which lives behind
/// `#[cfg(target_arch = "wasm32")]` in `pages::repo` and would drag leptos
/// in here with it.
pub fn from_refs<'a>(names: impl IntoIterator<Item = &'a str>, truncated: bool) -> Exposure {
let mut branches = Vec::new();
let mut tags = 0usize;
for name in names {
if let Some(short) = name.strip_prefix("refs/heads/") {
branches.push(short.to_owned());
} else if name.starts_with("refs/tags/") {
tags += 1;
}
}
Exposure::Known {
branches,
tags,
truncated,
}
}
/// How many branches this says would be published, when it can say.
pub fn branch_count(&self) -> Option<usize> {
match self {
Exposure::Unknown => None,
Exposure::Known { branches, .. } => Some(branches.len()),
}
}
/// ★ **The sentence that has to be true.**
///
/// Every branch of it states the rule — *every* branch, not the default one
/// — before it states the evidence, so the warning survives the case where
/// there is no evidence to give.
pub fn warning(&self) -> String {
match self {
Exposure::Unknown => "Public means anyone with the address can clone this \
repository with no key and no account, and that reaches EVERY branch in \
it, not only the default one. The console could not read this \
repository's references, so it cannot name them for you here."
.to_owned(),
Exposure::Known {
branches,
tags,
truncated,
} => {
let mut out = String::new();
let n = branches.len();
if n == 0 && !truncated {
out.push_str(
"Public means anyone with the address can clone this repository \
with no key and no account. It has no branches yet — but the \
setting is a standing one, and every branch pushed here from \
now on is readable by anyone the moment it lands.",
);
} else {
let at_least = if *truncated { "at least " } else { "" };
let plural = if n == 1 { "branch" } else { "branches" };
out.push_str(&format!(
"Public means anyone with the address can clone this repository \
with no key and no account, and that reaches EVERY branch in it, \
not only the default one — {at_least}{n} {plural}: "
));
let named: Vec<&str> = branches.iter().take(NAMED).map(String::as_str).collect();
out.push_str(&named.join(", "));
if n > NAMED {
out.push_str(&format!(", and {} more", n - NAMED));
}
out.push('.');
if *truncated {
out.push_str(
" The server left references out of the list this page was \
given, so there may be more than these.",
);
}
}
if *tags > 0 {
let plural = if *tags == 1 { "tag" } else { "tags" };
out.push_str(&format!(" Its {tags} {plural} go with them."));
}
out
}
}
}
/// What the button that actually publishes says on it.
///
/// The count lives on the BUTTON and not only in the paragraph above it, so
/// the click that publishes cannot be made without the number being under
/// the pointer. "Publish" alone would be a word anyone clicks.
pub fn confirm_label(&self) -> String {
match self {
Exposure::Unknown => "Publish every branch".to_owned(),
Exposure::Known {
branches,
truncated,
..
} => match (branches.len(), truncated) {
(0, false) => "Publish this repository".to_owned(),
(1, false) => "Publish 1 branch".to_owned(),
(n, false) => format!("Publish all {n} branches"),
(n, true) => format!("Publish all {n}+ branches"),
},
}
}
}
/// ★ **What the other direction does not undo.**
///
/// One sentence, and not a lecture: turning a repository private again is the
/// safe direction and nobody needs talking out of it. But "private" reads as
/// *undone*, and it is not — so the panel says the one true thing about it and
/// stops.
pub fn retraction_note() -> &'static str {
"Private again asks for a key on every read from here on. It does not unpublish: \
anything already cloned stays cloned."
}
/// What a press of the control does next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Press {
/// Open the confirmation. Nothing is sent.
Arm,
/// Send the PATCH.
Send,
/// Do nothing — a request is already in flight.
Ignore,
}
/// ★ **The guard itself**, in one place so it can be tested rather than
/// believed.
///
/// The rule this encodes is the whole point of the control: the widening
/// direction cannot reach [`Press::Send`] from a cold panel, however the button
/// is clicked, because the first press of it only ever arms. The narrowing
/// direction sends at once.
///
/// `busy` wins over both. A double-click on "Publish all 5 branches" must be
/// one PATCH, not two — gunnar would answer the second one happily and the
/// console would be racing itself over what the repository now is.
pub fn press(turn: Turn, armed: bool, busy: bool) -> Press {
if busy {
return Press::Ignore;
}
match turn {
Turn::Retract => Press::Send,
Turn::Publish if armed => Press::Send,
Turn::Publish => Press::Arm,
}
}
/// **What the server says about THIS session and this repository** —
/// `GET /api/repos/{ns}/{name}/visibility` (`api::visibility::Authority` on
/// the server). The panel draws the control from this and nothing else.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub struct Authority {
/// The account the repository belongs to: its namespace.
pub account: String,
/// This session is that account's admin.
pub account_admin: bool,
/// The PATCH this session would send can succeed.
pub may_change: bool,
/// Why not, in the server's words, when `may_change` is false.
#[serde(default)]
pub why: Option<String>,
}
/// Whether this session may change visibility at all, and what to say when not.
///
/// **A repository's visibility is its ACCOUNT admin's** — gunnar's default for
/// every account (the owner's "GitHub Enterprise makes it impossible for a
/// non-admin to make repos public"): not whoever may write, not whoever holds
/// admin on the one repository. The server decides who that is — the console calls gunnar with
/// its own key, so the question is asked against gunnar's grant table on the
/// server side — and says so in [`Authority`]; this only turns the answer into
/// what the panel draws. The PATCH re-asks before it signs, so nothing here is
/// load-bearing for the rule itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Standing {
/// `/auth/status`, or the authority read, has not answered. Draw nothing
/// yet rather than guess — the rule [`crate::app::session`] states for the
/// whole console.
Waiting,
/// This session is the account admin and may send the PATCH.
Allowed,
/// Not on offer, and the panel says why instead of offering a button that
/// would come back 403.
Refused(String),
}
impl Standing {
/// From what the page holds: has `/auth/status` answered, is anybody
/// signed in, and the server's [`Authority`] (or why it could not be read).
pub fn of(answered: bool, signed_in: bool, authority: Option<Result<&Authority, &str>>) -> Standing {
if !answered {
return Standing::Waiting;
}
if !signed_in {
return Standing::Refused(Standing::refusal(false).to_owned());
}
match authority {
None => Standing::Waiting,
Some(Err(why)) => Standing::Refused(format!(
"The console could not tell whether you may change what this repository is \
readable by, so it does not offer it: {why}"
)),
Some(Ok(a)) if !a.may_change => Standing::Refused(
a.why
.clone()
.unwrap_or_else(|| Standing::refusal(true).to_owned()),
),
Some(Ok(_)) => Standing::Allowed,
}
}
/// Why the control is not on offer, when the server gave no words.
///
/// Named rather than generic: "you cannot do this" sends a person to look
/// for a bug, and "the account admin can" sends them to whoever that is.
pub fn refusal(signed_in: bool) -> &'static str {
if signed_in {
"Changing what this repository is readable by is the account admin's. This \
session is not, so the console does not offer it here."
} else {
"Sign in as the account admin to change what this repository is readable by."
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn refs(names: &[&str]) -> Exposure {
Exposure::from_refs(names.iter().copied(), false)
}
/// ★ **The confirmation cannot be skipped in the widening direction.**
///
/// This is the row the whole control exists for. `alice/gunnar` carries
/// three `sec/*` branches; a first press that sent the PATCH would have put
/// a security review of this forge on the open internet.
#[test]
fn publishing_cannot_happen_on_the_first_press() {
assert_eq!(
press(Turn::Publish, false, false),
Press::Arm,
"the first press of the widening direction must only arm the confirmation"
);
assert_eq!(
press(Turn::Publish, true, false),
Press::Send,
"…and the second, with the confirmation on screen, is the one that sends"
);
assert_eq!(
press(Turn::Publish, false, true),
Press::Ignore,
"a press while a request is in flight is not a second PATCH"
);
assert_eq!(
press(Turn::Publish, true, true),
Press::Ignore,
"…nor is a double-click on the confirm button itself"
);
}
/// Narrowing needs no ceremony, and a guard in front of it would only teach
/// people to click through guards.
#[test]
fn retracting_sends_at_once() {
assert_eq!(press(Turn::Retract, false, false), Press::Send);
assert_eq!(press(Turn::Retract, false, true), Press::Ignore);
assert!(
retraction_note().contains("already cloned stays cloned"),
"the one sentence has to say what it does not undo: {}",
retraction_note()
);
}
/// ★ **The confirmation names what is about to become readable.**
///
/// The number, the names, and — the part that came out of a real near-miss
/// — the words saying it is EVERY branch and not the default one.
#[test]
fn the_confirmation_names_every_branch_it_would_publish() {
let e = refs(&[
"refs/heads/main",
"refs/heads/sec/gunnar-review",
"refs/heads/sec/gunnar-mirror",
"refs/heads/sec/gunnar-remaining",
"refs/tags/v0.1.0",
]);
assert_eq!(e.branch_count(), Some(4));
let warning = e.warning();
for branch in [
"main",
"sec/gunnar-review",
"sec/gunnar-mirror",
"sec/gunnar-remaining",
] {
assert!(
warning.contains(branch),
"the confirmation did not name {branch}: {warning}"
);
}
assert!(
warning.contains("4 branches"),
"the count is missing: {warning}"
);
assert!(
warning.contains("EVERY branch") && warning.contains("not only the default"),
"the confirmation has to say plainly that this is every branch: {warning}"
);
assert!(
warning.contains("1 tag"),
"tags are published too and the sentence counts them apart: {warning}"
);
assert_eq!(
e.confirm_label(),
"Publish all 4 branches",
"the count belongs on the button, not only in the paragraph"
);
}
/// A truncated reference list is a FLOOR, and the sentence says so rather
/// than presenting a prefix as the whole.
#[test]
fn a_truncated_ref_list_is_counted_as_at_least() {
let e = Exposure::from_refs(["refs/heads/main", "refs/heads/topic"], true);
let warning = e.warning();
assert!(
warning.contains("at least 2 branches"),
"a prefix must not be presented as a total: {warning}"
);
assert!(
warning.contains("left references out"),
"the reader has to be told the list was cut: {warning}"
);
assert_eq!(e.confirm_label(), "Publish all 2+ branches");
}
/// Past a dozen the list stops being read, so it stops being printed — but
/// the number does not.
#[test]
fn a_long_branch_list_is_counted_whole_and_named_in_part() {
let names: Vec<String> = (0..20).map(|i| format!("refs/heads/lane-{i}")).collect();
let e = Exposure::from_refs(names.iter().map(String::as_str), false);
let warning = e.warning();
assert!(warning.contains("20 branches"), "{warning}");
assert!(warning.contains("lane-0"), "{warning}");
assert!(warning.contains("and 8 more"), "{warning}");
assert!(
!warning.contains("lane-19"),
"the twelve named are the first twelve: {warning}"
);
}
/// ★ **Not knowing is said out loud.**
///
/// The refs read can fail, and an empty branch list would then read as "you
/// are publishing nothing" — the single most dangerous sentence this panel
/// could print.
#[test]
fn an_unread_ref_list_admits_it_rather_than_implying_an_empty_one() {
let warning = Exposure::Unknown.warning();
assert!(
warning.contains("could not read"),
"the blind case has to admit it: {warning}"
);
assert!(
warning.contains("EVERY branch"),
"…while still stating the rule: {warning}"
);
assert_eq!(Exposure::Unknown.branch_count(), None);
assert_eq!(Exposure::Unknown.confirm_label(), "Publish every branch");
}
/// An empty repository is still a standing promise about what lands later.
#[test]
fn an_empty_repository_is_told_what_public_means_for_its_future() {
let warning = refs(&[]).warning();
assert!(warning.contains("no branches yet"), "{warning}");
assert!(
warning.contains("from now on"),
"public is a standing setting, not a snapshot: {warning}"
);
}
/// gunnar answers two visibilities. A third would be a state this console
/// must not guess its way out of.
#[test]
fn only_the_two_visibilities_gunnar_has_offer_a_turn() {
assert_eq!(Turn::from_current("private"), Some(Turn::Publish));
assert_eq!(Turn::from_current("public"), Some(Turn::Retract));
assert_eq!(Turn::from_current("PUBLIC"), Some(Turn::Retract));
assert_eq!(Turn::from_current("internal"), None);
assert_eq!(Turn::from_current(""), None);
assert_eq!(Turn::Publish.target(), "public");
assert_eq!(Turn::Retract.target(), "private");
assert!(Turn::Publish.widens() && !Turn::Retract.widens());
}
fn authority(account_admin: bool, may_change: bool) -> Authority {
Authority {
account: "acme".into(),
account_admin,
may_change,
why: (!may_change).then(|| "Only the account admin of acme may change it.".into()),
}
}
/// ★ **The control is the ACCOUNT admin's**: hidden — with the server's
/// reason in its place — for anybody else, a write-capable operator
/// included; drawn for the account admin.
#[test]
fn the_control_is_shown_only_to_the_account_admin() {
assert_eq!(Standing::of(false, true, None), Standing::Waiting);
assert_eq!(
Standing::of(true, true, None),
Standing::Waiting,
"the authority read has not landed: draw nothing yet"
);
let operator_not_admin = authority(false, false);
match Standing::of(true, true, Some(Ok(&operator_not_admin))) {
Standing::Refused(why) => assert!(why.contains("account admin"), "{why}"),
other => panic!("a non-account-admin was offered the control: {other:?}"),
}
let admin = authority(true, true);
assert_eq!(Standing::of(true, true, Some(Ok(&admin))), Standing::Allowed);
match Standing::of(true, false, None) {
Standing::Refused(why) => assert!(why.contains("Sign in"), "{why}"),
other => panic!("a visitor was offered the control: {other:?}"),
}
match Standing::of(true, true, Some(Err("gunnar is unavailable"))) {
Standing::Refused(why) => assert!(why.contains("could not tell"), "{why}"),
other => panic!("an unread authority offered the control: {other:?}"),
}
}
/// ★ **An account admin whose session cannot PATCH is told why** — with
/// the server's words, not offered a button that ends in 403.
#[test]
fn an_account_admin_on_a_viewer_session_is_told_why() {
let viewer = Authority {
why: Some("this console session is a viewer's".into()),
..authority(true, false)
};
assert_eq!(
Standing::of(true, true, Some(Ok(&viewer))),
Standing::Refused("this console session is a viewer's".into())
);
}
}