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/src/crypt.rs

//! `gunnar crypt`: encrypted repositories, Level 1 — stock git, a filter, and
//! a server that only ever holds ciphertext.
//!
//! The pieces (all from `gunnar-crypt`, the crate the browser console runs
//! too):
//!
//! * `.gunnar-crypt/members` — members' encryption PUBLIC keys, plaintext:
//!   in a QUANTUM-SAFE repository (`level quantum-safe`, the default for a new
//!   one) only `age1pq1…` keys (age's hybrid ML-KEM-768 + X25519); in a
//!   classic one (`gunnar crypt init --level classic`) `age1…` X25519 keys.
//! * `.gunnar-crypt/keys` — the key ring, age-wrapped to every member key.
//! * `.gitattributes` — sends every other file through the `gunnar-crypt`
//!   filter, diff and merge drivers.
//! * `<git common dir>/gunnar-crypt/keyring` — this clone's unwrapped ring,
//!   mode 0600, written by `unlock` and removed by `lock`. It never leaves the
//!   machine; nothing here sends a key anywhere.
//!
//! git runs the filter: `clean` seals what is committed, `smudge` opens what
//! is checked out, `textconv` opens for `git diff`, and the merge driver
//! opens the three versions, merges the plaintext with `git merge-file` and
//! seals the result. So diff, log -p, blame and merge all happen HERE, on the
//! client; the server checks fast-forwards by object id as it always does.
//!
//! git is a subprocess here, which the CLIENT may do: this module only runs
//! in a clone the user is working in, under git, as git's own filter. The
//! zero-shell rule is the server's and the appliance's.

use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use anyhow::{anyhow, bail, Context, Result};
use gunnar_crypt::envelope;
use gunnar_crypt::keyring::{file_id, Keyring};
use gunnar_crypt::members::{KeyKind, Level, Members};
use gunnar_crypt::wrap::{self, age, Passphrase};
use gunnar_crypt::{paths, DRIVER, GITATTRIBUTES_LINES};

use crate::cli::{CryptCommand, CryptLevel};

/// The `--level` word as gunnar-crypt's level. No word: [`Level::NEW`], the
/// default every door reads from gunnar-crypt (quantum-safe).
pub(crate) fn level_of(word: Option<CryptLevel>) -> Level {
    match word {
        Some(CryptLevel::QuantumSafe) => Level::QuantumSafe,
        Some(CryptLevel::Classic) => Level::Classic,
        None => Level::NEW,
    }
}

/// Run one `gunnar crypt` verb.
pub fn run(command: CryptCommand) -> Result<()> {
    match command {
        CryptCommand::Init {
            member,
            keys,
            level,
            no_commit,
        } => init(member, &keys, level_of(level), no_commit),
        CryptCommand::Level {
            level,
            keys,
            from_server,
            fingerprints,
            repo,
            at,
            identity,
            no_commit,
        } => set_level(
            level.map(|l| level_of(Some(l))),
            &LevelKeys {
                keys: &keys,
                from_server,
                fingerprints: &fingerprints,
                repo: repo.as_deref(),
                at: at.as_deref(),
                identity: identity.as_deref(),
            },
            no_commit,
        ),
        CryptCommand::Keygen { out, force } => keygen(out, force),
        CryptCommand::AddMember {
            name,
            keys,
            from_server,
            fingerprint,
            repo,
            at,
            identity,
            no_commit,
        } => {
            let local = Repo::discover()?;
            let mut keys: Vec<String> = keys.iter().map(|k| read_key(k)).collect::<Result<_>>()?;
            // A key handed over by the member is theirs: pinned as such (RA4-1).
            for k in &keys {
                crate::crypt_key::pins::pin_member(&local, &name, k)?;
            }
            if from_server {
                let level = local.members()?.level();
                let held = server_keys(
                    &name,
                    repo.as_deref(),
                    at.as_deref(),
                    identity.as_deref(),
                    level,
                )?;
                // The server's word for someone else's key is not taken alone (RA4-1).
                for k in &held {
                    crate::crypt_key::pins::check_member(&local, &name, k, fingerprint.as_deref())?;
                }
                keys.extend(held);
            }
            add_member(&name, &keys, no_commit)
        }
        CryptCommand::Rekey {
            name,
            key,
            from_server,
            fingerprint,
            repo,
            at,
            identity,
            no_commit,
        } => {
            let local = Repo::discover()?;
            let (new, from) = match key {
                Some(k) => (read_key(&k)?, NewKeyFrom::Handed),
                None => {
                    debug_assert!(from_server);
                    let level = local.members()?.level();
                    let held = server_keys(
                        &name,
                        repo.as_deref(),
                        at.as_deref(),
                        identity.as_deref(),
                        level,
                    )?;
                    let k = held
                        .into_iter()
                        .next()
                        .expect("server_keys refuses an empty answer");
                    (k, NewKeyFrom::Server(fingerprint))
                }
            };
            rekey(&local, &name, &new, from, no_commit)
        }
        CryptCommand::RemoveMember { name, no_commit } => remove_member(&name, no_commit),
        CryptCommand::Rotate { no_commit } => rotate(no_commit),
        CryptCommand::Unlock { open } => unlock(&open.into()),
        CryptCommand::Key(command) => crate::crypt_key::run(command),
        CryptCommand::Lock => lock(),
        CryptCommand::Status => status(),
        CryptCommand::Diff { from, to, paths } => diff(&from, &to, &paths),
        CryptCommand::Clean { path } => filter_once(Mode::Clean, path.as_deref()),
        CryptCommand::Smudge { path } => filter_once(Mode::Smudge, path.as_deref()),
        CryptCommand::FilterProcess => filter_process(),
        CryptCommand::Textconv { file } => textconv(&file),
        CryptCommand::Merge {
            base,
            ours,
            theirs,
            marker_size,
            path,
        } => merge(&base, &ours, &theirs, marker_size, path.as_deref()),
    }
}

// ── the repository ──────────────────────────────────────────────────────────

/// A clone: its work tree and its (common) git directory.
pub(crate) struct Repo {
    top: PathBuf,
    common: PathBuf,
}

impl Repo {
    pub(crate) fn discover() -> Result<Repo> {
        let out = git_capture(None, &["rev-parse", "--show-toplevel", "--git-common-dir"])
            .context("not inside a git work tree")?;
        let mut lines = out.lines();
        let top = PathBuf::from(
            lines
                .next()
                .ok_or_else(|| anyhow!("git named no work tree"))?,
        );
        let common = PathBuf::from(
            lines
                .next()
                .ok_or_else(|| anyhow!("git named no git dir"))?,
        );
        let common = if common.is_absolute() {
            common
        } else {
            std::env::current_dir()?.join(common)
        };
        Ok(Repo { top, common })
    }

    fn state_dir(&self) -> PathBuf {
        self.common.join("gunnar-crypt")
    }

    fn ring_path(&self) -> PathBuf {
        self.state_dir().join("keyring")
    }

    fn source_path(&self) -> PathBuf {
        self.state_dir().join("keys-source")
    }

    pub(crate) fn git(&self, args: &[&str]) -> Result<String> {
        git_capture(Some(&self.top), args)
    }

    fn git_bytes(&self, args: &[&str]) -> Result<Vec<u8>> {
        let out = Command::new("git")
            .current_dir(&self.top)
            .args(args)
            .stderr(Stdio::piped())
            .output()
            .context("run git")?;
        if !out.status.success() {
            bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&out.stderr).trim()
            );
        }
        Ok(out.stdout)
    }

    fn git_ok(&self, args: &[&str]) -> bool {
        Command::new("git")
            .current_dir(&self.top)
            .args(args)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .is_ok_and(|s| s.success())
    }

    /// git with a NUL-separated pathspec on stdin.
    fn git_paths(&self, args: &[&str], paths: &[String]) -> Result<()> {
        if paths.is_empty() {
            return Ok(());
        }
        let mut child = Command::new("git")
            .current_dir(&self.top)
            .args(args)
            .args(["--pathspec-from-file=-", "--pathspec-file-nul"])
            .stdin(Stdio::piped())
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .spawn()
            .context("run git")?;
        {
            let mut stdin = child.stdin.take().expect("piped");
            for p in paths {
                stdin.write_all(p.as_bytes())?;
                stdin.write_all(b"\0")?;
            }
        }
        let out = child.wait_with_output()?;
        if !out.status.success() {
            bail!(
                "git {} failed: {}",
                args.join(" "),
                String::from_utf8_lossy(&out.stderr).trim()
            );
        }
        Ok(())
    }

    /// This clone's unwrapped key ring, if it is unlocked.
    pub(crate) fn load_ring(&self) -> Result<Option<Keyring>> {
        let path = self.ring_path();
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => gunnar_crypt::zeroize::Zeroizing::new(t),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
        };
        Ok(Some(Keyring::parse(&text).map_err(|e| {
            anyhow!(
                "{} is damaged ({e}); run `gunnar crypt unlock`",
                path.display()
            )
        })?))
    }

    fn save_ring(&self, ring: &Keyring, keys_file: &[u8]) -> Result<()> {
        std::fs::create_dir_all(self.state_dir())?;
        write_private(&self.ring_path(), ring.to_text().as_bytes())?;
        std::fs::write(self.source_path(), file_id(keys_file))?;
        Ok(())
    }

    fn keys_file(&self) -> Result<Vec<u8>> {
        let path = self.top.join(paths::KEYS);
        match std::fs::read(&path) {
            Ok(b) => Ok(b),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
                "this is not an encrypted repository ({} is missing); `gunnar crypt init` makes one",
                paths::KEYS
            ),
            Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
        }
    }

    fn members_text(&self) -> Result<String> {
        let path = self.top.join(paths::MEMBERS);
        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))
    }

    fn members(&self) -> Result<Members> {
        Members::parse(&self.members_text()?).map_err(|e| anyhow!("{}: {e}", paths::MEMBERS))
    }

    /// The local ring, refused unless it came from the key file now in the
    /// work tree: re-wrapping an older ring over a newer one would drop the
    /// epochs someone else added.
    fn current_ring(&self) -> Result<Keyring> {
        let ring = self
            .load_ring()?
            .ok_or_else(|| anyhow!("this clone is locked: run `gunnar crypt unlock` first"))?;
        let source = std::fs::read_to_string(self.source_path()).unwrap_or_default();
        if source.trim() != file_id(&self.keys_file()?) {
            bail!(
                "{} changed since this clone was unlocked (someone added, removed or rotated): \
                 run `gunnar crypt unlock` first",
                paths::KEYS
            );
        }
        Ok(ring)
    }

    /// Write the members file and the key ring wrapped to them; returns the
    /// key file's bytes.
    fn write_scheme(&self, members: &Members, ring: &Keyring) -> Result<Vec<u8>> {
        let wrapped = wrap::wrap(ring, members).map_err(|e| anyhow!("{e}"))?;
        std::fs::create_dir_all(self.top.join(paths::DIR))?;
        std::fs::write(self.top.join(paths::MEMBERS), members.to_text())?;
        std::fs::write(self.top.join(paths::KEYS), wrapped.as_bytes())?;
        Ok(wrapped.into_bytes())
    }

    fn commit(&self, message: &str, only_scheme: bool) -> Result<()> {
        let mut args = vec!["commit", "-q", "--no-verify", "-m", message];
        if only_scheme {
            args.extend(["--", paths::DIR]);
        }
        self.git(&args)?;
        Ok(())
    }
}

