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

nordisk/gunnar

crates/gunnar-cli/tests/encrypted_repo.rs

#![cfg(all(feature = "server", feature = "client"))]

//! **Encrypted repositories, Level 1: stock git, a gunnar filter, a real
//! gunnar server that only ever holds ciphertext.**
//!
//! Every user here is a separate HOME with their own ed25519 key at
//! `~/.ssh/id_ed25519`: the same key logs in over SSH and unwraps the
//! repository key. The server is the in-process gunnar the rest of this suite
//! uses, and its stored objects are read straight out of its store.
//!
//! ```text
//!   alice ─ gunnar crypt init/add-member ─▶ .gunnar-crypt/keys (age, to alice+bob)
//!   alice ─ git push ─▶ gunnar ─▶ store: envelopes only
//!   bob   ─ git clone + gunnar crypt unlock ─▶ plaintext
//!   carol ─ git clone (Read grant, no key) ─▶ ciphertext, unlock refused
//! ```

mod common;

use std::path::{Path, PathBuf};
use std::process::Command;

use common::member::{output, read, User};
use common::{tools_present, Harness, Output};
use gunnar_core::{Grant, HashKind, Level, Principal, PrincipalKind, RepoRef};
use gunnar_crypt::envelope;

const NS: &str = "team";
const REPO: &str = "vault";
const SECRET: &str = "the launch code is 0000-1111\n";

fn user(h: &Harness, name: &'static str, level: Option<Level>) -> User {
    User::new(h, name, level, NS, REPO)
}

/// alice makes the repository, encrypts it to alice (+ the given members),
/// commits the secret and pushes.
fn alice_starts(h: &Harness, alice: &User, also: &[&User]) {
    let w = alice.work();
    std::fs::create_dir_all(&w).unwrap();
    alice
        .git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    // The forge first: it keeps alice's encryption key (CRYPT-YUBI).
    alice
        .git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote add");
    alice
        .gunnar(&w, &["crypt", "init", "--member", alice.name])
        .expect_ok("gunnar crypt init");
    for m in also {
        let key_file = alice.home.join(format!("{}.pub", m.name));
        std::fs::write(&key_file, &m.public).unwrap();
        alice
            .gunnar(
                &w,
                &[
                    "crypt",
                    "add-member",
                    m.name,
                    "--key",
                    key_file.to_str().unwrap(),
                ],
            )
            .expect_ok("gunnar crypt add-member");
    }
    std::fs::write(w.join("secret.txt"), SECRET).unwrap();
    alice.git(&w, &["add", "secret.txt"]).expect_ok("git add");
    alice
        .git(&w, &["commit", "-q", "-m", "the secret"])
        .expect_ok("git commit");
    alice
        .git(&w, &["push", "-q", "-u", "origin", "main"])
        .expect_ok("alice's push");
}

/// A person whose ONE encryption key is quantum-safe (age1pq1…), opened by their SSH key.
fn quantum_safe_user(h: &Harness, name: &'static str) -> User {
    let mut u = User::without_encryption_key(h, name, Some(Level::Write), NS, REPO);
    u.public = common::member::give_an_encryption_key_at(
        h,
        name,
        &u.ssh_public,
        gunnar_crypt::members::Level::QuantumSafe,
    );
    u
}

/// A classic (X25519) encryption public key nobody here holds.
fn classic_key() -> String {
    gunnar_crypt::wrap::age::x25519::Identity::generate()
        .to_public()
        .to_string()
}

/// [`alice_starts`] for a quantum-safe repository, said explicitly: `crypt init --level quantum-safe`.
fn alice_starts_quantum_safe(h: &Harness, alice: &User) {
    let w = alice.work();
    std::fs::create_dir_all(&w).unwrap();
    alice
        .git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    alice
        .git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote add");
    alice
        .gunnar(
            &w,
            &[
                "crypt",
                "init",
                "--member",
                alice.name,
                "--level",
                "quantum-safe",
            ],
        )
        .expect_ok("crypt init --level quantum-safe");
    std::fs::write(w.join("secret.txt"), SECRET).unwrap();
    alice.git(&w, &["add", "secret.txt"]).expect_ok("git add");
    alice
        .git(&w, &["commit", "-q", "-m", "the secret"])
        .expect_ok("git commit");
    alice
        .git(&w, &["push", "-q", "-u", "origin", "main"])
        .expect_ok("the hybrid ring is accepted");
}

fn clone(h: &Harness, u: &User) -> PathBuf {
    let w = u.work();
    u.git(
        &u.home,
        &["clone", "-q", &h.url(NS, REPO), w.to_str().unwrap()],
    )
    .expect_ok("git clone");
    w
}

/// Every object id a commit's tree names, with its path.
fn tree_blobs(u: &User, w: &Path, rev: &str) -> Vec<(String, String)> {
    let out = u.git(w, &["ls-tree", "-r", rev]);
    out.expect_ok("ls-tree");
    out.stdout
        .lines()
        .map(|l| {
            let (meta, path) = l.split_once('\t').unwrap();
            (
                meta.split(' ').nth(2).unwrap().to_string(),
                path.to_string(),
            )
        })
        .collect()
}

/// The bytes the SERVER stores for an object.
fn stored(h: &Harness, oid: &str) -> Vec<u8> {
    let entry = h
        .stores
        .catalog()
        .get(&RepoRef::new(NS, REPO).unwrap())
        .unwrap()
        .expect("the repository exists");
    let store = h.stores.repo_store(&entry).unwrap();
    let id = gix_hash::ObjectId::from_hex(oid.as_bytes()).unwrap();
    gunnar_store::git_storage_trait::GitServe::read(store.serve(), id.as_bytes())
        .expect("ask the store")
        .expect("the server holds the object")
        .1
}

fn contains(hay: &[u8], needle: &str) -> bool {
    hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}

#[test]
fn two_members_share_a_repo_and_a_third_with_access_sees_only_ciphertext() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Write));
    let carol = user(&h, "carol", Some(Level::Read));

    alice_starts(&h, &alice, &[&bob]);
    // In alice's own clone the file is plaintext: the filter is transparent.
    assert_eq!(read(alice.work().join("secret.txt")), SECRET.as_bytes());

    // ── what the SERVER holds: envelopes, an age key file, public keys ──
    for (oid, path) in tree_blobs(&alice, &alice.work(), "HEAD") {
        let bytes = stored(&h, &oid);
        assert!(
            !contains(&bytes, "launch code"),
            "{path} is plaintext on the server"
        );
        match path.as_str() {
            ".gitattributes" => assert!(contains(&bytes, "filter=gunnar-crypt")),
            ".gunnar-crypt/members" => {
                assert!(
                    contains(&bytes, &format!("alice {}", alice.public))
                        && contains(&bytes, &format!("bob {}", bob.public)),
                    "the members file names user encryption keys"
                )
            }
            ".gunnar-crypt/keys" => assert!(
                bytes.starts_with(b"-----BEGIN AGE ENCRYPTED FILE-----"),
                "the key file is an age file"
            ),
            _ => assert!(
                envelope::is_envelope(&bytes),
                "{path} must be stored as a gunnar-crypt envelope"
            ),
        }
    }

    // ── bob: a member. Clone shows ciphertext until he unlocks. ──
    let bw = clone(&h, &bob);
    assert!(envelope::is_envelope(&read(bw.join("secret.txt"))));
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob unlocks with ~/.ssh/id_ed25519");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
    let status = bob.git(&bw, &["status", "--porcelain"]);
    status.expect_ok("status");
    assert_eq!(status.stdout.trim(), "", "an unlocked clone is clean");

    // bob writes; alice reads it back through the server.
    std::fs::write(bw.join("plan.md"), "# plan\nbob's part\n").unwrap();
    bob.git(&bw, &["add", "plan.md"]).expect_ok("add");
    bob.git(&bw, &["commit", "-q", "-m", "bob's plan"])
        .expect_ok("commit");
    bob.git(&bw, &["push", "-q"]).expect_ok("bob's push");
    alice
        .git(&alice.work(), &["pull", "-q", "--ff-only"])
        .expect_ok("alice pulls");
    assert_eq!(read(alice.work().join("plan.md")), b"# plan\nbob's part\n");
    let plan_oid = tree_blobs(&bob, &bw, "HEAD")
        .into_iter()
        .find(|(_, p)| p == "plan.md")
        .unwrap()
        .0;
    assert!(envelope::is_envelope(&stored(&h, &plan_oid)));

    // ── carol: may clone (Read grant), is not a member. ──
    let cw = clone(&h, &carol);
    let theirs = read(cw.join("secret.txt"));
    assert!(envelope::is_envelope(&theirs), "carol sees ciphertext");
    assert!(!contains(&theirs, "launch code"));
    let refused = carol.gunnar(&cw, &["crypt", "unlock"]);
    refused.expect_failure("carol is not a member");
    assert!(
        refused
            .stderr
            .contains("you are not a member of this encrypted repository"),
        "the refusal says why: {}",
        refused.stderr
    );
    assert!(envelope::is_envelope(&read(cw.join("secret.txt"))));
}

