crates/gunnar-ui-web/src/encryption.rs
//! **What the encryption control decides**, on the host side of the wasm
//! boundary (round 5.3.1): the badge an encrypted repository carries, which way
//! the Settings control would move it, and what each direction says before it
//! is sent. Same argument as [`crate::room`].
//!
//! Who may press it is gunnar's answer, read exactly as for visibility
//! ([`crate::visibility::Authority`]): a repository's "encrypted" flag is its
//! ACCOUNT admin's (`Repos.SetEncrypted` needs Admin over the namespace — the
//! admin, or a member the admin appointed). Nothing here decides that.
/// ★ **The encryption switch's info box** (UI-ENC): its button and words, ONE file
/// (`encryption_info.html`), drawn beside the switch by `ui::InfoTip`.
pub const INFO: &str = include_str!("encryption_info.html");
use gunnar_crypt::members::Level;
/// **New repository's level choice** (CRYPT-PQ-DEFAULT): gunnar's own default
/// ([`Level::NEW`], quantum-safe) first and marked so, the other level second as
/// the visible opt-out. The console decides nothing here: it renders gunnar's
/// default, and the level itself is written by `gunnar crypt init`.
pub fn level_choices() -> [(Level, &'static str); 2] {
let other = match Level::NEW {
Level::QuantumSafe => Level::Classic,
Level::Classic => Level::QuantumSafe,
};
[(Level::NEW, label(Level::NEW, true)), (other, label(other, false))]
}
fn label(level: Level, default: bool) -> &'static str {
match (level, default) {
(Level::QuantumSafe, true) => "quantum-safe (default)",
(Level::QuantumSafe, false) => "quantum-safe",
(Level::Classic, true) => "classic (default)",
(Level::Classic, false) => "classic: for members whose key is classic",
}
}
/// The command that seals a new repository at `level`, on the person's computer.
pub fn seal_command(level: Level) -> &'static str {
match level {
Level::QuantumSafe if Level::NEW == Level::QuantumSafe => "gunnar crypt init",
Level::QuantumSafe => "gunnar crypt init --level quantum-safe",
Level::Classic if Level::NEW == Level::Classic => "gunnar crypt init",
Level::Classic => "gunnar crypt init --level classic",
}
}
/// The badge an encrypted repository carries on its page and in the list;
/// nothing for one that is not.
pub fn badge(encrypted: bool) -> Option<&'static str> {
encrypted.then_some("encrypted")
}
/// Which way the control would move the flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flip {
/// Not encrypted now: turning it on.
On,
/// Encrypted now: turning it off.
Off,
}
impl Flip {
/// The direction from the flag as Gunnar reports it.
pub fn from_current(encrypted: bool) -> Flip {
if encrypted {
Flip::Off
} else {
Flip::On
}
}
/// What the PATCH sends (`{"encrypted": …}`).
pub fn target(self) -> bool {
matches!(self, Flip::On)
}
/// The control's button; its ellipsis opens the confirmation and sends nothing.
pub fn label(self) -> &'static str {
match self {
Flip::On => "Turn encryption on…",
Flip::Off => "Turn encryption off…",
}
}
/// The confirmation's button.
pub fn confirm_label(self) -> &'static str {
match self {
Flip::On => "Turn encryption on",
Flip::Off => "Turn encryption off",
}
}
/// What happens, said before it is sent.
pub fn consequence(self, path: &str) -> String {
match self {
Flip::On => format!(
"Gunnar then refuses every push to {path} that is not encrypted: encrypt the files in \
your copy first (the command-line tool shows how). What is already stored stays as it is."
),
Flip::Off => format!(
"Gunnar then takes unencrypted pushes to {path} again. Files already encrypted stay encrypted."
),
}
}
}
#[cfg(test)]
mod tests {
/// ★ **UI-ENC: the info box's words, in ONE place** (`encryption_info.html`): a real button
/// that names its `role="tooltip"` box, the owner's words exactly once, and the "i" drawn
/// beside both encryption switches (the new repository's and Settings'), Esc closing it.
#[test]
fn the_encryption_info_is_one_file_a_button_and_its_box() {
let words = "Encryption keeps this repository unreadable to anyone but its members, including the server operator, the cloud and backups. Files are sealed on your computer before push; open it with your security key or the Gunnar CLI. Best on private repositories: on a public one everyone here can fetch the ciphertext.";
assert_eq!(super::INFO.matches(words).count(), 1, "the owner's words, once: {}", super::INFO);
assert!(super::INFO.starts_with("<button type=\"button\" class=\"infotip-btn\"") && super::INFO.contains("aria-describedby=\"enc-info\""), "a focusable button naming its box");
assert!(super::INFO.contains("role=\"tooltip\" id=\"enc-info\""));
for page in [include_str!("pages/repos.rs"), include_str!("pages/repo.rs")] {
assert!(page.contains("<crate::ui::InfoTip markup=crate::encryption::INFO/>"), "the i beside the switch");
assert!(!page.contains("Encryption keeps this repository"), "the words live in one file only");
}
let ui = include_str!("ui.rs");
assert!(ui.contains("ev.key() == \"Escape\"") && ui.contains(".blur()"), "Esc closes it");
let css = include_str!("../styles/console.css");
assert!(css.contains(".infotip:hover .infotip-box,\n.infotip:focus-within .infotip-box {"), "hover, focus and tap show it");
}
use super::*;
/// ★ An encrypted repository says so where it is listed and opened; one that
/// is not says nothing.
#[test]
fn an_encrypted_repository_carries_its_badge_and_no_other_does() {
assert_eq!(badge(true), Some("encrypted"));
assert_eq!(badge(false), None);
}
/// ★ The control moves the flag the other way, and each direction says what it
/// does before anything is sent: on — Gunnar refuses a plaintext push and what is
/// already stored stays as it is; off — Gunnar takes plaintext again and what is
/// sealed stays sealed.
#[test]
fn each_direction_names_what_gunnar_does_after_it() {
let on = Flip::from_current(false);
assert_eq!((on, on.target()), (Flip::On, true));
let said = on.consequence("acme/handbook");
assert!(said.contains("acme/handbook") && said.contains("refuses") && said.contains("not encrypted"), "{said}");
assert!(said.contains("already"), "what is stored already is named: {said}");
let off = Flip::from_current(true);
assert_eq!((off, off.target()), (Flip::Off, false));
let said = off.consequence("acme/handbook");
assert!(said.contains("acme/handbook") && said.contains("unencrypted") && said.contains("stay encrypted"), "{said}");
assert!(!said.contains("sealed") && !said.contains("plaintext"), "engineer words: {said}");
assert!(on.label().ends_with('…') && off.label().ends_with('…'), "the first press opens the confirmation");
}
/// ★ CRYPT-PQ-DEFAULT: New repository's level choice reads gunnar's default
/// (quantum-safe, first, marked so), classic is the visible opt-out, and each
/// choice names the command that seals it.
#[test]
fn the_level_choice_defaults_to_quantum_safe_and_names_its_command() {
use gunnar_crypt::members::Level;
let choices = level_choices();
assert_eq!(choices[0].0, Level::NEW);
assert!(choices[0].1.contains("quantum-safe") && choices[0].1.contains("default"), "{}", choices[0].1);
assert_eq!(choices[1].0, Level::Classic);
assert!(choices[1].1.contains("classic"), "{}", choices[1].1);
assert_eq!(seal_command(Level::QuantumSafe), "gunnar crypt init");
assert_eq!(seal_command(Level::Classic), "gunnar crypt init --level classic");
assert!(include_str!("pages/repos.rs").contains("id=\"enc-level\""), "the choice is drawn");
assert!(INFO.contains("quantum-safe"), "the info box says new ones are quantum-safe");
}
}