fn git_capture(cwd: Option<&Path>, args: &[&str]) -> Result<String> {
    let mut c = Command::new("git");
    if let Some(cwd) = cwd {
        c.current_dir(cwd);
    }
    let out = c
        .args(args)
        .stderr(Stdio::piped())
        .output()
        .context("run git (is it installed?)")?;
    if !out.status.success() {
        bail!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
    let mut opts = std::fs::OpenOptions::new();
    opts.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        opts.mode(0o600);
    }
    let mut f = opts
        .open(path)
        .with_context(|| format!("write {}", path.display()))?;
    f.write_all(bytes)?;
    Ok(())
}

/// `'…'` for git's shell.
pub(crate) fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

/// Point git at this binary's filter, diff and merge drivers.
fn configure(repo: &Repo) -> Result<()> {
    let exe = std::env::current_exe().context("find this gunnar binary")?;
    let exe = shell_quote(&exe.to_string_lossy());
    let settings = [
        (
            format!("filter.{DRIVER}.process"),
            format!("{exe} crypt filter-process"),
        ),
        (
            format!("filter.{DRIVER}.clean"),
            format!("{exe} crypt clean %f"),
        ),
        (
            format!("filter.{DRIVER}.smudge"),
            format!("{exe} crypt smudge %f"),
        ),
        // A filter that fails must stop `git add`: plaintext never gets
        // committed because the filter could not run.
        (format!("filter.{DRIVER}.required"), "true".to_string()),
        (
            format!("diff.{DRIVER}.textconv"),
            format!("{exe} crypt textconv"),
        ),
        (
            format!("merge.{DRIVER}.name"),
            "gunnar-crypt: merge the decrypted texts".to_string(),
        ),
        (
            format!("merge.{DRIVER}.driver"),
            format!("{exe} crypt merge %O %A %B %L %P"),
        ),
    ];
    for (k, v) in settings {
        repo.git(&["config", "--local", &k, &v])?;
    }
    Ok(())
}

fn unconfigure(repo: &Repo) {
    for section in ["filter", "diff", "merge"] {
        let _ = repo.git_ok(&[
            "config",
            "--local",
            "--remove-section",
            &format!("{section}.{DRIVER}"),
        ]);
    }
}

fn ensure_gitattributes(repo: &Repo) -> Result<()> {
    let path = repo.top.join(paths::GITATTRIBUTES);
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let have: Vec<&str> = existing.lines().map(str::trim).collect();
    let mut text = existing.clone();
    if !text.is_empty() && !text.ends_with('\n') {
        text.push('\n');
    }
    for line in GITATTRIBUTES_LINES {
        if !have.contains(line) {
            text.push_str(line);
            text.push('\n');
        }
    }
    if text != existing {
        std::fs::write(&path, text)?;
    }
    Ok(())
}

fn is_scheme_path(path: &str) -> bool {
    path == paths::GITATTRIBUTES || path.starts_with(".gunnar-crypt/")
}

/// A public key from a file path or a key line.
pub(crate) fn read_key(arg: &str) -> Result<String> {
    let p = expand_home(arg);
    if p.exists() {
        let text = std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
        return text
            .lines()
            .map(str::trim)
            .find(|l| !l.is_empty() && !l.starts_with('#'))
            .map(str::to_string)
            .ok_or_else(|| anyhow!("{} holds no public key", p.display()));
    }
    Ok(arg.trim().to_string())
}

fn home() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .filter(|h| !h.is_empty())
        .map(PathBuf::from)
}

fn expand_home(arg: &str) -> PathBuf {
    match (arg.strip_prefix("~/"), home()) {
        (Some(rest), Some(h)) => h.join(rest),
        _ => PathBuf::from(arg),
    }
}

/// `~/.ssh/id_ed25519.pub`, else `~/.ssh/id_rsa.pub`.
pub(crate) fn default_public_key() -> Result<String> {
    let ssh = home().map(|h| h.join(".ssh")).unwrap_or_default();
    for name in ["id_ed25519.pub", "id_rsa.pub"] {
        let p = ssh.join(name);
        if p.exists() {
            return read_key(&p.to_string_lossy());
        }
    }
    if ssh.join("id_ed25519_sk.pub").exists() || ssh.join("id_ecdsa_sk.pub").exists() {
        bail!(
            "only a security-key (sk-) SSH key was found in {}. A security key can only sign, \
             so nothing can be encrypted to it: make an ordinary key for encrypted repositories \
             (ssh-keygen -t ed25519) and keep the security key for login",
            ssh.display()
        );
    }
    bail!(
        "no SSH public key found ({}/id_ed25519.pub or id_rsa.pub); pass --key",
        ssh.display()
    )
}

// ── identities: the SSH private key FILE ────────────────────────────────────

#[derive(Clone)]
struct TtyPassphrase;

impl Passphrase for TtyPassphrase {
    fn ask(&self, description: &str) -> Option<String> {
        rpassword::prompt_password(format!("{description}: ")).ok()
    }
}

/// Where `gunnar crypt keygen` writes the quantum-safe key by default:
/// `$XDG_CONFIG_HOME/gunnar/crypt-pq.key`, else `~/.config/gunnar/crypt-pq.key`.
pub(crate) fn default_pq_identity() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .filter(|d| !d.is_empty())
        .map(PathBuf::from)
        .or_else(|| home().map(|h| h.join(".config")))?;
    Some(base.join("gunnar").join("crypt-pq.key"))
}

/// The private keys to unwrap with: the named files, else the default ones
/// that exist (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, and the quantum-safe key
/// `gunnar crypt keygen` made). A file holding an `AGE-SECRET-KEY-PQ-1…` line
/// is a quantum-safe identity; anything else is read as an SSH key.
/// ssh-agent is not consulted: it can only sign.
pub(crate) fn identity_files(named: &[PathBuf]) -> Vec<PathBuf> {
    if !named.is_empty() {
        return named.to_vec();
    }
    let ssh = home().map(|h| h.join(".ssh")).unwrap_or_default();
    ["id_ed25519", "id_rsa"]
        .iter()
        .map(|n| ssh.join(n))
        .chain(default_pq_identity())
        .filter(|p| p.exists())
        .collect()
}

/// A quantum-safe identity file's identity, `None` when the file is not one.
pub(crate) fn pq_identity_in(
    path: &Path,
    bytes: &[u8],
) -> Option<Result<Box<dyn age::Identity + Send + Sync>>> {
    let text = std::str::from_utf8(bytes).ok()?;
    gunnar_crypt::pq::is_identity_file(text)
        .then(|| wrap::pq_identity(text).map_err(|e| anyhow!("{}: {e}", path.display())))
}

pub(crate) fn identities(named: &[PathBuf]) -> Result<Vec<(PathBuf, Box<dyn age::Identity>)>> {
    let candidates = identity_files(named);
    if candidates.is_empty() {
        bail!(
            "no private key file found (~/.ssh/id_ed25519, ~/.ssh/id_rsa, or the quantum-safe \
             key `gunnar crypt keygen` makes); pass -i <path>. ssh-agent cannot be used: it \
             only signs, and unwrapping needs the key itself"
        );
    }
    let mut out = Vec::new();
    for path in candidates {
        let pem = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
        if let Some(id) = pq_identity_in(&path, &pem) {
            out.push((path, id? as Box<dyn age::Identity>));
            continue;
        }
        let id = wrap::ssh_identity(&pem, Some(path.display().to_string()), TtyPassphrase)
            .map_err(|e| anyhow!("{}: {e}", path.display()))?;
        out.push((path, id));
    }
    Ok(out)
}

// ── the verbs ───────────────────────────────────────────────────────────────

/// The public half of this machine's quantum-safe key (`gunnar crypt keygen`).
pub(crate) fn default_pq_public_key() -> Result<String> {
    let path = default_pq_identity().ok_or_else(|| anyhow!("no HOME to look for a key in"))?;
    let text = std::fs::read_to_string(&path).map_err(|_| {
        anyhow!(
            "no quantum-safe key at {}: make one with `gunnar crypt keygen` (or pass --key age1pq1…). \
             {}",
            path.display(),
            wrap::SSH_IS_NOT_PQ
        )
    })?;
    let id = gunnar_crypt::pq::parse_identity_file(&text)
        .map_err(|e| anyhow!("{}: {e}", path.display()))?;
    Ok(id.to_public().to_string())
}