#[test]
fn adding_a_member_needs_no_presence_and_lets_them_read_everything() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let dave = user(&h, "dave", Some(Level::Read));
    alice_starts(&h, &alice, &[]);

    let dw = clone(&h, &dave);
    dave.gunnar(&dw, &["crypt", "unlock"])
        .expect_failure("not a member yet");

    // alice wraps the key ring to dave's PUBLIC key; dave does nothing.
    let key_file = alice.home.join("dave.pub");
    std::fs::write(&key_file, &dave.public).unwrap();
    alice
        .gunnar(
            &alice.work(),
            &[
                "crypt",
                "add-member",
                "dave",
                "--key",
                key_file.to_str().unwrap(),
            ],
        )
        .expect_ok("add dave");
    alice.git(&alice.work(), &["push", "-q"]).expect_ok("push");

    dave.git(&dw, &["pull", "-q", "--ff-only"])
        .expect_ok("dave pulls");
    dave.gunnar(&dw, &["crypt", "unlock"])
        .expect_ok("dave unlocks");
    assert_eq!(read(dw.join("secret.txt")), SECRET.as_bytes());
}

#[test]
fn a_security_key_ssh_key_cannot_be_a_member_and_is_told_why() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    alice_starts(&h, &alice, &[]);
    let sk = alice.home.join("yubi.pub");
    std::fs::write(
        &sk,
        "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEX/dQ0v4127bEo8eeG1EV0ApO2lWbSnN6RWusn/NjqIAAAABHNzaDo= erin@yubikey\n",
    )
    .unwrap();
    let out = alice.gunnar(
        &alice.work(),
        &["crypt", "add-member", "erin", "--key", sk.to_str().unwrap()],
    );
    out.expect_failure("an sk- key is refused");
    assert!(
        out.stderr.contains("cannot be a member key") && out.stderr.contains("encryption key"),
        "{}",
        out.stderr
    );
}

#[test]
fn removing_a_member_rotates_and_closes_new_content_to_them() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Write));
    alice_starts(&h, &alice, &[&bob]);

    let bw = clone(&h, &bob);
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob unlocks");

    let out = alice.gunnar(&alice.work(), &["crypt", "remove-member", "bob"]);
    out.expect_ok("remove bob");
    // Honest about what removal cannot do.
    assert!(out.stdout.contains("can still read"), "{}", out.stdout);
    assert!(out.stdout.contains("epoch 2"), "{}", out.stdout);
    let members = String::from_utf8(read(alice.work().join(".gunnar-crypt/members"))).unwrap();
    assert!(!members.contains("bob "), "{members}");

    std::fs::write(alice.work().join("after.txt"), "written after bob left\n").unwrap();
    alice
        .git(&alice.work(), &["add", "after.txt"])
        .expect_ok("add");
    alice
        .git(&alice.work(), &["commit", "-q", "-m", "after"])
        .expect_ok("commit");
    alice.git(&alice.work(), &["push", "-q"]).expect_ok("push");

    let after_oid = tree_blobs(&alice, &alice.work(), "HEAD")
        .into_iter()
        .find(|(_, p)| p == "after.txt")
        .unwrap()
        .0;
    assert_eq!(envelope::epoch_of(&stored(&h, &after_oid)), Some(2));

    // bob pulls (his ACL grant is still there in this test, to show the key
    // alone keeps him out). The pull itself works; the new file stays sealed.
    let pull = bob.git(&bw, &["pull", "-q", "--ff-only"]);
    pull.expect_ok("bob pulls");
    assert!(envelope::is_envelope(&read(bw.join("after.txt"))));
    // What he had, he keeps: nothing can take that back.
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());
    let refused = bob.gunnar(&bw, &["crypt", "unlock"]);
    refused.expect_failure("bob is no longer a recipient");
    assert!(envelope::is_envelope(&read(bw.join("after.txt"))));

    // alice (still a member) reads both, old and new.
    assert_eq!(
        read(alice.work().join("after.txt")),
        b"written after bob left\n"
    );
}

#[test]
fn rotation_keeps_old_content_readable_and_git_status_quiet() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    alice_starts(&h, &alice, &[]);
    let w = alice.work();

    alice.gunnar(&w, &["crypt", "rotate"]).expect_ok("rotate");
    // Touch without changing: git re-runs the clean filter, which must hand
    // back the SAME blob (sealed under epoch 1), not a new epoch-2 one.
    std::fs::write(w.join("secret.txt"), SECRET).unwrap();
    let status = alice.git(&w, &["status", "--porcelain"]);
    status.expect_ok("status");
    assert_eq!(
        status.stdout.trim(),
        "",
        "an unchanged file stays unchanged after a rotation"
    );

    std::fs::write(w.join("secret.txt"), "the launch code is now 2222\n").unwrap();
    alice
        .git(&w, &["commit", "-q", "-am", "new code"])
        .expect_ok("commit");
    let (new_oid, _) = tree_blobs(&alice, &w, "HEAD")
        .into_iter()
        .find(|(_, p)| p == "secret.txt")
        .unwrap();
    let raw = Command::new("git")
        .current_dir(&w)
        .args(["cat-file", "blob", &new_oid])
        .output()
        .unwrap()
        .stdout;
    assert_eq!(
        envelope::epoch_of(&raw),
        Some(2),
        "new content is sealed under the new epoch"
    );

    // The old version still opens: the diff (the same code the browser runs)
    // shows both sides in plaintext.
    let diff = alice.gunnar(&w, &["crypt", "diff", "HEAD~1", "HEAD"]);
    diff.expect_ok("gunnar crypt diff");
    assert!(
        diff.stdout.contains("-the launch code is 0000-1111"),
        "{}",
        diff.stdout
    );
    assert!(
        diff.stdout.contains("+the launch code is now 2222"),
        "{}",
        diff.stdout
    );
    // …and git's own diff, through the textconv driver.
    let gd = alice.git(&w, &["diff", "HEAD~1", "HEAD", "--", "secret.txt"]);
    gd.expect_ok("git diff");
    assert!(
        gd.stdout.contains("+the launch code is now 2222"),
        "{}",
        gd.stdout
    );
}

#[test]
fn concurrent_edits_merge_on_the_client_in_plaintext() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Write));
    let w = alice.work();
    std::fs::create_dir_all(&w).unwrap();
    alice_starts(&h, &alice, &[&bob]);
    let lines: String = (1..=12).map(|i| format!("line {i}\n")).collect();
    std::fs::write(w.join("list.txt"), &lines).unwrap();
    alice.git(&w, &["add", "list.txt"]).expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "list"])
        .expect_ok("commit");
    alice.git(&w, &["push", "-q"]).expect_ok("push");

    let bw = clone(&h, &bob);
    bob.gunnar(&bw, &["crypt", "unlock"]).expect_ok("unlock");
    std::fs::write(
        bw.join("list.txt"),
        lines.replace("line 2\n", "line 2 (bob)\n"),
    )
    .unwrap();
    bob.git(&bw, &["commit", "-q", "-am", "bob"])
        .expect_ok("commit");
    bob.git(&bw, &["push", "-q"]).expect_ok("push");

    std::fs::write(
        w.join("list.txt"),
        lines.replace("line 11\n", "line 11 (alice)\n"),
    )
    .unwrap();
    alice
        .git(&w, &["commit", "-q", "-am", "alice"])
        .expect_ok("commit");
    alice
        .git(&w, &["pull", "-q", "--no-rebase", "--no-edit"])
        .expect_ok("the merge driver merges the decrypted texts");
    let merged = String::from_utf8(read(w.join("list.txt"))).unwrap();
    assert!(
        merged.contains("line 2 (bob)") && merged.contains("line 11 (alice)"),
        "{merged}"
    );
    alice.git(&w, &["push", "-q"]).expect_ok("push the merge");
    let (oid, _) = tree_blobs(&alice, &w, "HEAD")
        .into_iter()
        .find(|(_, p)| p == "list.txt")
        .unwrap();
    assert!(
        envelope::is_envelope(&stored(&h, &oid)),
        "the merge result is sealed too"
    );
}

