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:?})"),
}
}