fn init(member: Option<String>, keys: &[String], level: Level, no_commit: bool) -> Result<()> {
    let repo = Repo::discover()?;
    if repo.top.join(paths::KEYS).exists() {
        bail!(
            "this repository is already encrypted ({} exists); `gunnar crypt unlock` opens it",
            paths::KEYS
        );
    }
    // The member's name is their Gunnar account name: `add-member --from-server` looks every
    // member up by it. Asked of the forge origin points at when not given; `$USER` is a login on
    // THIS machine, and guessed from it the lookup failed later (wiring tracer B 2026-10-08).
    let name = match member {
        Some(name) => name,
        None => door_whoami(&repo).map_err(|why| {
            anyhow!("say who you are: `gunnar crypt init --member <your Gunnar user name>` (the forge could not say: {why})")
        })?,
    };
    // **The member key is the person's ONE encryption key** (CRYPT-YUBI): given here, or the
    // forge's — made at the forge first, wrapped to the SSH key, when they have none yet. The
    // level is quantum-safe unless `--level classic` was asked for (CRYPT-PQ-DEFAULT).
    let keys: Vec<String> = if !keys.is_empty() {
        keys.iter().map(|k| read_key(k)).collect::<Result<_>>()?
    } else {
        vec![crate::crypt_key::public_or_new(&repo, level, None)?]
    };
    if level == Level::QuantumSafe {
        if let Some(classic) = keys
            .iter()
            .find(|k| gunnar_crypt::members::classify(k) == Ok(KeyKind::Age))
        {
            bail!(
                "your encryption key is classic (X25519, {}…), and a new encrypted repository is \
                 quantum-safe unless asked otherwise; age never mixes the two kinds in one key ring. \
                 Start this one classic, for members whose one key is classic: \
                 `gunnar crypt init --level classic`. Nothing was written",
                classic.chars().take(12).collect::<String>()
            );
        }
    }
    let mut members = Members::at(level);
    for k in &keys {
        members.add(&name, k).map_err(|e| anyhow!("{e}"))?;
    }
    let ring = Keyring::generate();
    let keys_file = repo.write_scheme(&members, &ring)?;
    ensure_gitattributes(&repo)?;
    repo.save_ring(&ring, &keys_file)?;
    configure(&repo)?;

    // Files already tracked go through the filter from the next commit on.
    let tracked = repo.git(&["ls-files"])?;
    let earlier: Vec<&str> = tracked.lines().filter(|p| !is_scheme_path(p)).collect();
    if !earlier.is_empty() {
        repo.git(&["add", "--renormalize", "."])?;
        eprintln!(
            "warning: {} file(s) were committed before encryption. They are sealed from the \
             next commit on, but their earlier versions stay PLAINTEXT in history (and on the \
             server). Start from a fresh repository if that history is secret.",
            earlier.len()
        );
    }
    repo.git(&["add", paths::GITATTRIBUTES, paths::DIR])?;
    if !no_commit {
        repo.commit(
            &format!("gunnar crypt: encrypt this repository (member {name})"),
            false,
        )?;
    }
    println!(
        "encrypted: key ring wrapped to {name} ({} key(s)), key {}.\n\
         Add members with `gunnar crypt add-member <name> --from-server`.",
        keys.len(),
        ring.newest().id()
    );
    Ok(())
}

/// `<ns>/<repo>` from `origin`'s URL (`ssh://host:port/ns/repo.git`,
/// `git@host:ns/repo.git`, `https://host/ns/repo.git`).
fn repo_from_origin(url: &str) -> Option<String> {
    let url = url.trim().trim_end_matches('/').trim_end_matches(".git");
    let path = if let Some((_, rest)) = url.split_once("://") {
        rest.split_once('/')?.1
    } else {
        url.split_once(':')?.1
    };
    let mut parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
    if parts.len() < 2 {
        return None;
    }
    let repo = parts.pop()?;
    Some(format!("{}/{repo}", parts.join("/")))
}

/// The encryption key gunnar holds for `name` (CRYPT-YUBI: one per person),
/// when it fits this repository's level.
///
/// **Where gunnar is asked.** With `--at` (or `GUNNAR_CONTROL_ADDR`), its
/// control plane (`Repos.RecipientKeys`). Without, the FORGE this clone pushes
/// to, over its SSH door — origin's host and port, signed in by the same `ssh`
/// git runs (`GIT_SSH_COMMAND`, `core.sshCommand`, `GIT_SSH`, else `ssh`): an
/// installed estate exposes its forge's door and its console, and binds the
/// control plane to its WireGuard overlay. Both doors keep one rule.
fn server_keys(
    name: &str,
    repo: Option<&str>,
    at: Option<&str>,
    identity: Option<&Path>,
    level: Level,
) -> Result<Vec<String>> {
    let local = Repo::discover()?;
    let origin = local.git(&["remote", "get-url", "origin"]).ok();
    let repo = match repo {
        Some(r) => r.to_string(),
        None => {
            let url = origin.clone().unwrap_or_default();
            repo_from_origin(&url).ok_or_else(|| {
                anyhow!(
                    "cannot tell the repository from origin ({}); pass --repo <ns>/<repo>",
                    url.trim()
                )
            })?
        }
    };
    let lines = match at {
        Some(at) => control_keys(at, identity, &repo, name)?,
        None => door_keys(
            &local,
            origin.as_deref().unwrap_or(""),
            identity,
            &repo,
            name,
        )?,
    };
    // gunnar hands out the member's ONE encryption key (CRYPT-YUBI), never an SSH key.
    let usable: Vec<String> = lines
        .iter()
        .filter(|l| matches!(gunnar_crypt::members::classify(l), Ok(kind) if level.takes(kind)))
        .cloned()
        .collect();
    if usable.is_empty() {
        let held = lines
            .iter()
            .any(|l| gunnar_crypt::members::classify(l).is_ok());
        match (held, level) {
            (false, _) => bail!(
                "gunnar holds no encryption key for {name}: {name} makes one with `gunnar crypt key new`, or by \
                 adding a security key on their Account page"
            ),
            (true, Level::QuantumSafe) => bail!(
                "gunnar holds only a classic encryption key (age1…) for {name}, and this repository is \
                 quantum-safe: a quantum-safe ring takes age1pq1… keys only, and age never mixes the two \
                 kinds. A person has one key, so {name} is a member of classic repositories only"
            ),
            (true, Level::Classic) => bail!(
                "gunnar holds a quantum-safe encryption key (age1pq1…) for {name}, and this repository is \
                 classic; age never mixes the two kinds. `gunnar crypt level quantum-safe` makes it \
                 quantum-safe once every member has a quantum-safe key"
            ),
        }
    }
    Ok(usable)
}

#[cfg(feature = "control")]
fn control_keys(at: &str, identity: Option<&Path>, repo: &str, name: &str) -> Result<Vec<String>> {
    crate::control::recipient_keys(at, identity, repo, name)
}

#[cfg(not(feature = "control"))]
fn control_keys(_: &str, _: Option<&Path>, _: &str, _: &str) -> Result<Vec<String>> {
    bail!("--at asks gunnar's control plane, which this `gunnar` binary does not carry: use `gunnar-cli crypt add-member … --from-server --at …`, or leave --at off to ask the forge origin pushes to over SSH")
}

/// The SSH door of an `ssh://[user@]host[:port]/path` or `[user@]host:path`
/// URL: `(user@host, port)`. `None` for any other scheme.
pub(crate) fn ssh_door(url: &str) -> Option<(String, Option<u16>)> {
    let url = url.trim();
    if let Some(rest) = url
        .strip_prefix("ssh://")
        .or_else(|| url.strip_prefix("git+ssh://"))
    {
        let authority = rest.split('/').next()?;
        let (host, port) = match authority.rsplit_once(':') {
            Some((host, port)) if !port.contains(']') => (host, Some(port.parse().ok()?)),
            _ => (authority, None),
        };
        return (!host.is_empty()).then(|| (host.to_string(), port));
    }
    if url.contains("://") {
        return None;
    }
    let (host, path) = url.split_once(':')?;
    (!host.is_empty() && !path.is_empty() && !host.contains('/')).then(|| (host.to_string(), None))
}

/// **Ask the forge's SSH door** (`gunnar-recipient-keys`, the same rule as
/// `Repos.RecipientKeys`) for `name`'s keys, as the ssh git runs would.
fn door_keys(
    local: &Repo,
    origin: &str,
    identity: Option<&Path>,
    repo: &str,
    name: &str,
) -> Result<Vec<String>> {
    let (target, port) = ssh_door(origin).ok_or_else(|| {
        anyhow!(
            "--from-server asks the forge origin pushes to over SSH, and origin is not an SSH URL ({}); \
             add the member with the public key they hand you instead (`gunnar crypt add-member <name> --key <their .pub file>`), \
             or set origin to the forge's SSH address",
            origin.trim()
        )
    })?;
    let verb = format!(
        "gunnar-recipient-keys {} {}",
        shell_quote(repo),
        shell_quote(name)
    );
    match door_exec(local, &target, port, identity, &verb)? {
        Ok(said) => Ok(said
            .lines()
            .map(str::trim)
            .filter(|l| !l.is_empty())
            .map(str::to_string)
            .collect()),
        Err(why) => bail!("the forge at {target} gave no keys for {name}: {why}"),
    }
}

/// **Who the forge's SSH door says this person is** (`gunnar-whoami`, wiring tracer B 2026-10-08):
/// the account name `add-member --from-server` looks members up by. `Err(why)` when
/// origin is not an SSH forge or the door does not say (an older Gunnar).
pub(crate) fn door_whoami(local: &Repo) -> std::result::Result<String, String> {
    door_whoami_seen(local).map(|(name, _)| name)
}

/// [`door_whoami`], with the SSH host key the door answered with (RA5-4).
pub(crate) fn door_whoami_seen(local: &Repo) -> std::result::Result<(String, String), String> {
    let origin = local
        .git(&["remote", "get-url", "origin"])
        .map_err(|_| "this clone has no origin".to_string())?;
    let (target, port) =
        ssh_door(&origin).ok_or_else(|| format!("origin is not an SSH URL ({})", origin.trim()))?;
    let (said, host_key) = door_exec_seen(local, &target, port, None, "gunnar-whoami")
        .map_err(|e| format!("{e:#}"))?;
    let said = said?;
    let name = said
        .lines()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .ok_or_else(|| format!("the forge at {target} named nobody"))?
        .to_string();
    if name.contains(char::is_whitespace) {
        return Err(format!(
            "the forge at {target} answered something that is no name"
        ));
    }
    Ok((name, host_key))
}

/// Run `verb` at the forge's SSH door `target`, signed in by the same `ssh` git runs
/// (`GIT_SSH_COMMAND`, `core.sshCommand`, `GIT_SSH`, else `ssh`): `Ok(Ok(stdout))`, or
/// `Ok(Err(why))` with the door's first line.
pub(crate) fn door_exec(
    local: &Repo,
    target: &str,
    port: Option<u16>,
    identity: Option<&Path>,
    verb: &str,
) -> Result<std::result::Result<String, String>> {
    door_run(local, target, port, identity, verb, false).map(|(said, _)| said)
}

/// [`door_exec`], and the **SSH host key the door signed in with** (`SHA256:…`, as
/// ssh logs it at `-v`): the forge's own identity, which the encryption key pins are
/// kept under (RA5-4). An ssh that does not say it is refused: no pin is checked blind.
pub(crate) fn door_exec_seen(
    local: &Repo,
    target: &str,
    port: Option<u16>,
    identity: Option<&Path>,
    verb: &str,
) -> Result<(std::result::Result<String, String>, String)> {
    let (said, host_key) = door_run(local, target, port, identity, verb, true)?;
    let host_key = host_key.ok_or_else(|| {
        anyhow!("the ssh that signs in to {target} did not say the forge's host key (`ssh -v` prints \"Server host key\"): the encryption key pin is kept under it, so nothing is trusted without it")
    })?;
    Ok((said, host_key))
}