#[test]
fn a_substituted_key_ring_is_refused_by_a_member_who_holds_the_real_one() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Write));
    alice_starts(&h, &alice, &[&bob]);
    let bw = clone(&h, &bob);
    bob.gunnar(&bw, &["crypt", "unlock"]).expect_ok("unlock");

    // Someone who never held the keys (an operator, say) makes a FRESH ring
    // and wraps it to the members' public keys, which are no secret. Anyone
    // who accepted it would seal new content under a key that someone knows.
    let members = gunnar_crypt::members::Members::parse(
        &String::from_utf8(read(alice.work().join(".gunnar-crypt/members"))).unwrap(),
    )
    .unwrap();
    let forged =
        gunnar_crypt::wrap::wrap(&gunnar_crypt::keyring::Keyring::generate(), &members).unwrap();
    std::fs::write(alice.work().join(".gunnar-crypt/keys"), forged).unwrap();
    alice
        .git(&alice.work(), &["commit", "-q", "-am", "nothing to see"])
        .expect_ok("commit");
    alice.git(&alice.work(), &["push", "-q"]).expect_ok("push");

    bob.git(&bw, &["pull", "-q", "--ff-only"]).expect_ok("pull");
    let out = bob.gunnar(&bw, &["crypt", "unlock"]);
    out.expect_failure("the substituted ring is refused");
    assert!(out.stderr.contains("refused"), "{}", out.stderr);
    // …and bob's clone keeps sealing under the REAL ring.
    std::fs::write(bw.join("new.txt"), "still private\n").unwrap();
    bob.git(&bw, &["add", "new.txt"]).expect_ok("add");
    let staged = bob.git(&bw, &["ls-files", "-s", "new.txt"]);
    let oid = staged.stdout.split(' ').nth(1).unwrap().to_string();
    let raw = Command::new("git")
        .current_dir(&bw)
        .args(["cat-file", "blob", &oid])
        .output()
        .unwrap()
        .stdout;
    assert_eq!(envelope::epoch_of(&raw), Some(1));
}

#[test]
fn a_locked_clone_cannot_commit_plaintext_through_the_filter() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    alice_starts(&h, &alice, &[]);
    let w = alice.work();
    alice.gunnar(&w, &["crypt", "lock"]).expect_ok("lock");
    assert!(
        envelope::is_envelope(&read(w.join("secret.txt"))),
        "locked = ciphertext on disk"
    );
    assert!(
        !w.join(".git/gunnar-crypt/keyring").exists(),
        "the local key ring is gone"
    );
    alice
        .gunnar(&w, &["crypt", "unlock"])
        .expect_ok("unlock again");
    assert_eq!(read(w.join("secret.txt")), SECRET.as_bytes());
    let status = alice.gunnar(&w, &["crypt", "status"]);
    status.expect_ok("status");
    assert!(status.stdout.contains("unlocked"), "{}", status.stdout);
    assert!(status.stdout.contains("alice"), "{}", status.stdout);
}

// ── Level 2, first slice: the server's "encrypted" flag ─────────────────────

fn set_encrypted(h: &Harness, on: bool) {
    h.stores
        .catalog()
        .set_encrypted(&RepoRef::new(NS, REPO).unwrap(), on)
        .expect("flag the repository");
}

fn remote_main(u: &User, w: &Path) -> String {
    let out = u.git(w, &["ls-remote", "origin", "refs/heads/main"]);
    out.expect_ok("ls-remote");
    out.stdout
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_string()
}

#[test]
fn an_encrypted_repository_refuses_plaintext_and_takes_ciphertext() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    set_encrypted(&h, true);

    // A clone that never ran `gunnar crypt`: plain git, plaintext. Refused,
    // and the refusal says what to do.
    let plain = alice.home.join("plain");
    std::fs::create_dir_all(&plain).unwrap();
    alice
        .git(&plain, &["init", "-q", "-b", "main"])
        .expect_ok("init");
    std::fs::write(plain.join("notes.txt"), "the merger closes friday\n").unwrap();
    alice.git(&plain, &["add", "notes.txt"]).expect_ok("add");
    alice
        .git(&plain, &["commit", "-q", "-m", "notes"])
        .expect_ok("commit");
    alice
        .git(&plain, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote");
    let refused = alice.git(&plain, &["push", "origin", "main"]);
    refused.expect_failure("plaintext is refused by an encrypted repository");
    assert!(
        refused.stderr.contains("notes.txt is plaintext")
            && refused.stderr.contains("gunnar crypt unlock"),
        "the refusal names the file and the fix: {}",
        refused.stderr
    );
    assert_eq!(
        remote_main(&alice, &plain),
        "",
        "a refused push moves no reference"
    );

    // The proper flow: sealed by the filter, accepted.
    alice_starts(&h, &alice, &[]);
    let w = alice.work();
    let head = alice
        .git(&w, &["rev-parse", "HEAD"])
        .stdout
        .trim()
        .to_string();
    assert_eq!(remote_main(&alice, &w), head);

    // A file slipped past the filter (written straight into the index).
    std::fs::write(w.join("leak.txt"), "plaintext that skipped the filter\n").unwrap();
    let oid = alice.git(&w, &["hash-object", "-w", "--no-filters", "leak.txt"]);
    oid.expect_ok("hash-object");
    alice
        .git(
            &w,
            &[
                "update-index",
                "--add",
                "--cacheinfo",
                &format!("100644,{},leak.txt", oid.stdout.trim()),
            ],
        )
        .expect_ok("update-index");
    alice
        .git(&w, &["commit", "-q", "-m", "leak"])
        .expect_ok("commit");
    let refused = alice.git(&w, &["push", "origin", "main"]);
    refused.expect_failure("a blob that skipped the filter is refused");
    assert!(
        refused.stderr.contains("leak.txt is plaintext"),
        "{}",
        refused.stderr
    );
    assert_eq!(remote_main(&alice, &w), head);
    alice
        .git(&w, &["reset", "-q", "--hard", "HEAD~1"])
        .expect_ok("reset");
    let _ = std::fs::remove_file(w.join("leak.txt"));

    // A symbolic link: git stores its target unencrypted. Refused.
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink("secret.txt", w.join("link")).unwrap();
        alice.git(&w, &["add", "link"]).expect_ok("add the link");
        alice
            .git(&w, &["commit", "-q", "-m", "link"])
            .expect_ok("commit");
        let refused = alice.git(&w, &["push", "origin", "main"]);
        refused.expect_failure("a symlink is refused");
        assert!(
            refused.stderr.contains("symbolic link"),
            "{}",
            refused.stderr
        );
        alice
            .git(&w, &["reset", "-q", "--hard", "HEAD~1"])
            .expect_ok("reset");
    }

    // New sealed content is still accepted.
    std::fs::write(w.join("more.txt"), "sealed as usual\n").unwrap();
    alice.git(&w, &["add", "more.txt"]).expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "more"])
        .expect_ok("commit");
    alice
        .git(&w, &["push", "-q", "origin", "main"])
        .expect_ok("ciphertext is accepted");
}

// ── add-member from the keys gunnar already holds (Repos.RecipientKeys) ─────

impl User {
    fn gunnar_cli(&self, cwd: &Path, args: &[&str]) -> Output {
        let mut c = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
        c.current_dir(cwd).args(args);
        self.env(&mut c);
        output(c, args, "gunnar-cli")
    }
}

#[test]
fn a_member_is_added_from_the_ssh_keys_gunnar_already_holds() {
    assert!(tools_present());
    let h = Harness::start_with_grpc(NS, REPO, HashKind::Sha1);
    let at = h.grpc_addr.expect("the control plane is up").to_string();
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Read));
    let frank = user(&h, "frank", None);
    alice_starts(&h, &alice, &[]);
    let w = alice.work();
    let key = alice.key.to_str().unwrap();

    // bob: nothing asked of him; his key comes from gunnar.
    let added = alice.gunnar_cli(
        &w,
        &[
            "crypt",
            "add-member",
            "bob",
            "--from-server",
            "--fingerprint",
            &gunnar_crypt::door::fingerprint(&bob.public),
            "--at",
            &at,
            "-i",
            key,
        ],
    );
    added.expect_ok("add bob from the server's keys");
    // The key gunnar handed out is named by its fingerprint (RecipientKey.fingerprint, wiring
    // tracer B 2026-10-08), so the adder can check it with bob before pushing.
    let bob_fp = gunnar_core::auth::fingerprint_of_blob(bob.public.as_bytes()).to_string();
    assert!(
        added.stdout.contains(&bob_fp),
        "add-member names the key it wrapped to: {}",
        added.stdout
    );
    let members = String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap();
    let bob_body = bob.public.as_str();
    assert!(
        members.contains(bob_body),
        "bob's stored key is now a recipient: {members}"
    );
    alice.git(&w, &["push", "-q"]).expect_ok("push");
    let bw = clone(&h, &bob);
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob unlocks with the key gunnar held");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());

    // frank has an account and keys but no access to the repository: gunnar
    // will not hand his keys out for it.
    let refused = alice.gunnar_cli(
        &w,
        &[
            "crypt",
            "add-member",
            "frank",
            "--from-server",
            "--at",
            &at,
            "-i",
            key,
        ],
    );
    refused.expect_failure("no keys for someone who cannot read the repository");
    assert!(refused.stderr.contains("cannot read"), "{}", refused.stderr);
    let _ = frank;

    // erin is a reader with no encryption key.
    let auth = h.stores.auth();
    auth.add_principal(Principal::new("erin", "erin", PrincipalKind::User).unwrap())
        .unwrap();
    auth.grant(Grant::repo(
        "erin",
        RepoRef::new(NS, REPO).unwrap(),
        Level::Read,
    ))
    .unwrap();
    // erin has no encryption key yet: told, in words, how to make one.
    let out = alice.gunnar_cli(
        &w,
        &[
            "crypt",
            "add-member",
            "erin",
            "--from-server",
            "--at",
            &at,
            "-i",
            key,
        ],
    );
    out.expect_failure("a member without an encryption key cannot be a recipient");
    assert!(
        out.stderr.contains("no encryption key for erin")
            && out.stderr.contains("gunnar crypt key new"),
        "{}",
        out.stderr
    );
}

