Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-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())
        );
    }
}