/// `debug1: Server host key: ssh-ed25519 SHA256:…` → `ssh-ed25519 SHA256:…`.
pub(crate) fn host_key_in(stderr: &str) -> Option<String> {
    stderr.lines().find_map(|l| {
        let rest = l.split_once("Server host key:")?.1.trim();
        let mut w = rest.split_ascii_whitespace();
        let (algo, fp) = (w.next()?, w.next()?);
        fp.contains(':').then(|| format!("{algo} {fp}"))
    })
}

fn door_run(
    local: &Repo,
    target: &str,
    port: Option<u16>,
    identity: Option<&Path>,
    verb: &str,
    seen: bool,
) -> Result<(std::result::Result<String, String>, Option<String>)> {
    let ssh = std::env::var("GIT_SSH_COMMAND")
        .ok()
        .filter(|c| !c.trim().is_empty())
        .or_else(|| {
            local
                .git(&["config", "core.sshCommand"])
                .ok()
                .map(|c| c.trim().to_string())
                .filter(|c| !c.is_empty())
        })
        .or_else(|| {
            std::env::var("GIT_SSH")
                .ok()
                .filter(|c| !c.trim().is_empty())
                .map(|p| shell_quote(&p))
        })
        .unwrap_or_else(|| "ssh".to_string());
    let mut argv: Vec<String> = Vec::new();
    if let Some(key) = identity {
        argv.extend([
            "-i".into(),
            key.display().to_string(),
            "-o".into(),
            "IdentitiesOnly=yes".into(),
        ]);
    }
    if let Some(port) = port {
        argv.extend(["-p".into(), port.to_string()]);
    }
    if seen {
        // After the caller's own options: `-v` raises the log level whatever LogLevel said.
        argv.push("-v".into());
    }
    argv.push(target.to_string());
    argv.push(verb.to_string());
    // As git runs it: the command through the shell, the arguments after it.
    let out = Command::new("sh")
        .arg("-c")
        .arg(format!("{ssh} \"$@\""))
        .arg(&ssh)
        .args(&argv)
        .stdin(Stdio::null())
        .output()
        .context("run ssh")?;
    let said = String::from_utf8_lossy(&out.stdout).to_string();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    let host_key = seen.then(|| host_key_in(&stderr)).flatten();
    if !out.status.success() {
        // The door's own line (stdout), else ssh's LAST line: the first is often a local
        // ssh_config warning that says nothing about the forge (and `-v`'s debug lines never do).
        let why = said
            .lines()
            .map(|l| l.trim().trim_start_matches("error: ").to_string())
            .find(|l| !l.is_empty())
            .or_else(|| {
                stderr
                    .lines()
                    .map(str::trim)
                    .rfind(|l| {
                        !l.is_empty()
                            && !l.starts_with("debug")
                            && !l.starts_with("OpenSSH_")
                            && !l.starts_with("Transferred:")
                            && !l.starts_with("Bytes per second")
                    })
                    .map(str::to_string)
            })
            .unwrap_or_else(|| format!("ssh exited {}", out.status));
        return Ok((Err(why), host_key));
    }
    Ok((Ok(said), host_key))
}

fn add_member(name: &str, keys: &[String], no_commit: bool) -> Result<()> {
    let repo = Repo::discover()?;
    let ring = repo.current_ring()?;
    let mut members = repo.members()?;
    for k in keys {
        members
            .add(name, &read_key(k)?)
            .map_err(|e| anyhow!("{e}"))?;
    }
    let keys_file = repo.write_scheme(&members, &ring)?;
    repo.save_ring(&ring, &keys_file)?;
    repo.git(&["add", paths::DIR])?;
    if !no_commit {
        repo.commit(&format!("gunnar crypt: add member {name}"), true)?;
    }
    println!(
        "{name} is a member: the key ring is now wrapped to {} key(s) of theirs. Push to share it; \
         they run `gunnar crypt unlock` in their clone.",
        keys.len()
    );
    Ok(())
}

/// Where `gunnar crypt rekey` took the member's new key from.
enum NewKeyFrom {
    /// `--key`: handed over by the member.
    Handed,
    /// `--from-server`, with the fingerprint the member read out (if given).
    Server(Option<String>),
}

/// **`gunnar crypt rekey NAME`** (CRYPT-PQ-REKEY): NAME's line takes their new key,
/// the ring is rotated and wrapped to the members' keys now, and committed.
fn rekey(repo: &Repo, name: &str, new: &str, from: NewKeyFrom, no_commit: bool) -> Result<()> {
    let mut members = repo.members()?;
    if !members.contains(name) {
        bail!("{name} is not a member of this encrypted repository (`gunnar crypt add-member` adds one)");
    }
    let new = new.trim();
    if members
        .entries()
        .iter()
        .any(|m| m.name == name && m.key_id() == new)
    {
        bail!("the key ring is wrapped to that key of {name}'s already: nothing to re-key");
    }
    // The new key is trusted as the pin trusts it (RA4-1): handed over, read out by
    // fingerprint, or the caller's own; else the pin of the old key refuses it.
    let fp = gunnar_crypt::door::fingerprint(new);
    let trusted = match &from {
        NewKeyFrom::Handed => true,
        NewKeyFrom::Server(Some(given)) if given.trim() == fp => true,
        NewKeyFrom::Server(Some(_)) => bail!(
            "the fingerprint you gave is not the one of the key the forge holds for {name} ({fp}): refused"
        ),
        NewKeyFrom::Server(None) => crate::crypt_key::pins::is_own(repo, new),
    };
    if !trusted {
        crate::crypt_key::pins::check_member(repo, name, new, None)?;
    }
    let mut ring = repo.current_ring()?;
    members.remove(name);
    members.add(name, new).map_err(|e| anyhow!("{e}"))?;
    crate::crypt_key::pins::replace_member(repo, name, new)?;
    let epoch = ring.rotate();
    let keys_file = repo.write_scheme(&members, &ring)?;
    repo.save_ring(&ring, &keys_file)?;
    repo.git(&["add", paths::DIR])?;
    if !no_commit {
        repo.commit(
            &format!(
                "gunnar crypt: rekey {name} to their new key {fp}, rotate to key epoch {epoch}"
            ),
            true,
        )?;
    }
    println!(
        "{name} is re-keyed: the key ring is wrapped to their new key {fp} and rotated to key epoch \
         {epoch}, so content committed from now on is sealed under a key their old one never receives. \
         The old key keeps what it could open up to now (the earlier epochs, and every copy of the old \
         ring in history). Push to share it; {name} runs `gunnar crypt unlock` with their new key."
    );
    Ok(())
}

fn remove_member(name: &str, no_commit: bool) -> Result<()> {
    let repo = Repo::discover()?;
    let mut ring = repo.current_ring()?;
    let mut members = repo.members()?;
    if members.remove(name) == 0 {
        bail!("{name} is not a member of this encrypted repository");
    }
    if members.is_empty() {
        bail!("{name} is the last member; an encrypted repository needs at least one");
    }
    let epoch = ring.rotate();
    let keys_file = repo.write_scheme(&members, &ring)?;
    repo.save_ring(&ring, &keys_file)?;
    repo.git(&["add", paths::DIR])?;
    if !no_commit {
        repo.commit(
            &format!("gunnar crypt: remove member {name}, rotate to key epoch {epoch}"),
            true,
        )?;
    }
    println!(
        "{name} is no longer a member. Content committed from now on is sealed under key epoch \
         {epoch}, which {name} never receives.\n\
         {name} can still read everything committed up to now: they held the key and may keep a \
         clone or a copy, and nothing can take that back. Revoke their access on the server too, \
         so they cannot fetch new commits at all."
    );
    Ok(())
}

/// What a level means, as `status` and `level` say it.
fn level_words(level: Level) -> &'static str {
    match level {
        Level::Classic => "classic (wrapped to members' X25519 encryption keys: not quantum-safe; \
                           `gunnar crypt level quantum-safe` upgrades it once every member has a quantum-safe key)",
        Level::QuantumSafe => "quantum-safe (wrapped only to ML-KEM-768 + X25519 age1pq1… keys)",
    }
}

/// Where `gunnar crypt level quantum-safe` takes members' quantum-safe keys from.
struct LevelKeys<'a> {
    keys: &'a [String],
    from_server: bool,
    fingerprints: &'a [String],
    repo: Option<&'a str>,
    at: Option<&'a str>,
    identity: Option<&'a Path>,
}

/// `NAME=VALUE` → `(NAME, VALUE)`.
fn named_value<'a>(flag: &str, text: &'a str) -> Result<(&'a str, &'a str)> {
    text.split_once('=')
        .filter(|(n, v)| !n.trim().is_empty() && !v.trim().is_empty())
        .map(|(n, v)| (n.trim(), v.trim()))
        .ok_or_else(|| {
            anyhow!("{flag} takes NAME=VALUE (the member's name, then the value): {text:?}")
        })
}