// ── add-member from the server over the FORGE's SSH door (no control plane) ──
//
// An installed estate exposes its forge's SSH door (tcp/2222 through the front)
// and its console, never the control plane: gRPC is bound to the WireGuard
// overlay. So `--from-server` with no `--at` asks the forge it pushes to —
// the origin's own SSH door, signed in with the member's own key, the same
// `ssh` git runs — and gunnar answers with the SAME rule `Repos.RecipientKeys`
// keeps: the caller reads the repository, and so does the person named.

#[test]
fn a_member_is_added_from_the_forges_ssh_door_with_no_control_plane() {
    assert!(tools_present());
    // `Harness::start`: SSH only, no control plane bound at all.
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let bob = user(&h, "bob", Some(Level::Read));
    let frank = user(&h, "frank", None);
    alice_starts(&h, &alice, &[]);
    let w = alice.work();

    // The plain `gunnar` binary (no control feature): bob's key comes from
    // the forge alice pushes to.
    // ★ RA4-1: the server's word for bob's key is not taken alone: its
    // fingerprint is shown, and confirming it is asked for.
    let unconfirmed = alice.gunnar(&w, &["crypt", "add-member", "bob", "--from-server"]);
    unconfirmed.expect_failure("an unconfirmed key from the server is not wrapped to");
    let fp = gunnar_crypt::door::fingerprint(&bob.public);
    assert!(
        unconfirmed.stderr.contains(&format!("--fingerprint {fp}")),
        "{}",
        unconfirmed.stderr
    );
    let wrong = alice.gunnar(
        &w,
        &[
            "crypt",
            "add-member",
            "bob",
            "--from-server",
            "--fingerprint",
            "SHA256:not-bobs",
        ],
    );
    wrong.expect_failure("a fingerprint that is not the key's is refused");
    alice
        .gunnar(
            &w,
            &[
                "crypt",
                "add-member",
                "bob",
                "--from-server",
                "--fingerprint",
                &fp,
            ],
        )
        .expect_ok("add bob from the keys the forge holds, over its SSH door");
    let members = String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap();
    let bob_body = bob.public.as_str();
    assert!(
        members.contains(bob_body),
        "bob's stored key is now a recipient: {members}"
    );
    alice.git(&w, &["push", "-q"]).expect_ok("push");
    let bw = clone(&h, &bob);
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob unlocks with the key the forge handed out");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());

    // frank cannot read the repository: no keys of his for it, in gunnar's words.
    let refused = alice.gunnar(&w, &["crypt", "add-member", "frank", "--from-server"]);
    refused.expect_failure("no keys for someone who cannot read the repository");
    assert!(
        refused.stderr.contains("frank cannot read team/vault"),
        "{}",
        refused.stderr
    );

    // frank asking for bob's keys: he cannot read the repository himself, and
    // is told what a stranger is told — nothing about whether it exists.
    let fw = frank.home.join("probe");
    std::fs::create_dir_all(&fw).unwrap();
    frank
        .git(&fw, &["init", "-q", "-b", "main"])
        .expect_ok("init");
    frank
        .git(&fw, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote add");
    frank
        .gunnar(&fw, &["crypt", "init", "--member", "frank", "--no-commit"])
        .expect_ok("frank's own ring");
    let hidden = frank.gunnar(&fw, &["crypt", "add-member", "bob", "--from-server"]);
    hidden.expect_failure("a caller who cannot read the repository gets no keys");
    assert!(
        hidden
            .stderr
            .contains("repository not found, or you do not have access"),
        "{}",
        hidden.stderr
    );
    assert!(!hidden.stderr.contains(bob_body), "{}", hidden.stderr);
}

// ── a refused push leaves NOTHING: not on main's disk, not for the twin ─────
//
// crypt-level2 item 4, pulled forward (2026-10-06, cov-06 measured it on the
// estate: a refused plaintext push left its pack on main until gc AND framed
// it into the transaction log the twin replays — level 1's promise broken).
// The gate decides on the pushed pack BEFORE it is stored or framed: a
// refusal stores no object and appends no `Pack` frame.

fn pack_frames(h: &Harness) -> usize {
    let mut n = 0;
    h.stores
        .txlog()
        .scan_meta(|_, kind, _| {
            if gunnar_core::txlog::Kind::from_u16(kind) == Some(gunnar_core::txlog::Kind::Pack) {
                n += 1;
            }
        })
        .expect("scan the transaction log");
    n
}

fn held(h: &Harness, oid: &str) -> bool {
    let entry = h
        .stores
        .catalog()
        .get(&RepoRef::new(NS, REPO).unwrap())
        .unwrap()
        .expect("the repository exists");
    let store = h.stores.repo_store(&entry).unwrap();
    let id = gix_hash::ObjectId::from_hex(oid.as_bytes()).unwrap();
    gunnar_store::git_storage_trait::GitServe::read(store.serve(), id.as_bytes())
        .expect("ask the store")
        .is_some()
}

#[test]
fn a_refused_plaintext_push_is_never_stored_and_never_framed_for_the_twin() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    set_encrypted(&h, true);
    alice_starts(&h, &alice, &[]);
    let w = alice.work();
    let frames_before = pack_frames(&h);
    assert!(
        frames_before >= 1,
        "the accepted sealed push is a Pack frame"
    );

    std::fs::write(
        w.join("leak.txt"),
        "plaintext that must never reach the server\n",
    )
    .unwrap();
    let oid = alice.git(&w, &["hash-object", "-w", "--no-filters", "leak.txt"]);
    oid.expect_ok("hash-object");
    let oid = oid.stdout.trim().to_string();
    alice
        .git(
            &w,
            &[
                "update-index",
                "--add",
                "--cacheinfo",
                &format!("100644,{oid},leak.txt"),
            ],
        )
        .expect_ok("update-index");
    alice
        .git(&w, &["commit", "-q", "-m", "leak"])
        .expect_ok("commit");
    let refused = alice.git(&w, &["push", "origin", "main"]);
    refused.expect_failure("plaintext is refused");
    assert!(
        refused.stderr.contains("leak.txt is plaintext")
            && refused.stderr.contains("gunnar crypt unlock"),
        "the refusal still names the file and the fix: {}",
        refused.stderr
    );
    assert!(
        !held(&h, &oid),
        "the refused plaintext blob {oid} is in the server's store"
    );
    assert_eq!(
        pack_frames(&h),
        frames_before,
        "the refused pack was framed for the twin"
    );

    // Sealed content is still taken, and framed.
    alice
        .git(&w, &["reset", "-q", "--hard", "HEAD~1"])
        .expect_ok("reset");
    let _ = std::fs::remove_file(w.join("leak.txt"));
    std::fs::write(w.join("more.txt"), "sealed as usual\n").unwrap();
    alice.git(&w, &["add", "more.txt"]).expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "more"])
        .expect_ok("commit");
    alice
        .git(&w, &["push", "-q", "origin", "main"])
        .expect_ok("ciphertext is accepted");
    assert_eq!(
        pack_frames(&h),
        frames_before + 1,
        "the accepted pack is one frame"
    );
}

// ── the quantum-safe level (crypt-pq) ────────────────────────────────────────
//
// A member's quantum-safe key is a separate age1pq1… key (age's hybrid
// ML-KEM-768 + X25519) made by `gunnar crypt keygen` on their own machine. A
// quantum-safe repository's key ring is wrapped to those keys ONLY; the gate
// refuses anything classical in it, in quarantine.

#[test]
fn keygen_makes_a_private_0600_key_here_and_never_overwrites_it() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", None);
    let out = alice.gunnar(&alice.home, &["crypt", "keygen"]);
    out.expect_ok("keygen");
    assert!(
        out.stdout.contains("cannot be quantum-safe recipients")
            && out.stdout.contains("never leaves this machine"),
        "keygen says what the key is and why the SSH key is not it: {}",
        out.stdout
    );
    let key = alice.pq_key();
    let text = String::from_utf8(read(&key)).unwrap();
    assert!(
        text.lines().any(|l| l.starts_with("AGE-SECRET-KEY-PQ-1")),
        "an age identity file"
    );
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600, "the private key is the owner's only");
    }
    let public = std::fs::read_to_string(key.with_extension("key.pub")).unwrap();
    assert!(public.starts_with("age1pq1"), "{public}");
    assert!(
        !out.stdout.contains("AGE-SECRET-KEY"),
        "the private key is never printed"
    );
    let again = alice.gunnar(&alice.home, &["crypt", "keygen"]);
    again.expect_failure("an existing key is never overwritten silently");
    assert_eq!(String::from_utf8(read(&key)).unwrap(), text);
}

