Allow quire mirror push over SSH dispatch
Assisted-by: GLM-5.3 via pi
change vtnkpyxzuzopyoomnwkmxyxxwmrzptwp
commit 04946180f4ca3b6d92a8e044c3afd9835d378efd
author Alpha Chen <alpha@kejadlen.dev>
date
parent xpulzynp
diff --git a/README.md b/README.md
index 5c3532a..63852f8 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,9 @@ jj git remote add origin git@host:foo.git
 jj git push
 ```
 
-Other management commands: `quire repo list` and `quire repo rm foo.git`, invoked the same way via SSH.
+Other management commands: `quire repo list`, `quire repo rm foo.git`, and
+`quire mirror push foo.git refs/heads/main` (re-trigger mirroring of a ref),
+invoked the same way via SSH.
 
 ## Design principles
 
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 1293490..09be7d6 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -122,7 +122,7 @@ Things most likely to go wrong here:
 
 ### 2. `quire exec` dispatch subcommand
 
-Replace the ad-hoc shell dispatch from step 1 with `docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. The `quire exec` subcommand takes the original command string, parses it properly (shell-style with a real parser, not regex), validates it against a strict allowlist — `git-receive-pack`, `git-upload-pack`, `git-upload-archive`, and a specific set of `quire` subcommands (`new`, `list`, `rm`) — and execs the appropriate binary.
+Replace the ad-hoc shell dispatch from step 1 with `docker exec -i quire-container quire exec "$SSH_ORIGINAL_COMMAND"`. The `quire exec` subcommand takes the original command string, parses it properly (shell-style with a real parser, not regex), validates it against a strict allowlist — `git-receive-pack`, `git-upload-pack`, `git-upload-archive`, and a specific set of `quire` subcommands (`repo new`, `repo list`, `repo rm`, `mirror push`) — and execs the appropriate binary.
 
 **This is the only dispatch surface into the container.** There's no sshd in the container to backstop a permissive parser; anything that gets past `quire exec` runs as trusted. The allowlist is the security boundary — not a UX convenience, the actual boundary. Treat it that way: explicit enumeration, reject by default, no regex-based "looks safe enough," tests for the rejection paths as well as the accept paths.
 
diff --git a/docs/host/README.md b/docs/host/README.md
index f191de2..b256381 100644
--- a/docs/host/README.md
+++ b/docs/host/README.md
@@ -63,9 +63,10 @@ Reference configs for dispatching SSH connections into the quire container.
 
 SSH dispatch is handled by `quire exec` inside the container. The sshd
 ForceCommand passes `$SSH_ORIGINAL_COMMAND` directly to the binary,
-which validates the git command against an allowlist (git-receive-pack,
-git-upload-pack, git-upload-archive) and sanitizes the repository path
-before exec'ing the git subprocess.
+which validates the command against an allowlist (git-receive-pack,
+git-upload-pack, git-upload-archive, and the `quire repo`/`quire mirror
+push` subcommands) and sanitizes the repository path
+before exec'ing the appropriate subprocess.
 
 The container image doesn't bake in a `quire` user — it runs as whatever
 uid/gid the host passes via `--user`. This avoids "dubious ownership"
diff --git a/quire-server/src/bin/quire/commands/exec.rs b/quire-server/src/bin/quire/commands/exec.rs
index 96da931..79205fa 100644
--- a/quire-server/src/bin/quire/commands/exec.rs
+++ b/quire-server/src/bin/quire/commands/exec.rs
@@ -64,9 +64,36 @@ fn dispatch_git(quire: &Quire, git_cmd: &str, args: &[String]) -> Result<()> {
 fn dispatch_quire(args: &[String]) -> Result<()> {
     ensure!(!args.is_empty(), "no quire subcommand provided");
 
-    ensure!(args[0] == "repo", "unsupported quire command: {}", args[0]);
+    // Allowlist: `repo <any>` and `mirror push <repo> <ref>`. An explicit mirror
+    // push grants no new capability over SSH — pushing already mirrors every
+    // updated ref — it just re-triggers after a mirror-side failure.
+    let words: Vec<&str> = args.iter().map(String::as_str).collect();
+    ensure!(
+        matches!(words.as_slice(), ["repo", ..] | ["mirror", "push", ..]),
+        "unsupported quire command: {}",
+        args.join(" ")
+    );
 
-    tracing::info!(subcmd = "repo", "dispatching quire command");
-    let err = Command::new("quire").arg("repo").args(&args[1..]).exec();
+    tracing::info!(subcmd = %args[0], "dispatching quire command");
+    let err = Command::new("quire").args(args).exec();
     bail!("exec failed: {err}");
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn mirror_dispatch_requires_push_subcommand() {
+        assert!(dispatch_quire(&["mirror".into()]).is_err());
+        assert!(dispatch_quire(&["mirror".into(), "status".into()]).is_err());
+    }
+
+    #[test]
+    fn non_allowlisted_quire_commands_are_rejected() {
+        // `serve` and `ci` are real subcommands that must stay undispatchable
+        // over SSH; the accept paths exec and can't run in-process.
+        assert!(dispatch_quire(&["serve".into()]).is_err());
+        assert!(dispatch_quire(&["ci".into(), "run".into()]).is_err());
+    }
+}