/// **`gunnar crypt level [quantum-safe]`**: say the level, or re-wrap a classic
/// repository to every member's quantum-safe key and rotate. The level only
/// goes up.
fn set_level(target: Option<Level>, from: &LevelKeys<'_>, no_commit: bool) -> Result<()> {
    let repo = Repo::discover()?;
    let members = repo.members()?;
    let Some(target) = target else {
        println!("level: {}", level_words(members.level()));
        return Ok(());
    };
    match (members.level(), target) {
        (Level::QuantumSafe, Level::Classic) => bail!(
            "this repository is quantum-safe, and the level only goes up: a ring wrapped classically again \
             would open to anyone who keeps a copy until quantum computers arrive, and Gunnar refuses the push"
        ),
        (now, then) if now == then => {
            println!("already {}: nothing to do.", now.word());
            return Ok(());
        }
        _ => {}
    }
    let mut keys: BTreeMap<String, String> = BTreeMap::new();
    for given in from.keys {
        let (name, key) = named_value("--key", given)?;
        keys.insert(name.to_string(), read_key(key)?);
    }
    let handed: Vec<String> = keys.keys().cloned().collect();
    let mut why: Vec<String> = Vec::new();
    if from.from_server {
        let mut fingerprints: BTreeMap<&str, &str> = BTreeMap::new();
        for f in from.fingerprints {
            let (name, fp) = named_value("--fingerprint", f)?;
            fingerprints.insert(name, fp);
        }
        for name in members.names() {
            if keys.contains_key(name) {
                continue;
            }
            match server_keys(name, from.repo, from.at, from.identity, Level::QuantumSafe) {
                Ok(held) => {
                    let key = held
                        .into_iter()
                        .next()
                        .expect("server_keys refuses an empty answer");
                    crate::crypt_key::pins::check_member(
                        &repo,
                        name,
                        &key,
                        fingerprints.get(name).copied(),
                    )?;
                    keys.insert(name.to_string(), key);
                }
                Err(e) => why.push(format!("{name}: {e:#}")),
            }
        }
    }
    let up = members
        .to_quantum_safe(&keys)
        .map_err(|e| match why.is_empty() {
            true => anyhow!("{e}"),
            false => anyhow!("{e}.\n  {}", why.join("\n  ")),
        })?;
    let mut ring = repo.current_ring()?;
    // A key handed over by hand is the member's: pinned as theirs, in place of the classic one.
    for name in &handed {
        let key = &keys[name];
        if let Some(old) = crate::crypt_key::pins::replace_member(&repo, name, key)? {
            eprintln!(
                "re-pinned {name}'s key on this machine: {} → {} (handed over with --key)",
                gunnar_crypt::door::fingerprint(&old),
                gunnar_crypt::door::fingerprint(key)
            );
        }
    }
    let epoch = ring.rotate();
    let keys_file = repo.write_scheme(&up, &ring)?;
    repo.save_ring(&ring, &keys_file)?;
    repo.git(&["add", paths::DIR])?;
    if !no_commit {
        repo.commit(
            &format!("gunnar crypt: level quantum-safe, rotate to key epoch {epoch}"),
            true,
        )?;
    }
    println!(
        "quantum-safe: the key ring is wrapped only to {} member(s)' age1pq1… keys and rotated to key \
         epoch {epoch}, so content committed from now on is sealed under a key that was never wrapped \
         classically. The earlier epochs stay in the ring for old content, and copies of the classic ring \
         in history stay as weak as they were. Push to share it; each member opens it with their \
         quantum-safe key.",
        up.names().len()
    );
    Ok(())
}

fn rotate(no_commit: bool) -> Result<()> {
    let repo = Repo::discover()?;
    let mut ring = repo.current_ring()?;
    let members = repo.members()?;
    let epoch = ring.rotate();
    let keys_file = repo.write_scheme(&members, &ring)?;
    repo.save_ring(&ring, &keys_file)?;
    repo.git(&["add", paths::DIR])?;
    if !no_commit {
        repo.commit(&format!("gunnar crypt: rotate to key epoch {epoch}"), true)?;
    }
    println!("rotated: new content is sealed under key epoch {epoch}; older epochs still open old content.");
    Ok(())
}

/// `gunnar crypt keygen`: a quantum-safe identity, made HERE, private half
/// 0600 and never sent anywhere.
fn keygen(out: Option<PathBuf>, force: bool) -> Result<()> {
    let path = match out {
        Some(p) => p,
        None => default_pq_identity().ok_or_else(|| anyhow!("no HOME: pass --out <path>"))?,
    };
    if path.exists() && !force {
        bail!(
            "{} already holds a key; rings wrapped to it open only with it. Pass --force to \
             replace it",
            path.display()
        );
    }
    if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
        }
    }
    let identity = gunnar_crypt::pq::Identity::generate();
    let created = utc_now();
    let _ = std::fs::remove_file(&path);
    write_private(
        &path,
        gunnar_crypt::pq::identity_file(&identity, &created).as_bytes(),
    )?;
    let public = identity.to_public().to_string();
    let pub_path = PathBuf::from(format!("{}.pub", path.display()));
    std::fs::write(&pub_path, format!("{public}\n"))
        .with_context(|| format!("write {}", pub_path.display()))?;
    println!(
        "made a quantum-safe key (age's hybrid ML-KEM-768 + X25519):\n  \
         private: {} (mode 0600; it never leaves this machine — back it up as you would an SSH key)\n  \
         public:  {}\n\n\
         Make it a quantum-safe way to open your encryption key: gunnar crypt key add-pq\n\
         For `gunnar crypt level quantum-safe`, hand it to the member who upgrades: --key <you>={}\n\
         (It can also be registered on your keys page in the console: the line in {}.)\n\n\
         Your SSH keys stay your login. They cannot be quantum-safe recipients: an ed25519 key's \
         wrap is X25519 and an RSA key's is RSA-OAEP, which a quantum computer breaks.",
        path.display(),
        pub_path.display(),
        pub_path.display(),
        pub_path.display(),
    );
    Ok(())
}

/// Now, as age-keygen's `# created:` line writes it (UTC).
fn utc_now() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
    // Days since 1970-01-01 to a civil date (H. Hinnant's algorithm).
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!(
        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
        rem / 3600,
        rem % 3600 / 60,
        rem % 60
    )
}