/// **keygen sends a member to the console, never to a control address**
/// (wiring tracer B 2026-10-08): on an installed estate the control plane sits
/// on the estate's private overlay and no member reaches it, so
/// `gunnar-cli control … --at <control address>` was a dead end printed to
/// every member.
#[test]
fn keygen_points_a_member_to_the_consoles_keys_page_never_to_a_control_address() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", None);
    let out = alice.gunnar(&alice.home, &["crypt", "keygen"]);
    out.expect_ok("keygen");
    assert!(
        out.stdout.contains("keys page in the console"),
        "{}",
        out.stdout
    );
    assert!(
        out.stdout.contains("gunnar crypt key add-pq"),
        "keygen's file is an unlock method (CRYPT-PQ-DEFAULT): {}",
        out.stdout
    );
    for dead_end in ["--at <control address>", "gunnar-cli control"] {
        assert!(
            !out.stdout.contains(dead_end),
            "{dead_end:?} is printed to a member: {}",
            out.stdout
        );
    }
}

/// ★ **`crypt init` names its first member by the account the forge says,
/// not by `$USER`** (wiring tracer B 2026-10-08): `add-member --from-server`
/// looks every member up by that name, and a login on the member's own machine
/// that differs from the account name broke it later. Without a forge to ask,
/// `--member` is required.
#[test]
fn crypt_init_names_its_member_by_the_forge_account_not_by_user() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    let w = alice.work();
    std::fs::create_dir_all(&w).unwrap();
    alice
        .git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    let mut bare = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
    bare.current_dir(&w).args(["crypt", "init", "--no-commit"]);
    alice.env(&mut bare);
    bare.env("USER", "not-the-account-name");
    let bare = output(bare, &["crypt", "init", "--no-commit"], "gunnar");
    bare.expect_failure("no forge to ask and no --member: $USER is never the guess");
    assert!(
        bare.stderr.contains("--member"),
        "it says what to pass: {}",
        bare.stderr
    );
    alice
        .git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote add");
    let mut init = Command::new(env!("CARGO_BIN_EXE_gunnar-cli"));
    init.current_dir(&w).args(["crypt", "init", "--no-commit"]);
    alice.env(&mut init);
    init.env("USER", "not-the-account-name");
    output(init, &["crypt", "init", "--no-commit"], "gunnar")
        .expect_ok("crypt init asks the forge");
    let members = String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap();
    assert!(
        members.lines().any(|l| l.starts_with("alice ")),
        "the forge's account name: {members}"
    );
    assert!(!members.contains("not-the-account-name"), "{members}");
}

#[test]
fn a_quantum_safe_repository_refuses_a_classical_ring_before_storing_it() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = quantum_safe_user(&h, "alice");
    set_encrypted(&h, true);
    alice_starts_quantum_safe(&h, &alice);
    let w = alice.work();
    let frames = pack_frames(&h);

    // A ring wrapped classically again (to an X25519 key) beside a members
    // file that still says quantum-safe: the weak link the level forbids.
    let ring = gunnar_crypt::keyring::Keyring::parse(
        &String::from_utf8(read(w.join(".git/gunnar-crypt/keyring"))).unwrap(),
    )
    .unwrap();
    let mut classic = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
    classic.add("alice", &classic_key()).unwrap();
    let classical = gunnar_crypt::wrap::wrap(&ring, &classic).unwrap();
    std::fs::write(w.join(".gunnar-crypt/keys"), &classical).unwrap();
    alice
        .git(&w, &["add", ".gunnar-crypt/keys"])
        .expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "classical ring"])
        .expect_ok("commit");
    let keys_blob = alice
        .git(&w, &["rev-parse", "HEAD:.gunnar-crypt/keys"])
        .stdout
        .trim()
        .to_string();
    let refused = alice.git(&w, &["push", "origin", "main"]);
    refused.expect_failure("a classical ring is refused in a quantum-safe repository");
    assert!(
        refused.stderr.contains("X25519 stanza") && refused.stderr.contains("quantum-safe"),
        "{}",
        refused.stderr
    );
    assert!(
        !held(&h, &keys_blob),
        "the refused ring is in the server's store"
    );
    assert_eq!(
        pack_frames(&h),
        frames,
        "the refused pack was framed for the twin"
    );

    // Mixed: the hybrid stanza AND the classical one (a member who holds the
    // file key can write that header). Refused the same way.
    alice
        .git(&w, &["reset", "-q", "--hard", "HEAD~1"])
        .expect_ok("reset");
    let mut both = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
    both.add("alice", &classic_key()).unwrap();
    let hybrid = String::from_utf8(read(w.join(".gunnar-crypt/keys"))).unwrap();
    let mixed = splice(&hybrid, &gunnar_crypt::wrap::wrap(&ring, &both).unwrap());
    std::fs::write(w.join(".gunnar-crypt/keys"), &mixed).unwrap();
    alice
        .git(&w, &["add", ".gunnar-crypt/keys"])
        .expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "mixed ring"])
        .expect_ok("commit");
    let refused = alice.git(&w, &["push", "origin", "main"]);
    refused.expect_failure("a mixed ring is refused in a quantum-safe repository");
    assert!(
        refused.stderr.contains("quantum-safe"),
        "{}",
        refused.stderr
    );
    assert_eq!(pack_frames(&h), frames);
}

/// ★ **A quantum-safe repository does not go back to classical** (wiring
/// tracer B 2026-10-08). The gate read the level from the PUSHED members file
/// only, so a writer who dropped `level quantum-safe` from it and wrapped the
/// ring to an SSH key again was accepted — every later epoch exposed to a
/// harvested ring. The level only goes up: the branch's stored members file
/// says what it was.
#[test]
fn a_quantum_safe_repository_refuses_going_back_to_classical() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = quantum_safe_user(&h, "alice");
    set_encrypted(&h, true);
    alice_starts_quantum_safe(&h, &alice);
    let w = alice.work();
    let tip = remote_main(&alice, &w);

    // The members file says classic again, and the ring matches it: each file
    // alone is consistent, so only the stored level can refuse it.
    let ring = gunnar_crypt::keyring::Keyring::parse(
        &String::from_utf8(read(w.join(".git/gunnar-crypt/keyring"))).unwrap(),
    )
    .unwrap();
    let mut classic = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
    classic.add("alice", &classic_key()).unwrap();
    std::fs::write(w.join(".gunnar-crypt/members"), classic.to_text()).unwrap();
    std::fs::write(
        w.join(".gunnar-crypt/keys"),
        gunnar_crypt::wrap::wrap(&ring, &classic).unwrap(),
    )
    .unwrap();
    alice
        .git(&w, &["add", ".gunnar-crypt/members", ".gunnar-crypt/keys"])
        .expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "back to classical"])
        .expect_ok("commit");
    let refused = alice.git(&w, &["push", "origin", "main"]);
    refused.expect_failure("a quantum-safe repository does not go back to classical");
    assert!(
        refused.stderr.contains("quantum-safe"),
        "the refusal says why: {}",
        refused.stderr
    );
    assert_eq!(remote_main(&alice, &w), tip, "the branch did not move");
}

/// ★ **A new branch of a quantum-safe repository is quantum-safe too**
/// (wiring tracer B 2026-10-08): the level only goes up, and a NEW reference
/// has no old tip to compare with — so it is compared with the default
/// branch. A writer who pushes the classical ring as a fresh branch is
/// refused, and no such branch appears on the server.
#[test]
fn a_new_branch_of_a_quantum_safe_repository_cannot_be_classical() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = quantum_safe_user(&h, "alice");
    set_encrypted(&h, true);
    alice_starts_quantum_safe(&h, &alice);
    let w = alice.work();

    let ring = gunnar_crypt::keyring::Keyring::parse(
        &String::from_utf8(read(w.join(".git/gunnar-crypt/keyring"))).unwrap(),
    )
    .unwrap();
    let mut classic = gunnar_crypt::members::Members::at(gunnar_crypt::members::Level::Classic);
    classic.add("alice", &classic_key()).unwrap();
    alice
        .git(&w, &["checkout", "-q", "-b", "classic"])
        .expect_ok("a new branch");
    std::fs::write(w.join(".gunnar-crypt/members"), classic.to_text()).unwrap();
    std::fs::write(
        w.join(".gunnar-crypt/keys"),
        gunnar_crypt::wrap::wrap(&ring, &classic).unwrap(),
    )
    .unwrap();
    alice
        .git(&w, &["add", ".gunnar-crypt/members", ".gunnar-crypt/keys"])
        .expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "a classical branch"])
        .expect_ok("commit");
    let refused = alice.git(&w, &["push", "origin", "classic"]);
    refused.expect_failure("a new branch of a quantum-safe repository may not be classical");
    assert!(
        refused.stderr.contains("quantum-safe"),
        "the refusal says why: {}",
        refused.stderr
    );
    let listed = alice.git(&w, &["ls-remote", "origin", "refs/heads/classic"]);
    assert!(
        listed.stdout.trim().is_empty(),
        "the classical branch is on the server: {}",
        listed.stdout
    );
}