/// Tracked files whose bytes on disk are still the sealed blob in the index.
fn sealed_on_disk(repo: &Repo) -> Result<Vec<String>> {
    let listing = repo.git_bytes(&["ls-files", "-s", "-z"])?;
    let mut candidates: Vec<(String, String)> = Vec::new();
    for entry in listing.split(|b| *b == 0).filter(|e| !e.is_empty()) {
        let entry = String::from_utf8_lossy(entry);
        let Some((meta, path)) = entry.split_once('\t') else {
            continue;
        };
        let mut m = meta.split(' ');
        let (mode, oid) = (m.next().unwrap_or(""), m.next().unwrap_or(""));
        if mode == "160000" || mode == "120000" || is_scheme_path(path) {
            continue;
        }
        let mut head = [0u8; 8];
        let ok = std::fs::File::open(repo.top.join(path))
            .and_then(|mut f| f.read_exact(&mut head))
            .is_ok();
        if ok && envelope::has_magic(&head) {
            candidates.push((path.to_string(), oid.to_string()));
        }
    }
    if candidates.is_empty() {
        return Ok(vec![]);
    }
    // Only files whose disk bytes ARE the index blob: never overwrite an edit.
    let mut child = Command::new("git")
        .current_dir(&repo.top)
        .args(["hash-object", "--no-filters", "--stdin-paths"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()?;
    {
        let mut stdin = child.stdin.take().expect("piped");
        for (p, _) in &candidates {
            writeln!(stdin, "{p}")?;
        }
    }
    let out = child.wait_with_output()?;
    let hashes = String::from_utf8_lossy(&out.stdout).into_owned();
    Ok(candidates
        .into_iter()
        .zip(hashes.lines())
        .filter(|((_, oid), disk)| oid.as_str() == disk.trim())
        .map(|((p, _), _)| p)
        .collect())
}

/// Re-write files from the index, through whatever filter is configured now.
fn refresh(repo: &Repo, files: &[String]) -> Result<()> {
    for f in files {
        let _ = std::fs::remove_file(repo.top.join(f));
    }
    repo.git_paths(&["checkout"], files)
}

fn unlock(how: &crate::crypt_key::Openers) -> Result<()> {
    let repo = Repo::discover()?;
    let keys_file = repo.keys_file()?;
    let members = repo.members_text()?;
    // The person's encryption key, opened with one of its unlock methods; then the ring.
    let answer = crate::crypt_key::mine(&repo)?;
    let user = crate::crypt_key::open(&repo, &answer, how)?;
    let ring = wrap::unwrap_checked(&keys_file, &members, &[user.identity()])
        .map_err(|e| anyhow!("{e}"))?;
    if let Some(held) = repo.load_ring()? {
        held.check_continuity(&ring).map_err(|e| anyhow!("{e}"))?;
    }
    repo.save_ring(&ring, &keys_file)?;
    configure(&repo)?;
    let sealed = sealed_on_disk(&repo)?;
    refresh(&repo, &sealed)?;
    let still = sealed_on_disk(&repo)?;
    println!(
        "unlocked: {} key epoch(s), newest {} (key {}); {} file(s) decrypted.",
        ring.keys().len(),
        ring.newest().epoch(),
        ring.newest().id(),
        sealed.len() - still.len().min(sealed.len())
    );
    if !still.is_empty() {
        eprintln!(
            "warning: {} file(s) stay sealed (a key epoch this ring does not hold): {}",
            still.len(),
            still.join(", ")
        );
    }
    Ok(())
}

fn lock() -> Result<()> {
    let repo = Repo::discover()?;
    let dirty = repo.git(&["status", "--porcelain", "--untracked-files=no"])?;
    if !dirty.trim().is_empty() {
        bail!("commit or stash your changes first: locking rewrites the work tree");
    }
    let listing = repo.git(&["ls-files"])?;
    let files: Vec<String> = listing
        .lines()
        .filter(|p| !is_scheme_path(p))
        .map(str::to_string)
        .collect();
    let _ = std::fs::remove_file(repo.ring_path());
    let _ = std::fs::remove_file(repo.source_path());
    unconfigure(&repo);
    refresh(&repo, &files)?;
    println!("locked: the local key ring is gone and the work tree holds ciphertext.");
    Ok(())
}

fn status() -> Result<()> {
    let repo = Repo::discover()?;
    let keys_file = repo.keys_file()?;
    let members = repo.members()?;
    match repo.load_ring()? {
        Some(ring) => {
            let source = std::fs::read_to_string(repo.source_path()).unwrap_or_default();
            println!(
                "unlocked: {} key epoch(s), newest {} (key {})",
                ring.keys().len(),
                ring.newest().epoch(),
                ring.newest().id()
            );
            if source.trim() != file_id(&keys_file) {
                println!(
                    "  the key ring in the work tree changed since: run `gunnar crypt unlock`"
                );
            }
        }
        None => println!("locked: run `gunnar crypt unlock` to decrypt this clone"),
    }
    println!("level: {}", level_words(members.level()));
    let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for e in members.entries() {
        by_name
            .entry(e.name.as_str())
            .or_default()
            .push(match e.kind {
                KeyKind::AgePq => "age1pq (ML-KEM-768 + X25519)",
                _ => e.key.split_ascii_whitespace().next().unwrap_or(""),
            });
    }
    println!("members:");
    for name in members.names() {
        println!("  {name}: {}", by_name[name].join(", "));
    }
    Ok(())
}

fn diff(from: &str, to: &str, paths_filter: &[String]) -> Result<()> {
    let repo = Repo::discover()?;
    let ring = repo
        .load_ring()?
        .ok_or_else(|| anyhow!("this clone is locked: run `gunnar crypt unlock` first"))?;
    let mut args = vec!["diff-tree", "-r", "-z", "--no-renames", from, to, "--"];
    args.extend(paths_filter.iter().map(String::as_str));
    let raw = repo.git_bytes(&args)?;
    let mut fields = raw.split(|b| *b == 0).filter(|f| !f.is_empty());
    let zero = |oid: &str| oid.bytes().all(|b| b == b'0');
    let mut out = std::io::stdout().lock();
    while let (Some(meta), Some(path)) = (fields.next(), fields.next()) {
        let meta = String::from_utf8_lossy(meta);
        let path = String::from_utf8_lossy(path);
        let m: Vec<&str> = meta.trim_start_matches(':').split(' ').collect();
        if m.len() < 4 {
            continue;
        }
        let (old_oid, new_oid) = (m[2], m[3]);
        let blob = |oid: &str| -> Result<Option<Vec<u8>>> {
            if zero(oid) {
                Ok(None)
            } else {
                repo.git_bytes(&["cat-file", "blob", oid]).map(Some)
            }
        };
        let (old, new) = (blob(old_oid)?, blob(new_oid)?);
        let d = gunnar_crypt::diff_blobs(&ring, old.as_deref(), new.as_deref())
            .map_err(|e| anyhow!("{path}: {e}"))?;
        writeln!(out, "diff --gunnar-crypt a/{path} b/{path}")?;
        writeln!(
            out,
            "--- {}",
            if old.is_some() {
                format!("a/{path}")
            } else {
                "/dev/null".into()
            }
        )?;
        writeln!(
            out,
            "+++ {}",
            if new.is_some() {
                format!("b/{path}")
            } else {
                "/dev/null".into()
            }
        )?;
        write!(out, "{}", gunnar_crypt::diff::unified(&d))?;
    }
    Ok(())
}

// ── git's drivers ───────────────────────────────────────────────────────────
//
// **Bounded memory** (envelope v2, 2026-10-06). git hands a filter the WHOLE
// file before it reads an answer (both protocols), so the input is SPOOLED:
// in memory up to `SPOOL_IN_MEMORY`, then in an unnamed temporary file in the
// clone's own git directory (mode 0600, unlinked at once; the work tree beside
// it holds the same plaintext). Clean MACs the plaintext for the v2 file nonce
// while it spools, then seals from the spool one chunk at a time; smudge
// verifies a spooled envelope block by block, then opens it again to git. What
// the filter holds is the spool's memory part and one chunk, whatever the
// file's size. (git itself still holds a filtered file whole: that is git's
// convert.c, not this filter.)

#[derive(Clone, Copy)]
enum Mode {
    Clean,
    Smudge,
}

/// Bytes a spool keeps in memory before it moves to a file.
const SPOOL_IN_MEMORY: usize = 4 << 20;

/// A file's bytes as git handed them over, kept to be read back (twice, for
/// a clean that checks the index's envelope, or a smudge that verifies first).
struct Spool {
    mem: gunnar_crypt::zeroize::Zeroizing<Vec<u8>>,
    file: Option<std::fs::File>,
    dir: Option<PathBuf>,
    len: u64,
    head: Vec<u8>,
}

/// The first bytes a spool remembers for the format checks (a v2 header).
const HEAD_LEN: usize = envelope::V2_HEADER_LEN;

impl Spool {
    /// A spool that spills into `dir` (the clone's state directory), or into
    /// the system's temporary directory when there is no clone. `mem` is the
    /// filter's one reusable buffer, its capacity reserved once
    /// (`SPOOL_IN_MEMORY`): it never moves, so no reallocation leaves a copy
    /// of a file behind, and it is zeroed when the filter ends.
    fn new(dir: Option<PathBuf>, mut mem: gunnar_crypt::zeroize::Zeroizing<Vec<u8>>) -> Spool {
        mem.clear();
        if mem.capacity() < SPOOL_IN_MEMORY {
            mem.reserve_exact(SPOOL_IN_MEMORY);
        }
        Spool {
            mem,
            file: None,
            dir,
            len: 0,
            head: Vec::with_capacity(HEAD_LEN),
        }
    }

    fn in_memory(&self) -> bool {
        self.file.is_none()
    }

    /// The whole content, when it is still in memory.
    fn bytes(&self) -> Option<&[u8]> {
        self.in_memory().then_some(&self.mem[..])
    }

    /// Read it back from the start.
    fn reader(&mut self) -> std::io::Result<Box<dyn Read + '_>> {
        use std::io::{Seek, SeekFrom};
        match &mut self.file {
            None => Ok(Box::new(&self.mem[..])),
            Some(f) => {
                f.seek(SeekFrom::Start(0))?;
                Ok(Box::new(BufReader::with_capacity(256 << 10, &*f)))
            }
        }
    }

    fn spill(&mut self) -> std::io::Result<()> {
        let mut f = match &self.dir {
            Some(d) if std::fs::create_dir_all(d).is_ok() => tempfile::tempfile_in(d)?,
            _ => tempfile::tempfile()?,
        };
        f.write_all(&self.mem)?;
        self.file = Some(f);
        // From here on the bytes live in the file only.
        self.mem.clear();
        Ok(())
    }
}

impl Write for Spool {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        if self.head.len() < HEAD_LEN {
            let k = (HEAD_LEN - self.head.len()).min(buf.len());
            self.head.extend_from_slice(&buf[..k]);
        }
        if self.file.is_none() && self.mem.len() + buf.len() > SPOOL_IN_MEMORY {
            self.spill()?;
        }
        match &mut self.file {
            Some(f) => f.write_all(buf)?,
            // Within the reserved capacity: never a reallocation.
            None => self.mem.extend_from_slice(buf),
        }
        self.len += buf.len() as u64;
        Ok(buf.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

/// A writer that only compares what it is given with what `other` reads:
/// the opened index envelope against the spooled file.
struct SameAs<R: Read> {
    other: R,
    buf: Vec<u8>,
}

impl<R: Read> Write for SameAs<R> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        self.buf.resize(data.len(), 0);
        self.other.read_exact(&mut self.buf)?;
        if self.buf[..] != *data {
            return Err(std::io::Error::other("the file differs"));
        }
        Ok(data.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

/// The size of the envelope a v2 seal of `len` plaintext bytes makes.
fn v2_len(len: u64) -> u64 {
    let chunk = u64::from(envelope::CHUNK);
    let blocks = len.div_ceil(chunk).max(1);
    envelope::V2_HEADER_LEN as u64 + blocks * envelope::TAG_LEN as u64 + len
}

/// `git cat-file --batch-check`, kept for the filter's life: the size of the
/// blob the index holds for a path, without a process per file.
struct IndexSizes {
    child: std::process::Child,
    stdin: std::process::ChildStdin,
    stdout: BufReader<std::process::ChildStdout>,
}

impl IndexSizes {
    fn start(top: &Path) -> Option<IndexSizes> {
        let mut child = Command::new("git")
            .current_dir(top)
            .args(["cat-file", "--batch-check"])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .ok()?;
        let stdin = child.stdin.take()?;
        let stdout = BufReader::new(child.stdout.take()?);
        Some(IndexSizes {
            child,
            stdin,
            stdout,
        })
    }

    /// The size of the index's blob at `path`; `None` when there is none.
    fn size(&mut self, path: &str) -> Option<u64> {
        if path.contains('\n') {
            return None;
        }
        writeln!(self.stdin, ":{path}").ok()?;
        self.stdin.flush().ok()?;
        let mut line = String::new();
        self.stdout.read_line(&mut line).ok()?;
        // `<oid> blob <size>`, or `<name> missing`.
        let mut f = line.split_ascii_whitespace();
        let (_, kind, size) = (f.next()?, f.next()?, f.next()?);
        (kind == "blob").then(|| size.parse().ok()).flatten()
    }
}

impl Drop for IndexSizes {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// What clean sends back.
enum CleanPlan {
    /// The input as it is (it is already an envelope).
    Verbatim,
    /// The index's envelope for this path: the file is unchanged, and the
    /// envelope is v1 or under an older epoch, so a re-seal would differ and
    /// show the file as modified (after a rotation, or across v1 → v2).
    Index(String),
    /// A fresh v2 seal under the newest epoch, with this file nonce.
    Seal([u8; envelope::NONCE_LEN]),
}

/// What smudge sends back.
enum SmudgePlan {
    /// The input as it is: plaintext, a locked clone, or an envelope that
    /// does not open here (left sealed, with a warning).
    Verbatim,
    /// A small envelope, opened (in the filter's result buffer).
    Opened,
    /// A spooled envelope that verified: open it again, to git.
    Stream,
}

/// What a filter run knows: the clone and its ring (none when locked).
struct Filter {
    repo: Option<Repo>,
    ring: Option<Keyring>,
    sizes: std::cell::RefCell<Option<IndexSizes>>,
    /// Reused for every file, zeroed when the filter ends: the spool's
    /// memory part, the read buffer, and a small file's sealed or opened
    /// result (sealed and opened IN PLACE there, so a small file is copied
    /// once on the way in and once on the way out).
    mem: std::cell::RefCell<gunnar_crypt::zeroize::Zeroizing<Vec<u8>>>,
    inbuf: std::cell::RefCell<gunnar_crypt::zeroize::Zeroizing<Vec<u8>>>,
    result: std::cell::RefCell<gunnar_crypt::zeroize::Zeroizing<Vec<u8>>>,
}

impl Filter {
    fn load() -> Filter {
        let repo = Repo::discover().ok();
        let ring = repo.as_ref().and_then(|r| r.load_ring().ok().flatten());
        let buf = || std::cell::RefCell::new(gunnar_crypt::zeroize::Zeroizing::new(Vec::new()));
        Filter {
            repo,
            ring,
            sizes: std::cell::RefCell::new(None),
            mem: buf(),
            inbuf: buf(),
            result: buf(),
        }
    }

    /// A spool for one file, spilling into this clone's state directory.
    fn spool(&self) -> Spool {
        let mem = std::mem::take(&mut *self.mem.borrow_mut());
        Spool::new(self.repo.as_ref().map(Repo::state_dir), mem)
    }

    /// Done with a file: its spool's buffer goes back for the next one.
    fn recycle(&self, spool: Spool) {
        *self.mem.borrow_mut() = spool.mem;
    }

    /// The result buffer, empty, with room for `len` bytes reserved once and
    /// for good (`SPOOL_IN_MEMORY` and a little: a small file's envelope).
    fn result(&self) -> std::cell::RefMut<'_, gunnar_crypt::zeroize::Zeroizing<Vec<u8>>> {
        let mut r = self.result.borrow_mut();
        r.clear();
        let room = SPOOL_IN_MEMORY + (64 << 10);
        if r.capacity() < room {
            r.reserve_exact(room);
        }
        r
    }

    /// The file nonce to compute while a clean spools its input.
    fn nonce(&self) -> Option<envelope::FileNonce> {
        self.ring
            .as_ref()
            .map(|r| envelope::FileNonce::new(r.newest()))
    }

    /// Spool `input` (the nonce computed on the way when `clean`).
    fn read_in(
        &self,
        input: &mut impl Read,
        clean: bool,
    ) -> Result<(Spool, Option<envelope::FileNonce>)> {
        let mut spool = self.spool();
        let mut nonce = if clean { self.nonce() } else { None };
        let mut buf = self.inbuf.borrow_mut();
        if buf.len() < 64 << 10 {
            buf.resize(64 << 10, 0);
        }
        loop {
            let n = match input.read(&mut buf) {
                Ok(0) => break,
                Ok(n) => n,
                Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                Err(e) => return Err(e.into()),
            };
            if let Some(m) = &mut nonce {
                m.update(&buf[..n]);
            }
            spool.write_all(&buf[..n])?;
        }
        Ok((spool, nonce))
    }

    fn plan_clean(
        &self,
        path: Option<&str>,
        spool: &mut Spool,
        nonce: Option<envelope::FileNonce>,
    ) -> Result<CleanPlan> {
        if envelope::has_magic(&spool.head) {
            // Already sealed (a file checked out while locked, added back).
            return Ok(CleanPlan::Verbatim);
        }
        let ring = self.ring.as_ref().ok_or_else(|| {
            anyhow!(
                "this clone of an encrypted repository is locked, so the file cannot be sealed \
                 and is NOT added: run `gunnar crypt unlock`"
            )
        })?;
        let nonce = match nonce {
            Some(n) => n.finish(),
            None => {
                // Not computed on the way in: one more read of the spool.
                let mut n = envelope::FileNonce::new(ring.newest());
                let mut r = spool.reader()?;
                let mut buf = gunnar_crypt::zeroize::Zeroizing::new(vec![0u8; 64 << 10]);
                loop {
                    let k = r.read(&mut buf)?;
                    if k == 0 {
                        break;
                    }
                    n.update(&buf[..k]);
                }
                n.finish()
            }
        };
        if let (Some(repo), Some(path)) = (&self.repo, path) {
            if self.unchanged_in_index(repo, ring, path, spool)? {
                return Ok(CleanPlan::Index(path.to_string()));
            }
        }
        Ok(CleanPlan::Seal(nonce))
    }

    /// Is the index's envelope for `path` one that a fresh seal would NOT
    /// reproduce (v1, or an older epoch) and that opens to exactly the
    /// spooled file? An UNCHANGED file must clean to the blob it already is,
    /// or every touched file shows as modified: after a rotation, and across
    /// v1 → v2 (an unchanged v1 file stays v1, so no repository-wide diff).
    /// A v2 envelope under the newest epoch needs no look: the seal is
    /// deterministic, so an unchanged file seals to the same bytes.
    fn unchanged_in_index(
        &self,
        repo: &Repo,
        ring: &Keyring,
        path: &str,
        spool: &mut Spool,
    ) -> Result<bool> {
        let size = {
            let mut sizes = self.sizes.borrow_mut();
            if sizes.is_none() {
                *sizes = IndexSizes::start(&repo.top);
            }
            match sizes.as_mut().and_then(|s| s.size(path)) {
                Some(s) => s,
                None => return Ok(false),
            }
        };
        // From the sizes alone: an unchanged file under v1 is 29 bytes
        // longer, under v2 `v2_len`. Anything else changed.
        let maybe_v1 = size == spool.len + envelope::MIN_LEN as u64;
        let maybe_v2 = size == v2_len(spool.len);
        if !maybe_v1 && !(maybe_v2 && ring.keys().len() > 1) {
            return Ok(false);
        }
        let mut child = Command::new("git")
            .current_dir(&repo.top)
            .args(["cat-file", "blob", &format!(":{path}")])
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .context("run git cat-file")?;
        let mut out = BufReader::with_capacity(256 << 10, child.stdout.take().expect("piped"));
        let len = spool.len;
        let same = (|| -> Result<bool> {
            let mut head = [0u8; envelope::V2_HEADER_LEN];
            let mut got = 0;
            while got < head.len() {
                match out.read(&mut head[got..])? {
                    0 => break,
                    k => got += k,
                }
            }
            let head = &head[..got];
            if !envelope::has_magic(head) || envelope::is_current(ring, head) {
                return Ok(false);
            }
            let mut same = SameAs {
                other: spool.reader()?,
                buf: Vec::new(),
            };
            let opened = envelope::open_stream(ring, &mut head.chain(&mut out), &mut same);
            Ok(matches!(opened, Ok(n) if n == len))
        })();
        let _ = child.kill();
        let _ = child.wait();
        same
    }

    /// Write what `plan` says to `out`.
    fn emit_clean(&self, plan: CleanPlan, spool: &mut Spool, out: &mut impl Write) -> Result<()> {
        match plan {
            CleanPlan::Verbatim => {
                std::io::copy(&mut spool.reader()?, out)?;
            }
            CleanPlan::Index(path) => {
                let repo = self.repo.as_ref().expect("an index plan has a clone");
                let mut child = Command::new("git")
                    .current_dir(&repo.top)
                    .args(["cat-file", "blob", &format!(":{path}")])
                    .stdout(Stdio::piped())
                    .stderr(Stdio::null())
                    .spawn()
                    .context("run git cat-file")?;
                let copied = std::io::copy(&mut child.stdout.take().expect("piped"), out);
                let status = child.wait()?;
                copied?;
                if !status.success() {
                    bail!("git cat-file :{path} failed");
                }
            }
            CleanPlan::Seal(nonce) => {
                let ring = self.ring.as_ref().expect("a file to seal has a ring");
                match spool.bytes() {
                    // Small: sealed in place in the result buffer.
                    Some(plain) => {
                        let mut sealed = self.result();
                        envelope::seal_into(ring.newest(), &nonce, plain, &mut sealed);
                        out.write_all(&sealed)?;
                    }
                    None => {
                        envelope::seal_stream(ring.newest(), &nonce, &mut spool.reader()?, out)?;
                    }
                }
            }
        }
        Ok(())
    }

    fn plan_smudge(&self, path: Option<&str>, spool: &mut Spool) -> SmudgePlan {
        if !envelope::has_magic(&spool.head) {
            return SmudgePlan::Verbatim;
        }
        let Some(ring) = &self.ring else {
            return SmudgePlan::Verbatim;
        };
        let left_sealed = |e: &dyn std::fmt::Display| {
            eprintln!(
                "gunnar crypt: {}: {e} — left sealed",
                path.unwrap_or("(a file)")
            );
            SmudgePlan::Verbatim
        };
        if let Some(bytes) = spool.bytes() {
            // Small: opened in place in the result buffer, all of it before
            // git is told anything.
            return match envelope::open_into(ring, bytes, &mut self.result()) {
                Ok(()) => SmudgePlan::Opened,
                Err(e) => left_sealed(&e),
            };
        }
        // Spooled: verify every block before git is told anything, so a
        // file that does not open is left sealed whatever its size.
        let verified = spool
            .reader()
            .map_err(envelope::StreamError::Io)
            .and_then(|mut r| envelope::open_stream(ring, &mut r, &mut std::io::sink()));
        match verified {
            Ok(n) if n == envelope::plaintext_len(&spool.head, spool.len).unwrap_or(u64::MAX) => {
                SmudgePlan::Stream
            }
            Ok(_) => left_sealed(&envelope::OpenError::Corrupt),
            Err(envelope::StreamError::Open(e)) => left_sealed(&e),
            Err(envelope::StreamError::Io(e)) => left_sealed(&e),
        }
    }

    fn emit_smudge(&self, plan: SmudgePlan, spool: &mut Spool, out: &mut impl Write) -> Result<()> {
        match plan {
            SmudgePlan::Verbatim => {
                std::io::copy(&mut spool.reader()?, out)?;
            }
            SmudgePlan::Opened => out.write_all(&self.result.borrow())?,
            SmudgePlan::Stream => {
                let ring = self.ring.as_ref().expect("a stream plan has a ring");
                envelope::open_stream(ring, &mut spool.reader()?, out)
                    .map_err(|e| anyhow!("{e}"))?;
            }
        }
        Ok(())
    }
}

fn filter_once(mode: Mode, path: Option<&str>) -> Result<()> {
    let f = Filter::load();
    let (mut spool, nonce) =
        f.read_in(&mut std::io::stdin().lock(), matches!(mode, Mode::Clean))?;
    let mut out = BufWriter::with_capacity(256 << 10, std::io::stdout().lock());
    match mode {
        Mode::Clean => {
            let plan = f.plan_clean(path, &mut spool, nonce)?;
            f.emit_clean(plan, &mut spool, &mut out)?;
        }
        Mode::Smudge => {
            let plan = f.plan_smudge(path, &mut spool);
            f.emit_smudge(plan, &mut spool, &mut out)?;
        }
    }
    out.flush()?;
    Ok(())
}

// git's long-running filter protocol (gitattributes(5), "Long Running
// Filter Process"): pkt-lines over stdin/stdout, version 2.

const MAX_PKT_DATA: usize = 65516;

/// One pkt-line into `buf`; `None` is a flush packet.
fn read_pkt_into(r: &mut impl BufRead, buf: &mut Vec<u8>) -> Result<Option<usize>> {
    let mut len = [0u8; 4];
    r.read_exact(&mut len)?;
    let len = usize::from_str_radix(std::str::from_utf8(&len)?, 16)
        .context("a malformed pkt-line length")?;
    if len == 0 {
        return Ok(None);
    }
    if !(4..=MAX_PKT_DATA + 4).contains(&len) {
        bail!("an unexpected pkt-line length {len:04x}");
    }
    buf.resize(len - 4, 0);
    r.read_exact(buf)?;
    Ok(Some(len - 4))
}

/// One pkt-line; `None` is a flush packet.
fn read_pkt(r: &mut impl BufRead) -> Result<Option<Vec<u8>>> {
    let mut buf = Vec::new();
    Ok(read_pkt_into(r, &mut buf)?.map(|_| buf))
}

/// Text packets up to a flush; `None` at a clean end of input.
fn read_list(r: &mut impl BufRead) -> Result<Option<Vec<String>>> {
    if r.fill_buf()?.is_empty() {
        return Ok(None);
    }
    let mut out = Vec::new();
    while let Some(p) = read_pkt(r)? {
        let s = String::from_utf8_lossy(&p);
        out.push(s.trim_end_matches('\n').to_string());
    }
    Ok(Some(out))
}

/// The content packets up to a flush, as one reader.
struct PktContent<'r, R: BufRead> {
    r: &'r mut R,
    /// The filter's one packet buffer, reused for every request.
    buf: &'r mut Vec<u8>,
    at: usize,
    done: bool,
}

impl<R: BufRead> Read for PktContent<'_, R> {
    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
        while self.at == self.buf.len() {
            if self.done {
                return Ok(0);
            }
            self.at = 0;
            match read_pkt_into(self.r, self.buf).map_err(std::io::Error::other)? {
                Some(_) => {}
                None => {
                    self.buf.clear();
                    self.done = true;
                }
            }
        }
        let n = out.len().min(self.buf.len() - self.at);
        out[..n].copy_from_slice(&self.buf[self.at..self.at + n]);
        self.at += n;
        Ok(n)
    }
}

/// Content out as pkt-lines of the largest size git takes.
struct PktWriter<'w, W: Write> {
    w: &'w mut W,
    /// The filter's one packet buffer for short writes, reused.
    buf: &'w mut Vec<u8>,
}