/// A binary age file whose header holds `a`'s stanzas, then `b`'s, and `a`'s
/// MAC: what a hand-edited ring looks like (the server cannot check the MAC).
fn splice(a: &str, b: &str) -> Vec<u8> {
    use std::io::Read as _;
    fn parts(file: &str) -> (Vec<u8>, Vec<u8>) {
        let mut bin = Vec::new();
        gunnar_crypt::wrap::age::armor::ArmoredReader::new(file.as_bytes())
            .read_to_end(&mut bin)
            .unwrap();
        let start = bin.iter().position(|b| *b == b'\n').unwrap() + 1;
        let mac = bin.windows(5).position(|w| w == b"\n--- ").unwrap() + 1;
        (bin[start..mac].to_vec(), bin[mac..].to_vec())
    }
    let (sa, rest) = parts(a);
    let (sb, _) = parts(b);
    [b"age-encryption.org/v1\n".to_vec(), sa, sb, rest].concat()
}

// ── CRYPT-YUBI (2026-10-26): one encryption key per person ─────────────────

/// ★ **SSH must still work** (owner, 2026-10-26 ~22:10): a person with ONLY an
/// SSH key does the whole flow in the CLI — `crypt init` makes their
/// encryption key (wrapped to the SSH key, kept at the forge), push, a fresh
/// clone, unlock, add a member who is not present, remove a member, rotate.
#[test]
fn a_person_with_only_an_ssh_key_does_the_whole_flow_in_the_cli() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
    let bob = User::without_encryption_key(&h, "bob", Some(Level::Write), NS, REPO);
    alice_starts(&h, &alice, &[]);
    let made = h
        .stores
        .encryption_keys()
        .get("alice")
        .unwrap()
        .expect("crypt init made alice's encryption key");
    assert_eq!(
        (made.methods.len(), made.methods[0].kind),
        (1, gunnar_core::encryption_key::MethodKind::Ssh)
    );
    let members = String::from_utf8(read(alice.work().join(".gunnar-crypt/members"))).unwrap();
    assert!(
        members.contains(&format!("alice {}", made.public_key)) && !members.contains("ssh-"),
        "{members}"
    );

    // A fresh clone of her own opens with her SSH key alone.
    let again = alice.home.join("again");
    alice
        .git(
            &alice.home,
            &["clone", "-q", &h.url(NS, REPO), again.to_str().unwrap()],
        )
        .expect_ok("clone");
    assert!(envelope::is_envelope(&read(again.join("secret.txt"))));
    alice
        .gunnar(&again, &["crypt", "unlock"])
        .expect_ok("alice unlocks a fresh clone with her SSH key");
    assert_eq!(read(again.join("secret.txt")), SECRET.as_bytes());

    // bob makes his key once; alice adds him while he is not there.
    let bw = clone(&h, &bob);
    bob.gunnar(&bw, &["crypt", "key", "new"])
        .expect_ok("bob makes his encryption key");
    let w = alice.work();
    let bob_public = h
        .stores
        .encryption_keys()
        .public_of("bob")
        .unwrap()
        .expect("bob's key");
    alice
        .gunnar(
            &w,
            &[
                "crypt",
                "add-member",
                "bob",
                "--from-server",
                "--fingerprint",
                &gunnar_crypt::door::fingerprint(&bob_public),
            ],
        )
        .expect_ok("add bob from the forge");
    alice.git(&w, &["push", "-q"]).expect_ok("push");
    bob.git(&bw, &["pull", "-q", "--ff-only"])
        .expect_ok("bob pulls");
    bob.gunnar(&bw, &["crypt", "unlock"])
        .expect_ok("bob unlocks with his SSH key");
    assert_eq!(read(bw.join("secret.txt")), SECRET.as_bytes());

    // Rotate, then remove bob: new content is closed to him.
    alice.gunnar(&w, &["crypt", "rotate"]).expect_ok("rotate");
    alice
        .gunnar(&w, &["crypt", "remove-member", "bob"])
        .expect_ok("remove bob");
    std::fs::write(w.join("later.txt"), "after bob\n").unwrap();
    alice.git(&w, &["add", "later.txt"]).expect_ok("add");
    alice
        .git(&w, &["commit", "-q", "-m", "later"])
        .expect_ok("commit");
    alice.git(&w, &["push", "-q"]).expect_ok("push");
    bob.git(&bw, &["pull", "-q", "--ff-only"])
        .expect_ok("bob pulls");
    let refused = bob.gunnar(&bw, &["crypt", "unlock"]);
    refused.expect_failure("bob is no member any more");
    assert!(
        refused.stderr.contains("you are not a member"),
        "{}",
        refused.stderr
    );
    assert!(
        envelope::is_envelope(&read(bw.join("later.txt"))),
        "new content stays sealed to bob"
    );
}

/// ★ **Adding or removing an unlock method touches no repository**: a printed
/// recovery code added and removed again, the ring and the members file
/// unchanged, the SSH key still opening it; the last method is never removed.
#[test]
fn adding_and_removing_an_unlock_method_touches_no_repository() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = user(&h, "alice", Some(Level::Write));
    alice_starts(&h, &alice, &[]);
    let w = alice.work();
    let before = (
        read(w.join(".gunnar-crypt/keys")),
        read(w.join(".gunnar-crypt/members")),
        remote_main(&alice, &w),
    );

    let added = alice.gunnar(&w, &["crypt", "key", "add-recovery-code"]);
    added.expect_ok("a recovery code");
    assert!(
        added.stdout.contains("No repository changed"),
        "{}",
        added.stdout
    );
    let key = h.stores.encryption_keys().get("alice").unwrap().unwrap();
    assert_eq!(key.methods.len(), 2);
    let status = alice.gunnar(&w, &["crypt", "key", "status"]);
    status.expect_ok("status");
    assert!(
        status.stdout.contains("recovery-code") && status.stdout.contains("ssh"),
        "{}",
        status.stdout
    );

    let code_id = key
        .methods
        .iter()
        .find(|m| m.kind == gunnar_core::encryption_key::MethodKind::RecoveryCode)
        .unwrap()
        .id
        .clone();
    alice
        .gunnar(&w, &["crypt", "key", "remove", &code_id])
        .expect_ok("remove the code");
    let ssh_id = h
        .stores
        .encryption_keys()
        .get("alice")
        .unwrap()
        .unwrap()
        .methods[0]
        .id
        .clone();
    let last = alice.gunnar(&w, &["crypt", "key", "remove", &ssh_id]);
    last.expect_failure("the last method stays");
    assert!(
        last.stderr.contains("last unlock method"),
        "{}",
        last.stderr
    );

    let after = (
        read(w.join(".gunnar-crypt/keys")),
        read(w.join(".gunnar-crypt/members")),
        remote_main(&alice, &w),
    );
    assert!(before == after, "no repository was touched");
    alice.gunnar(&w, &["crypt", "lock"]).expect_ok("lock");
    alice
        .gunnar(&w, &["crypt", "unlock"])
        .expect_ok("the SSH key still opens it");
}

/// ★ **RA4-1: a server that swaps a person's key AND its wrap is refused.**
/// alice's machine pinned her encryption key; a forge that answers another key
/// for her (here: another server where "alice" holds an operator's key, wrapped
/// to alice's real SSH key, so it opens) is refused by name at init and at
/// unlock, and nothing is wrapped to the operator's key.
#[test]
fn a_forge_that_swaps_the_persons_key_and_wrap_is_refused_by_the_pin() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
    alice_starts(&h, &alice, &[]);
    let pins = alice.home.join(".config/gunnar/encryption-pins");
    let pinned = std::fs::read_to_string(&pins).expect("crypt init pinned alice's own key");
    let real = h
        .stores
        .encryption_keys()
        .get("alice")
        .unwrap()
        .unwrap()
        .public_key;
    // RA5-4: `<forge> self <host key> <account> <key>`, one own pin per forge host.
    assert!(
        pinned
            .lines()
            .any(|l| l.split_ascii_whitespace().nth(1) == Some("self")
                && l.ends_with(&format!(" alice {real}"))),
        "{pinned}"
    );

    // The hostile forge: the same host, alice's SSH key, but an operator's key.
    let h2 = Harness::start(NS, REPO, HashKind::Sha1);
    let alice2 = User::without_encryption_key(&h2, "alice", Some(Level::Write), NS, REPO);
    std::fs::copy(alice.home.join(".ssh/id_ed25519"), &alice2.key).unwrap();
    std::fs::write(
        alice2.home.join(".ssh/id_ed25519.pub"),
        format!("{}\n", alice.ssh_public),
    )
    .unwrap();
    h2.stores
        .auth()
        .register_key("alice", &alice.ssh_public, None)
        .unwrap();
    let operator = common::member::give_an_encryption_key(&h2, "alice", &alice.ssh_public);
    assert_ne!(operator, real);
    std::fs::create_dir_all(alice2.home.join(".config/gunnar")).unwrap();
    std::fs::copy(&pins, alice2.home.join(".config/gunnar/encryption-pins")).unwrap();

    let w = alice2.work();
    std::fs::create_dir_all(&w).unwrap();
    alice2
        .git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    alice2
        .git(&w, &["remote", "add", "origin", &h2.url(NS, REPO)])
        .expect_ok("remote add");
    let refused = alice2.gunnar(&w, &["crypt", "init", "--member", "alice"]);
    refused.expect_failure("init refuses a key the pin does not know");
    // Another server: its SSH host key is not the pinned one (RA5-4), refused before the key is read.
    assert!(
        refused
            .stderr
            .contains("another SSH host key than the one this machine pinned"),
        "{}",
        refused.stderr
    );
    assert!(
        !w.join(".gunnar-crypt/keys").exists(),
        "no ring was wrapped to the operator's key"
    );
    let status = alice2.gunnar(&w, &["crypt", "key", "status"]);
    status.expect_failure("status refuses it too");
}