impl<'w, W: Write> PktWriter<'w, W> {
    fn new(w: &'w mut W, buf: &'w mut Vec<u8>) -> Self {
        buf.clear();
        PktWriter { w, buf }
    }

    /// The last packet (the caller writes the flush).
    fn finish(mut self) -> std::io::Result<()> {
        self.send()
    }

    fn send(&mut self) -> std::io::Result<()> {
        if !self.buf.is_empty() {
            write!(self.w, "{:04x}", self.buf.len() + 4)?;
            self.w.write_all(self.buf)?;
            self.buf.clear();
        }
        Ok(())
    }
}

impl<W: Write> Write for PktWriter<'_, W> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        if self.buf.is_empty() && data.len() >= MAX_PKT_DATA {
            // A whole packet straight from the caller's bytes.
            write!(self.w, "{:04x}", MAX_PKT_DATA + 4)?;
            self.w.write_all(&data[..MAX_PKT_DATA])?;
            return Ok(MAX_PKT_DATA);
        }
        let n = data.len().min(MAX_PKT_DATA - self.buf.len());
        self.buf.extend_from_slice(&data[..n]);
        if self.buf.len() == MAX_PKT_DATA {
            self.send()?;
        }
        Ok(n)
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

fn write_pkt(w: &mut impl Write, data: &[u8]) -> Result<()> {
    write!(w, "{:04x}", data.len() + 4)?;
    w.write_all(data)?;
    Ok(())
}