/// The forge's SSH door, asked directly as alice's machine would.
fn alice_forge_says(u: &User, w: &Path, verb: &str) -> String {
    let mut c = Command::new("sh");
    c.current_dir(w)
        .arg("-c")
        .arg("$GIT_SSH_COMMAND \"$@\"")
        .arg("ssh");
    let origin = u.git(w, &["remote", "get-url", "origin"]).stdout.clone();
    let rest = origin.trim().strip_prefix("ssh://").unwrap();
    let (authority, _) = rest.split_once('/').unwrap();
    let (host, port) = authority.rsplit_once(':').unwrap();
    c.args(["-p", port, host, verb]);
    u.env(&mut c);
    String::from_utf8(c.output().unwrap().stdout).unwrap()
}

/// ★ **RA5-4: the pin is not keyed by the forge's own word for who you are.**
/// alice's machine pinned her encryption key at this forge. The forge (the
/// same server, the same host key) then answers `gunnar-whoami` with another
/// name, `alice-2`, which holds an operator's key wrapped to alice's real SSH
/// key. Before, no pin was found under `self:alice-2`, the operator's key was
/// pinned "the first time" (one stderr line) and every new ring was wrapped to
/// it. Now the forge's other name is refused by name, and so is a name that is
/// not the operator's configured `gunnar.account`; nothing is wrapped; only
/// the explicit `gunnar crypt key repin --fingerprint` takes the new key.
#[test]
fn a_forge_that_answers_another_name_is_refused_and_nothing_is_wrapped_to_it() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
    alice_starts(&h, &alice, &[]);
    let pins = alice.home.join(".config/gunnar/encryption-pins");
    let real = h
        .stores
        .encryption_keys()
        .get("alice")
        .unwrap()
        .unwrap()
        .public_key;

    // The hostile forge: alice's SSH key now signs in as "alice-2", who holds an operator's key.
    let auth = h.stores.auth();
    let parsed = gunnar_core::ssh_key::PublicKey::from_openssh(&alice.ssh_public).unwrap();
    auth.revoke_key(&gunnar_core::auth::Fingerprint::of_key(&parsed))
        .unwrap()
        .expect("alice's key was registered");
    auth.add_principal(Principal::new("alice-2", "alice-2", PrincipalKind::User).unwrap())
        .unwrap();
    auth.register_key("alice-2", &alice.ssh_public, None)
        .unwrap();
    auth.grant(Grant::repo(
        "alice-2",
        RepoRef::new(NS, REPO).unwrap(),
        Level::Write,
    ))
    .unwrap();
    let operator = common::member::give_an_encryption_key(&h, "alice-2", &alice.ssh_public);
    assert_ne!(operator, real);

    let w = alice.home.join("second");
    std::fs::create_dir_all(&w).unwrap();
    alice
        .git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    alice
        .git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote add");
    assert_eq!(
        alice_forge_says(&alice, &w, "gunnar-whoami").trim(),
        "alice-2",
        "the forge lies about the name"
    );
    let pins_before = std::fs::read_to_string(&pins).unwrap();

    for args in [
        &["crypt", "init"][..],
        &["crypt", "init", "--member", "alice"][..],
        &["crypt", "key", "status"][..],
    ] {
        let refused = alice.gunnar(&w, args);
        refused.expect_failure("the forge's other name is refused");
        assert!(
            refused
                .stderr
                .contains("now says you are alice-2, and this machine pinned you as alice"),
            "{args:?}: {}",
            refused.stderr
        );
        assert!(
            refused.stderr.contains("gunnar crypt key repin"),
            "{}",
            refused.stderr
        );
        assert!(
            !refused.stderr.contains("pinned your encryption key"),
            "no new pin: {}",
            refused.stderr
        );
    }
    assert!(
        !w.join(".gunnar-crypt/keys").exists(),
        "no ring was wrapped to the operator's key"
    );
    assert_eq!(
        std::fs::read_to_string(&pins).unwrap(),
        pins_before,
        "the pins are unchanged"
    );

    // A machine with no pin yet, whose operator configured who they are: the other name is refused too.
    std::fs::rename(&pins, pins.with_extension("away")).unwrap();
    alice
        .git(&w, &["config", "gunnar.account", "alice"])
        .expect_ok("configure the account");
    let refused = alice.gunnar(&w, &["crypt", "init"]);
    refused.expect_failure("not the configured account");
    assert!(
        refused
            .stderr
            .contains("says you are alice-2, and gunnar.account says alice"),
        "{}",
        refused.stderr
    );
    assert!(!pins.exists(), "nothing was pinned");
    assert!(!w.join(".gunnar-crypt/keys").exists());
    alice
        .git(&w, &["config", "--unset", "gunnar.account"])
        .expect_ok("unset");
    std::fs::rename(pins.with_extension("away"), &pins).unwrap();

    // Only the explicit re-pin, with the new key's fingerprint read out, takes it.
    let fp = gunnar_crypt::door::fingerprint(&operator);
    let wrong = alice.gunnar(
        &w,
        &[
            "crypt",
            "key",
            "repin",
            "--fingerprint",
            "SHA256:not-this-one",
        ],
    );
    wrong.expect_failure("a wrong fingerprint re-pins nothing");
    assert_eq!(std::fs::read_to_string(&pins).unwrap(), pins_before);
    let repinned = alice.gunnar(&w, &["crypt", "key", "repin", "--fingerprint", &fp]);
    repinned.expect_ok("the explicit re-pin");
    assert!(
        repinned.stdout.contains("alice-2") && repinned.stdout.contains(&fp),
        "{}",
        repinned.stdout
    );
    alice
        .gunnar(&w, &["crypt", "key", "status"])
        .expect_ok("the re-pinned key is taken");
}

// ── CRYPT-PQ-DEFAULT (owner 2026-10-30): quantum-safe is the default ────────

/// The members file of the work tree `w`.
fn members_at(w: &Path) -> String {
    String::from_utf8(read(w.join(".gunnar-crypt/members"))).unwrap()
}

/// The age stanza types of the work tree's key ring (age's grease left out).
fn ring_stanzas(w: &Path) -> Vec<String> {
    gunnar_crypt::policy::age_stanzas(&read(w.join(".gunnar-crypt/keys")))
        .unwrap()
        .into_iter()
        .map(|s| s.tag)
        .filter(|t| !t.ends_with("-grease"))
        .collect()
}

/// A person whose ONE encryption key is classic (X25519), opened by their SSH key.
fn classic_user(h: &Harness, name: &'static str) -> User {
    let mut u = User::without_encryption_key(h, name, Some(Level::Write), NS, REPO);
    u.public = common::member::give_an_encryption_key_at(
        h,
        name,
        &u.ssh_public,
        gunnar_crypt::members::Level::Classic,
    );
    u
}

/// A git work tree for `u` with the forge as origin, nothing committed.
fn empty_work(h: &Harness, u: &User) -> PathBuf {
    let w = u.work();
    std::fs::create_dir_all(&w).unwrap();
    u.git(&w, &["init", "-q", "-b", "main"])
        .expect_ok("git init");
    u.git(&w, &["remote", "add", "origin", &h.url(NS, REPO)])
        .expect_ok("remote add");
    w
}

/// Commit the secret in `w` and push it.
fn commit_and_push(u: &User, w: &Path) {
    std::fs::write(w.join("secret.txt"), SECRET).unwrap();
    u.git(w, &["add", "secret.txt"]).expect_ok("git add");
    u.git(w, &["commit", "-q", "-m", "the secret"])
        .expect_ok("git commit");
    u.git(w, &["push", "-q", "-u", "origin", "main"])
        .expect_ok("push");
}