fn write_text(w: &mut impl Write, s: &str) -> Result<()> {
    write_pkt(w, format!("{s}\n").as_bytes())
}

fn flush_pkt(w: &mut impl Write) -> Result<()> {
    w.write_all(b"0000")?;
    Ok(())
}

fn filter_process() -> Result<()> {
    let stdin = std::io::stdin();
    let mut r = BufReader::with_capacity(256 << 10, stdin.lock());
    let stdout = std::io::stdout();
    let mut w = BufWriter::with_capacity(256 << 10, stdout.lock());

    let hello = read_list(&mut r)?.unwrap_or_default();
    if hello.first().map(String::as_str) != Some("git-filter-client")
        || !hello.iter().any(|l| l == "version=2")
    {
        bail!("not git's filter protocol version 2: {hello:?}");
    }
    write_text(&mut w, "git-filter-server")?;
    write_text(&mut w, "version=2")?;
    flush_pkt(&mut w)?;
    w.flush()?;
    let caps = read_list(&mut r)?.unwrap_or_default();
    for cap in ["clean", "smudge"] {
        if caps.iter().any(|c| c == &format!("capability={cap}")) {
            write_text(&mut w, &format!("capability={cap}"))?;
        }
    }
    flush_pkt(&mut w)?;
    w.flush()?;

    let f = Filter::load();
    // One packet buffer each way for the filter's life (the incoming one
    // holds plaintext on a clean: zeroed when the filter ends).
    let mut pkt_in = gunnar_crypt::zeroize::Zeroizing::new(Vec::with_capacity(MAX_PKT_DATA));
    let mut pkt_out = Vec::with_capacity(MAX_PKT_DATA);
    while let Some(headers) = read_list(&mut r)? {
        let get = |k: &str| {
            headers
                .iter()
                .find_map(|h| h.strip_prefix(&format!("{k}=")).map(str::to_string))
        };
        let command = get("command").unwrap_or_default();
        let pathname = get("pathname");
        let path = pathname.as_deref();
        let mode = match command.as_str() {
            "clean" => Some(Mode::Clean),
            "smudge" => Some(Mode::Smudge),
            _ => None,
        };
        // git sends the whole file before it reads a word: spool it.
        pkt_in.clear();
        let mut content = PktContent {
            r: &mut r,
            buf: &mut pkt_in,
            at: 0,
            done: false,
        };
        let (mut spool, nonce) = f.read_in(&mut content, matches!(mode, Some(Mode::Clean)))?;
        let planned = match mode {
            Some(Mode::Clean) => f.plan_clean(path, &mut spool, nonce).map(Ok),
            Some(Mode::Smudge) => Ok(Err(f.plan_smudge(path, &mut spool))),
            None => Err(anyhow!("unknown filter command {command:?}")),
        };
        let plan = match planned {
            Ok(plan) => plan,
            Err(e) => {
                eprintln!("gunnar crypt: {}: {e:#}", path.unwrap_or("(a file)"));
                write_text(&mut w, "status=error")?;
                flush_pkt(&mut w)?;
                w.flush()?;
                f.recycle(spool);
                continue;
            }
        };
        write_text(&mut w, "status=success")?;
        flush_pkt(&mut w)?;
        let mut pkt = PktWriter::new(&mut w, &mut pkt_out);
        let sent = match plan {
            Ok(clean) => f.emit_clean(clean, &mut spool, &mut pkt),
            Err(smudge) => f.emit_smudge(smudge, &mut spool, &mut pkt),
        }
        .and_then(|()| pkt.finish().map_err(Into::into));
        flush_pkt(&mut w)?;
        match sent {
            // An empty list: the status stays "success".
            Ok(()) => flush_pkt(&mut w)?,
            Err(e) => {
                // Content was (partly) sent; git discards it on this status.
                eprintln!("gunnar crypt: {}: {e:#}", path.unwrap_or("(a file)"));
                write_text(&mut w, "status=error")?;
                flush_pkt(&mut w)?;
            }
        }
        w.flush()?;
        f.recycle(spool);
    }
    Ok(())
}

/// A writer that counts what passes through.
struct Counted<'w, W: Write> {
    w: &'w mut W,
    n: u64,
}

impl<W: Write> Write for Counted<'_, W> {
    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
        let k = self.w.write(data)?;
        self.n += k as u64;
        Ok(k)
    }

    fn flush(&mut self) -> std::io::Result<()> {
        self.w.flush()
    }
}

fn textconv(file: &Path) -> Result<()> {
    let f = Filter::load();
    let mut input = std::io::BufReader::with_capacity(
        256 << 10,
        std::fs::File::open(file).with_context(|| format!("read {}", file.display()))?,
    );
    let mut out = BufWriter::with_capacity(256 << 10, std::io::stdout().lock());
    let sealed = envelope::has_magic(input.fill_buf()?);
    match (&f.ring, sealed) {
        (_, false) => {
            std::io::copy(&mut input, &mut out)?;
        }
        (Some(ring), true) => {
            // Streamed: a diff of a large sealed file holds one chunk here.
            // An envelope that does not open is the marker alone; one that
            // fails part-way ends its text with the marker.
            let mut counted = Counted { w: &mut out, n: 0 };
            if let Err(e) = envelope::open_stream(ring, &mut input, &mut counted) {
                let nl = if counted.n > 0 { "\n" } else { "" };
                writeln!(out, "{nl}[gunnar-crypt: sealed — {e}]")?;
            }
        }
        (None, true) => {
            out.write_all(b"[gunnar-crypt: sealed \xe2\x80\x94 this clone is locked]\n")?
        }
    }
    out.flush()?;
    Ok(())
}

fn merge(
    base: &Path,
    ours: &Path,
    theirs: &Path,
    marker_size: usize,
    path: Option<&str>,
) -> Result<()> {
    let repo = Repo::discover()?;
    let ring = repo.load_ring()?.ok_or_else(|| {
        anyhow!("this clone is locked: run `gunnar crypt unlock`, then merge again")
    })?;
    let open = |p: &Path| -> Result<Vec<u8>> {
        let b = std::fs::read(p).with_context(|| format!("read {}", p.display()))?;
        gunnar_crypt::open_blob(&ring, &b).map_err(|e| anyhow!("{}: {e}", path.unwrap_or("?")))
    };
    let (o, a, b) = (open(base)?, open(ours)?, open(theirs)?);
    let tmp = repo
        .state_dir()
        .join(format!("merge-{}", std::process::id()));
    std::fs::create_dir_all(&tmp)?;
    let (fa, fo, fb) = (tmp.join("ours"), tmp.join("base"), tmp.join("theirs"));
    let result = (|| -> Result<Option<i32>> {
        write_private(&fa, &a)?;
        write_private(&fo, &o)?;
        write_private(&fb, &b)?;
        let status = Command::new("git")
            .args([
                "merge-file",
                &format!("--marker-size={marker_size}"),
                "-L",
                "ours",
                "-L",
                "base",
                "-L",
                "theirs",
            ])
            .arg(&fa)
            .arg(&fo)
            .arg(&fb)
            .status()
            .context("run git merge-file")?;
        let merged = std::fs::read(&fa)?;
        std::fs::write(ours, envelope::seal(ring.newest(), &merged))?;
        Ok(status.code())
    })();
    let _ = std::fs::remove_dir_all(&tmp);
    match result? {
        Some(0) => Ok(()),
        Some(n) if (1..128).contains(&n) => std::process::exit(1),
        other => bail!("git merge-file failed ({other:?})"),
    }
}