/// ★ **A new repository is quantum-safe unless asked otherwise.** A person with
/// only an SSH key runs `gunnar crypt init`: their key is made quantum-safe, the
/// members file says `level quantum-safe`, the ring carries hybrid stanzas only,
/// the server takes it, and a fresh clone opens with the SSH key. The SSH key's
/// wrap of the new key is classical, and init says so plainly.
#[test]
fn crypt_init_starts_quantum_safe_by_default() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    set_encrypted(&h, true);
    let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
    let w = empty_work(&h, &alice);
    let init = alice.gunnar(&w, &["crypt", "init", "--member", "alice"]);
    init.expect_ok("gunnar crypt init, no level asked for");
    let made = h
        .stores
        .encryption_keys()
        .public_of("alice")
        .unwrap()
        .expect("init made alice's key");
    assert!(
        made.starts_with("age1pq1"),
        "a new key is quantum-safe: {made}"
    );
    let members = members_at(&w);
    assert!(
        members.lines().any(|l| l == "level quantum-safe"),
        "{members}"
    );
    assert!(members.contains(&format!("alice {made}")), "{members}");
    let stanzas = ring_stanzas(&w);
    assert!(
        !stanzas.is_empty() && stanzas.iter().all(|t| t == "mlkem768x25519"),
        "{stanzas:?}"
    );
    assert!(
        init.stderr.contains("not quantum-safe") && init.stderr.contains("gunnar crypt key add-pq"),
        "the SSH wrap's strength is said plainly: {}",
        init.stderr
    );
    commit_and_push(&alice, &w);

    let again = alice.home.join("again");
    alice
        .git(
            &alice.home,
            &["clone", "-q", &h.url(NS, REPO), again.to_str().unwrap()],
        )
        .expect_ok("clone");
    alice
        .gunnar(&again, &["crypt", "unlock"])
        .expect_ok("the SSH key opens the quantum-safe key");
    assert_eq!(read(again.join("secret.txt")), SECRET.as_bytes());
    let status = alice.gunnar(&again, &["crypt", "status"]);
    status.expect_ok("status");
    assert!(status.stdout.contains("quantum-safe"), "{}", status.stdout);
}

/// ★ **Classic is a visible opt-out.** A person whose one key is classic is
/// told how to start a classic repository (`--level classic`) and nothing is
/// written; with it, the repository is classic in the old shape.
#[test]
fn a_classic_key_starts_a_classic_repository_only_when_asked() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = classic_user(&h, "alice");
    let w = empty_work(&h, &alice);
    let refused = alice.gunnar(&w, &["crypt", "init", "--member", "alice"]);
    refused.expect_failure("a classic key cannot be a member of a quantum-safe repository");
    assert!(
        refused.stderr.contains("--level classic") && refused.stderr.contains("classic"),
        "the refusal names the opt-out: {}",
        refused.stderr
    );
    assert!(!w.join(".gunnar-crypt").exists(), "nothing was written");

    alice
        .gunnar(
            &w,
            &["crypt", "init", "--member", "alice", "--level", "classic"],
        )
        .expect_ok("crypt init --level classic");
    let members = members_at(&w);
    assert!(
        !members.contains("level"),
        "a classic file keeps its old shape: {members}"
    );
    assert!(
        members.contains(&format!("alice {}", alice.public)),
        "{members}"
    );
    assert!(
        ring_stanzas(&w).iter().all(|t| t == "X25519"),
        "{:?}",
        ring_stanzas(&w)
    );
    commit_and_push(&alice, &w);
    let status = alice.gunnar(&w, &["crypt", "status"]);
    status.expect_ok("status");
    assert!(status.stdout.contains("classic"), "{}", status.stdout);
}

/// ★ **`gunnar crypt key new` makes a quantum-safe key unless `--classic`**, and
/// says plainly that the SSH key's wrap of a quantum-safe key is classical;
/// `key status` marks each method's wrap.
#[test]
fn key_new_makes_a_quantum_safe_key_unless_classic_is_asked_for() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    let alice = User::without_encryption_key(&h, "alice", Some(Level::Write), NS, REPO);
    let bob = User::without_encryption_key(&h, "bob", Some(Level::Write), NS, REPO);
    let aw = empty_work(&h, &alice);
    let made = alice.gunnar(&aw, &["crypt", "key", "new"]);
    made.expect_ok("key new");
    let public = h
        .stores
        .encryption_keys()
        .public_of("alice")
        .unwrap()
        .expect("alice's key");
    assert!(public.starts_with("age1pq1"), "{public}");
    let said = format!("{}{}", made.stdout, made.stderr);
    assert!(
        said.contains("not quantum-safe"),
        "the SSH wrap's strength is said: {said}"
    );
    let status = alice.gunnar(&aw, &["crypt", "key", "status"]);
    status.expect_ok("key status");
    assert!(
        status.stdout.contains("quantum-safe key") && status.stdout.contains("not quantum-safe"),
        "{}",
        status.stdout
    );

    let bw = empty_work(&h, &bob);
    let classic = bob.gunnar(&bw, &["crypt", "key", "new", "--classic"]);
    classic.expect_ok("key new --classic");
    let public = h
        .stores
        .encryption_keys()
        .public_of("bob")
        .unwrap()
        .expect("bob's key");
    assert!(
        public.starts_with("age1") && !public.starts_with("age1pq1"),
        "{public}"
    );
}

/// ★ **`gunnar crypt level quantum-safe`**: a classic repository is re-wrapped to
/// quantum-safe keys only when every member has one. Members without one are
/// named and nothing changes; with a quantum-safe key for each, the ring is
/// rotated and wrapped to hybrid stanzas only, the server takes it, and the
/// level never goes back.
#[test]
fn crypt_level_quantum_safe_upgrades_only_with_a_pq_key_for_every_member() {
    assert!(tools_present());
    let h = Harness::start(NS, REPO, HashKind::Sha1);
    set_encrypted(&h, true);
    let alice = classic_user(&h, "alice");
    let bob = classic_user(&h, "bob");
    let w = empty_work(&h, &alice);
    alice
        .gunnar(
            &w,
            &["crypt", "init", "--member", "alice", "--level", "classic"],
        )
        .expect_ok("a classic repository");
    let bob_key = alice.home.join("bob.pub");
    std::fs::write(&bob_key, &bob.public).unwrap();
    alice
        .gunnar(
            &w,
            &[
                "crypt",
                "add-member",
                "bob",
                "--key",
                bob_key.to_str().unwrap(),
            ],
        )
        .expect_ok("add bob");
    commit_and_push(&alice, &w);
    let shown = alice.gunnar(&w, &["crypt", "level"]);
    shown.expect_ok("crypt level");
    assert!(shown.stdout.contains("classic"), "{}", shown.stdout);

    let before = (
        members_at(&w),
        remote_main(&alice, &w),
        alice.git(&w, &["rev-parse", "HEAD"]).stdout,
    );
    let refused = alice.gunnar(&w, &["crypt", "level", "quantum-safe", "--from-server"]);
    refused.expect_failure("alice and bob have classic keys only");
    assert!(
        refused.stderr.contains("alice") && refused.stderr.contains("bob"),
        "every member without one is named: {}",
        refused.stderr
    );
    assert!(refused.stderr.contains("age1pq1"), "{}", refused.stderr);
    assert_eq!(
        before,
        (
            members_at(&w),
            remote_main(&alice, &w),
            alice.git(&w, &["rev-parse", "HEAD"]).stdout
        ),
        "nothing changed"
    );

    let (pa, pb) = (alice.keygen(), bob.keygen());
    let done = alice.gunnar(
        &w,
        &[
            "crypt",
            "level",
            "quantum-safe",
            "--key",
            &format!("alice={pa}"),
            "--key",
            &format!("bob={pb}"),
        ],
    );
    done.expect_ok("every member has a quantum-safe key");
    let members = members_at(&w);
    assert!(
        members.lines().any(|l| l == "level quantum-safe"),
        "{members}"
    );
    assert!(
        members.contains(&format!("alice {pa}")) && members.contains(&format!("bob {pb}")),
        "{members}"
    );
    assert!(
        ring_stanzas(&w).iter().all(|t| t == "mlkem768x25519"),
        "{:?}",
        ring_stanzas(&w)
    );
    assert!(
        done.stdout.contains("epoch 2"),
        "the ring is rotated: {}",
        done.stdout
    );
    alice
        .git(&w, &["push", "-q"])
        .expect_ok("the server takes classic → quantum-safe");

    // bob's quantum-safe key file opens the new ring, and the old epoch is in it.
    let bob_file = std::fs::read_to_string(bob.pq_key()).unwrap();
    let id = gunnar_crypt::wrap::pq_identity(&bob_file).unwrap();
    let ring = gunnar_crypt::wrap::unwrap_checked(
        &read(w.join(".gunnar-crypt/keys")),
        &members,
        &[id.as_ref()],
    )
    .unwrap();
    assert_eq!(ring.keys().len(), 2);

    let back = alice.gunnar(&w, &["crypt", "level", "classic"]);
    back.expect_failure("the level only goes up");
    assert!(back.stderr.contains("only goes up"), "{}", back.stderr);
}