mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
Managed agents commit through a Git wrapper that pins author/committer to the agent's Nostr identity, signs with the agent key, and refuses identity overrides, so commit attribution no longer depends on host Git config. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
3859 lines
150 KiB
Rust
3859 lines
150 KiB
Rust
//! Process-level, mutation-sensitive regression for the deterministic
|
|
//! agent-git-identity enforcement wrapper. Unlike the unit tests in
|
|
//! `buzz-git-identity`, this exercises the REAL multicall binary: `buzz-acp`
|
|
//! symlinked as `git`, invoked exactly as an agent's shell would invoke it,
|
|
//! with a `.git-identity` manifest beside the symlink (the harness-owned
|
|
//! authority) and the real `git` reachable later on PATH.
|
|
//!
|
|
//! Each test targets one enforcement layer and is designed to go RED if that
|
|
//! layer is deleted:
|
|
//! * `enforce` — flag-based identity/signing override is rejected.
|
|
//! * `verify_push` — a human-authored outgoing commit cannot be pushed.
|
|
//! * `apply_authority_env`— the agent identity is re-applied over caller/repo
|
|
//! config (the env-var override vector), so commits land agent-authored
|
|
//! even when repo-local config names a human.
|
|
//!
|
|
//! The whole suite is unix-only: enforcement installs the wrapper as a PATH
|
|
//! symlink and every test wires a real `git-sign-nostr` signer via
|
|
//! [`signed_shim_env`], both of which need unix symlinks. buzz-acp's tests do
|
|
//! not run on Windows CI; this gate keeps `cargo check --all-targets` there
|
|
//! from compiling helpers it can never exercise.
|
|
#![cfg(unix)]
|
|
|
|
use nostr::ToBech32;
|
|
use std::ffi::OsStr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
const ALIAS_HOP_LIMIT: usize = 10;
|
|
|
|
/// A named manifest mutation: a label and a fn that rewrites the manifest body.
|
|
/// Aliased to keep the tampered-manifest table under `clippy::type_complexity`.
|
|
type ManifestMutation = (&'static str, fn(&str) -> String);
|
|
|
|
/// Directory of the first real `git` on PATH; the wrapper is installed ahead
|
|
/// of it so `find_real_git` skips our shim symlink and reaches this one.
|
|
fn real_git_dir() -> PathBuf {
|
|
for dir in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
|
|
let cand = dir.join("git");
|
|
if cand.is_file() {
|
|
return dir;
|
|
}
|
|
}
|
|
panic!("no real git on PATH");
|
|
}
|
|
|
|
/// Repository-local env git exports into hook processes. Under a pre-push hook
|
|
/// in a linked worktree `GIT_DIR` is absolute, so an inherited value escapes
|
|
/// `current_dir`/`-C` and fixture commands would rewrite the real repository.
|
|
const INHERITED_GIT_ENV: &[&str] = &[
|
|
"GIT_DIR",
|
|
"GIT_WORK_TREE",
|
|
"GIT_INDEX_FILE",
|
|
"GIT_COMMON_DIR",
|
|
"GIT_OBJECT_DIRECTORY",
|
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
"GIT_PREFIX",
|
|
"GIT_NAMESPACE",
|
|
"GIT_CONFIG_PARAMETERS",
|
|
"GIT_CONFIG_COUNT",
|
|
];
|
|
|
|
/// `Command::new(program)` with [`INHERITED_GIT_ENV`] removed. Every test spawn
|
|
/// that runs git (directly, via the wrapper, or via a child that runs git) goes
|
|
/// through this so the fixture only ever touches its own tempdir repos.
|
|
fn hermetic_command(program: impl AsRef<OsStr>) -> Command {
|
|
let mut cmd = Command::new(program);
|
|
for var in INHERITED_GIT_ENV {
|
|
cmd.env_remove(var);
|
|
}
|
|
cmd
|
|
}
|
|
|
|
/// Verdict returned by the isolated capability probe helpers.
|
|
///
|
|
/// Mirrors the production `SubsectionSupport` discrimination:
|
|
/// - `Supported`: exit 0 + "git version" stdout
|
|
/// - `Unsupported`: exit 1 + empty stdout + stderr starting with
|
|
/// `git: '<sentinel>' is not a git command`
|
|
/// - `Failure`: setup error, spawn failure, timeout, output overflow, or
|
|
/// unclassifiable output — callers `panic!` (fail the test)
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum ProbeVerdict {
|
|
Supported,
|
|
Unsupported,
|
|
Failure,
|
|
}
|
|
|
|
/// Probe timeout — same order of magnitude as the production `PROBE_TIMEOUT`.
|
|
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
/// Classify a raw probe `Output` for alias `sentinel` using the same rules as
|
|
/// the production `git_supports_subsection_alias` classifier.
|
|
fn classify_probe_output(out: &std::process::Output, sentinel: &str) -> ProbeVerdict {
|
|
let unknown_cmd_diag = format!("git: '{sentinel}' is not a git command");
|
|
if out.status.success() && out.stdout.starts_with(b"git version") {
|
|
ProbeVerdict::Supported
|
|
} else if out.status.code() == Some(1)
|
|
&& out.stdout.is_empty()
|
|
&& out.stderr.starts_with(unknown_cmd_diag.as_bytes())
|
|
{
|
|
ProbeVerdict::Unsupported
|
|
} else {
|
|
ProbeVerdict::Failure
|
|
}
|
|
}
|
|
|
|
/// Isolated capability probe for `alias.<name>.command` form aliases.
|
|
///
|
|
/// Matches the production `git_supports_subsection_alias` isolation model:
|
|
/// - Probe-only private tempdir with a controlled `git` symlink to the real
|
|
/// binary (prevents sibling helpers from intercepting the probe)
|
|
/// - Both PATH and GIT_EXEC_PATH set to the private dir only
|
|
/// - GIT_CONFIG_NOSYSTEM, scratch HOME, XDG_CONFIG_HOME/GIT_DIR removed
|
|
/// - LC_ALL=C so the unknown-command diagnostic is ASCII-stable
|
|
/// - Bounded execution via production `run_bounded`: timeout + output cap
|
|
/// over the whole process group, including pipe draining and teardown
|
|
///
|
|
/// Callers `panic!` on `Failure` — setup, spawn, timeout, and unclassifiable
|
|
/// output indicate a broken test environment, not a capability answer.
|
|
fn isolated_subsection_probe() -> ProbeVerdict {
|
|
run_isolated_probe_for(
|
|
&real_git_dir().join("git"),
|
|
"alias._probe_.command=version",
|
|
"_probe_",
|
|
)
|
|
}
|
|
|
|
/// Isolated empty-subsection dispatch probe for `alias..<name>` form aliases.
|
|
///
|
|
/// Same isolation model as `isolated_subsection_probe`; same tri-state return.
|
|
fn isolated_empty_subsection_probe() -> ProbeVerdict {
|
|
run_isolated_probe_for(&real_git_dir().join("git"), "alias..pub=version", "pub")
|
|
}
|
|
|
|
/// Shared implementation for the isolated probes: runs
|
|
/// `git -c <config> <sentinel>` against `git_binary`.
|
|
fn run_isolated_probe_for(git_binary: &Path, config: &str, sentinel: &str) -> ProbeVerdict {
|
|
let git_binary = git_binary
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| git_binary.to_path_buf());
|
|
|
|
// Private probe dir: only the controlled git symlink — no adjacent helpers.
|
|
let probe_dir = match tempfile::tempdir() {
|
|
Ok(d) => d,
|
|
Err(_) => return ProbeVerdict::Failure,
|
|
};
|
|
let git_link = probe_dir.path().join("git");
|
|
if std::os::unix::fs::symlink(&git_binary, &git_link).is_err() {
|
|
return ProbeVerdict::Failure;
|
|
}
|
|
|
|
let scratch = match tempfile::tempdir() {
|
|
Ok(d) => d,
|
|
Err(_) => return ProbeVerdict::Failure,
|
|
};
|
|
let probe_path = probe_dir.path().as_os_str().to_owned();
|
|
let out = match buzz_git_identity::git_wrapper::run_bounded(
|
|
Command::new(&git_link)
|
|
.args(["-c", config, sentinel])
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.env("HOME", scratch.path())
|
|
.env_remove("XDG_CONFIG_HOME")
|
|
.env_remove("GIT_CONFIG_GLOBAL")
|
|
.env_remove("GIT_CONFIG_SYSTEM")
|
|
.env_remove("GIT_DIR")
|
|
.env_remove("GIT_WORK_TREE")
|
|
.env_remove("GIT_NAMESPACE")
|
|
.env_remove("GIT_CONFIG_COUNT")
|
|
.env_remove("GIT_CONFIG_PARAMETERS")
|
|
.env("PATH", &probe_path)
|
|
.env("GIT_EXEC_PATH", probe_dir.path())
|
|
.env("LC_ALL", "C")
|
|
.current_dir(scratch.path()),
|
|
PROBE_TIMEOUT,
|
|
) {
|
|
Some(o) => o,
|
|
None => return ProbeVerdict::Failure,
|
|
};
|
|
classify_probe_output(&out, sentinel)
|
|
}
|
|
|
|
/// The test probe helpers must report setup/process/classification failure as
|
|
/// `Failure` — never as `Unsupported`, which callers treat as a legitimate skip.
|
|
#[test]
|
|
fn probe_helpers_report_timeout_and_unrecognized_exit1_as_failure() {
|
|
let mut sleeper = Command::new("sh");
|
|
sleeper.args(["-c", "sleep 30"]);
|
|
assert!(
|
|
buzz_git_identity::git_wrapper::run_bounded(
|
|
&mut sleeper,
|
|
std::time::Duration::from_millis(200)
|
|
)
|
|
.is_none(),
|
|
"a probe exceeding its deadline must yield no output"
|
|
);
|
|
|
|
let unrelated = Command::new("sh")
|
|
.args(["-c", "echo 'fatal: something else' >&2; exit 1"])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(
|
|
classify_probe_output(&unrelated, "_probe_"),
|
|
ProbeVerdict::Failure
|
|
);
|
|
|
|
let recognized = Command::new("sh")
|
|
.args([
|
|
"-c",
|
|
"echo \"git: '_probe_' is not a git command. See 'git --help'.\" >&2; exit 1",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(
|
|
classify_probe_output(&recognized, "_probe_"),
|
|
ProbeVerdict::Unsupported
|
|
);
|
|
|
|
let missing = Path::new("/nonexistent/buzz-probe-test/git");
|
|
assert_eq!(
|
|
run_isolated_probe_for(missing, "alias._probe_.command=version", "_probe_"),
|
|
ProbeVerdict::Failure
|
|
);
|
|
}
|
|
|
|
/// A git repo with one human-authored commit and human-named local config.
|
|
fn human_repo() -> tempfile::TempDir {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let p = d.path();
|
|
let g = |args: &[&str]| {
|
|
let ok = hermetic_command("git")
|
|
.args(args)
|
|
.current_dir(p)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.status()
|
|
.unwrap()
|
|
.success();
|
|
assert!(ok, "git {args:?} failed");
|
|
};
|
|
g(&["init", "-q", "-b", "main"]);
|
|
g(&["config", "user.name", "Human Dev"]);
|
|
g(&["config", "user.email", "human@example.com"]);
|
|
g(&["config", "commit.gpgSign", "false"]);
|
|
std::fs::write(p.join("f"), "one").unwrap();
|
|
g(&["add", "f"]);
|
|
g(&["commit", "-qm", "human commit"]);
|
|
d
|
|
}
|
|
|
|
/// A fresh repo with a staged file but no commit object.
|
|
fn unborn_repo() -> tempfile::TempDir {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let p = d.path();
|
|
let g = |args: &[&str]| {
|
|
let ok = hermetic_command("git")
|
|
.args(args)
|
|
.current_dir(p)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.status()
|
|
.unwrap()
|
|
.success();
|
|
assert!(ok, "git {args:?} failed");
|
|
};
|
|
g(&["init", "-q", "-b", "main"]);
|
|
g(&["config", "user.name", "Human Dev"]);
|
|
g(&["config", "user.email", "human@example.com"]);
|
|
g(&["config", "commit.gpgSign", "false"]);
|
|
std::fs::write(p.join("f"), "staged").unwrap();
|
|
g(&["add", "f"]);
|
|
d
|
|
}
|
|
|
|
/// Number of commit objects in `repo`, including unreachable objects.
|
|
fn commit_object_count(repo: &Path) -> usize {
|
|
let out = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.to_str().unwrap(),
|
|
"cat-file",
|
|
"--batch-all-objects",
|
|
"--batch-check",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "enumerating git objects failed");
|
|
String::from_utf8_lossy(&out.stdout)
|
|
.lines()
|
|
.filter(|line| line.split_whitespace().nth(1) == Some("commit"))
|
|
.count()
|
|
}
|
|
|
|
/// Invoke the wrapper (`git` on the shim PATH) with `args`, in `cwd`.
|
|
///
|
|
/// `NOSTR_PRIVATE_KEY`/`BUZZ_PRIVATE_KEY` are scrubbed so `git-sign-nostr` signs
|
|
/// from the manifest's `nostr.keyfile` — the real agent-runtime child has the
|
|
/// private key env removed, and leaving the runner's ambient key set would make
|
|
/// the signer load the wrong identity (a non-hermetic test). `BUZZ_AUTH_TAG` is
|
|
/// scrubbed so the signer skips NIP-OA owner attestation (no relay to verify
|
|
/// against offline); signing itself needs no network.
|
|
fn wrapper(path: &str, cwd: &Path, args: &[&str]) -> std::process::Output {
|
|
hermetic_command("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.env("PATH", path)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.env_remove("NOSTR_PRIVATE_KEY")
|
|
.env_remove("BUZZ_PRIVATE_KEY")
|
|
.env_remove("BUZZ_AUTH_TAG")
|
|
.output()
|
|
.expect("run wrapper git")
|
|
}
|
|
|
|
/// Like `wrapper`, but bounded: the whole process tree is killed after
|
|
/// `timeout` and the test panics. Used for nested-agent tests where a recursion
|
|
/// regression would otherwise hang CI indefinitely.
|
|
#[cfg(unix)]
|
|
fn wrapper_bounded(
|
|
path: &str,
|
|
cwd: &Path,
|
|
args: &[&str],
|
|
timeout: std::time::Duration,
|
|
) -> std::process::Output {
|
|
let mut cmd = hermetic_command("git");
|
|
cmd.args(args)
|
|
.current_dir(cwd)
|
|
.env("PATH", path)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.env_remove("NOSTR_PRIVATE_KEY")
|
|
.env_remove("BUZZ_PRIVATE_KEY")
|
|
.env_remove("BUZZ_AUTH_TAG");
|
|
run_tree_bounded(&mut cmd, timeout)
|
|
}
|
|
|
|
/// Run `cmd` via `run_bounded`, which drains pipes and tears down the full
|
|
/// process group, so a descendant holding stdout cannot outlive the deadline.
|
|
#[cfg(unix)]
|
|
fn run_tree_bounded(cmd: &mut Command, timeout: std::time::Duration) -> std::process::Output {
|
|
buzz_git_identity::git_wrapper::run_bounded(cmd, timeout).unwrap_or_else(|| {
|
|
panic!(
|
|
"bounded run of {cmd:?} timed out after {timeout:?} or failed to spawn \
|
|
— likely a recursion or hang regression"
|
|
)
|
|
})
|
|
}
|
|
|
|
/// The bounded helper must not wait on, or leave alive, a backgrounded
|
|
/// descendant that keeps the child's stdout open after the child exits.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn bounded_helper_kills_descendant_holding_stdout() {
|
|
let timeout = std::time::Duration::from_secs(2);
|
|
let start = std::time::Instant::now();
|
|
let out = run_tree_bounded(
|
|
Command::new("sh").args(["-c", "sleep 30 & echo $!"]),
|
|
timeout,
|
|
);
|
|
assert!(
|
|
start.elapsed() < timeout,
|
|
"helper waited {:?} for a pipe-holding descendant",
|
|
start.elapsed()
|
|
);
|
|
assert!(out.status.success());
|
|
let pid = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
assert!(!pid.is_empty(), "descendant pid missing from stdout");
|
|
// The orphaned sleeper is reaped asynchronously by init; allow a moment.
|
|
let alive = || {
|
|
Command::new("kill")
|
|
.args(["-0", &pid])
|
|
.status()
|
|
.unwrap()
|
|
.success()
|
|
};
|
|
let reap_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
|
|
while alive() && std::time::Instant::now() < reap_deadline {
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
assert!(
|
|
!alive(),
|
|
"descendant sleeper {pid} survived the bounded run"
|
|
);
|
|
}
|
|
|
|
/// Assert `remote` enumerates refs successfully and holds none.
|
|
fn assert_remote_empty(remote: &Path) {
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.status.success(),
|
|
"for-each-ref failed: {:?}",
|
|
refs.status
|
|
);
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout)
|
|
);
|
|
}
|
|
|
|
/// The current `HEAD` commit SHA of `repo`, via real git (empty if unborn).
|
|
fn head_sha(repo: &Path) -> String {
|
|
let out = hermetic_command("git")
|
|
.args(["-C", repo.to_str().unwrap(), "rev-parse", "HEAD"])
|
|
.output()
|
|
.unwrap();
|
|
if !out.status.success() {
|
|
return String::new();
|
|
}
|
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn wrapper_rejects_flag_based_identity_override() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
std::fs::write(repo.path().join("f"), "two").unwrap();
|
|
wrapper(&path, repo.path(), &["add", "f"]);
|
|
|
|
let out = wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["-c", "user.email=evil@example.com", "commit", "-m", "x"],
|
|
);
|
|
assert!(
|
|
!out.status.success(),
|
|
"override commit should be rejected; stdout={} stderr={}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("machine-managed"),
|
|
"expected the loud enforce message; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapper_refuses_to_push_human_authored_commit() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
// A reachable bare remote so the dry-run plan resolves and HEAD (human
|
|
// authored) is examined as an offender.
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
|
|
let out = wrapper(&path, repo.path(), &["push", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"pushing a human-authored commit must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// The bare remote must have received nothing.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"no ref should have reached the remote: {}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R4 real-wrapper regression for the allowlist alias guard. Two Thufir p3
|
|
/// bypass probes must be refused through the actual `buzz-acp`-as-`git`
|
|
/// multicall, and neither may create a commit:
|
|
///
|
|
/// (a) a *quoted* config alias — git's quote-aware parser dequotes `'-c'`
|
|
/// `'user.email=…'` into real `-c` config that the whitespace-naive
|
|
/// round-3 scan missed;
|
|
/// (b) a *shell* (`!`) commit alias — git runs it with real git ahead of the
|
|
/// wrapper on PATH, so its inner `-c` re-authors the commit.
|
|
///
|
|
/// A plain-subcommand alias must still resolve and commit as the agent
|
|
/// identity, proving the allowlist did not over-reject Gurney's working shapes.
|
|
#[test]
|
|
fn wrapper_rejects_quoted_and_shell_aliases_and_allows_plain_alias() {
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
|
|
// (a) quoted config alias — the parser-parity bypass.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&[
|
|
"config",
|
|
"alias.quoted",
|
|
"'-c' 'user.name=QuotedHuman' '-c' 'user.email=quoted@human.test' '-c' 'commit.gpgSign=false' commit",
|
|
],
|
|
);
|
|
// (b) shell commit alias — git prepends real git to PATH for `!` bodies.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&[
|
|
"config",
|
|
"alias.sc",
|
|
"!f(){ git -c user.name=ShellHuman -c user.email=shell@human.test -c commit.gpgSign=false commit \"$@\"; }; f",
|
|
],
|
|
);
|
|
// A plain-subcommand alias that must keep working.
|
|
wrapper(&path, repo.path(), &["config", "alias.ci", "commit"]);
|
|
|
|
let head_before = head_sha(repo.path());
|
|
std::fs::write(repo.path().join("f"), "two").unwrap();
|
|
wrapper(&path, repo.path(), &["add", "f"]);
|
|
|
|
// (a) refused, no commit created.
|
|
let out = wrapper(&path, repo.path(), &["quoted", "-m", "via quoted alias"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"quoted config alias must be refused; stdout={} stderr={}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert_eq!(
|
|
head_sha(repo.path()),
|
|
head_before,
|
|
"the refused quoted alias must not create a commit"
|
|
);
|
|
|
|
// (b) refused, no commit created.
|
|
let out = wrapper(&path, repo.path(), &["sc", "-m", "via shell alias"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"shell commit alias must be refused; stdout={} stderr={}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("shell (`!`) git alias"),
|
|
"expected the shell-alias rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert_eq!(
|
|
head_sha(repo.path()),
|
|
head_before,
|
|
"the refused shell alias must not create a commit"
|
|
);
|
|
|
|
// The plain alias must still resolve and commit as the agent identity.
|
|
let out = wrapper(&path, repo.path(), &["ci", "-m", "via plain alias"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"plain-subcommand alias must still commit; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let author = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.path().to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&author.stdout).trim(),
|
|
email,
|
|
"plain-alias commit must be authored as the agent identity"
|
|
);
|
|
}
|
|
|
|
/// R5 real-wrapper regression for the alias-unification fix (Thufir's rd-4
|
|
/// IMPORTANT). A bare-word alias whose body carries identity/signing *flags*
|
|
/// passes the allowlist (every token is a plain bare word), but after the alias
|
|
/// is expanded the wrapper holds the expansion to the SAME `enforce`/author
|
|
/// policy as a directly-typed command — so the alias can do no more than its
|
|
/// expansion could. Three shapes, each of which is refused when typed directly,
|
|
/// must therefore be refused through the alias too, with `HEAD` unchanged:
|
|
///
|
|
/// (a) Thufir's exact probe — `--author` (split form) plus `--no-gpg-sign`;
|
|
/// (b) `--no-gpg-sign` alone, pinning that the fix is not one hard-coded string;
|
|
/// (c) an alias *chain* that resolves to `commit --no-gpg-sign` through two
|
|
/// hops, pinning that unification applies to the final accumulated command.
|
|
#[test]
|
|
fn wrapper_rejects_bare_word_alias_carried_identity_and_signing_flags() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
|
|
// (a) Thufir's exact bypass probe — bare-word `--author`/`--no-gpg-sign`.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&[
|
|
"config",
|
|
"alias.human",
|
|
"commit --author Human<human@human.test> --no-gpg-sign",
|
|
],
|
|
);
|
|
// (b) `--no-gpg-sign` alone.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.unsign", "commit --no-gpg-sign"],
|
|
);
|
|
// (c) an alias chain: `chain` → `co --no-gpg-sign` → `commit --no-gpg-sign`.
|
|
wrapper(&path, repo.path(), &["config", "alias.co", "commit"]);
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.chain", "co --no-gpg-sign"],
|
|
);
|
|
|
|
let head_before = head_sha(repo.path());
|
|
std::fs::write(repo.path().join("f"), "two").unwrap();
|
|
wrapper(&path, repo.path(), &["add", "f"]);
|
|
|
|
for (alias, label) in [
|
|
("human", "author+no-gpg-sign alias"),
|
|
("unsign", "no-gpg-sign-only alias"),
|
|
("chain", "chained no-gpg-sign alias"),
|
|
] {
|
|
let out = wrapper(&path, repo.path(), &[alias, "-m", "leak"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"{label} must be refused; stdout={} stderr={}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("machine-managed"),
|
|
"{label} must give the identity/signing rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert_eq!(
|
|
head_sha(repo.path()),
|
|
head_before,
|
|
"{label} must not create a commit"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wrapper_refuses_alias_chain_beyond_limit_and_allows_exact_limit() {
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let repo = unborn_repo();
|
|
|
|
// Exactly ALIAS_HOP_LIMIT substitutions end at real `commit`, so the wrapper
|
|
// must preserve the boundary's useful side: it resolves and commits under
|
|
// the managed agent identity.
|
|
for index in 0..ALIAS_HOP_LIMIT {
|
|
let name = format!("at{index}");
|
|
let next = if index + 1 == ALIAS_HOP_LIMIT {
|
|
"commit".to_string()
|
|
} else {
|
|
format!("at{}", index + 1)
|
|
};
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", &format!("alias.{name}"), &next],
|
|
);
|
|
}
|
|
let out = wrapper(&path, repo.path(), &["at0", "-m", "at the alias limit"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"chain at the limit must reach the real command; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let author = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.path().to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(String::from_utf8_lossy(&author.stdout).trim(), email);
|
|
|
|
// Thufir's limit+1 counterexample: the wrapper must not hand a partial
|
|
// expansion to git. A human-author `commit` beyond the bound is refused
|
|
// before git runs, leaving the fresh repo unborn with no commit objects.
|
|
let beyond = unborn_repo();
|
|
for index in 0..=ALIAS_HOP_LIMIT {
|
|
let name = format!("a{index}");
|
|
let next = if index == ALIAS_HOP_LIMIT {
|
|
"commit --author Human<human@human.test>".to_string()
|
|
} else {
|
|
format!("a{}", index + 1)
|
|
};
|
|
wrapper(
|
|
&path,
|
|
beyond.path(),
|
|
&["config", &format!("alias.{name}"), &next],
|
|
);
|
|
}
|
|
let out = wrapper(&path, beyond.path(), &["a0", "-m", "leak"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"chain past the limit must be refused; stdout={} stderr={}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr)
|
|
.contains(&format!("after {ALIAS_HOP_LIMIT} expansions")),
|
|
"expected the alias-limit refusal; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let beyond_head = head_sha(beyond.path());
|
|
assert!(
|
|
beyond_head.is_empty(),
|
|
"HEAD must remain unborn; HEAD={beyond_head:?}; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert_eq!(
|
|
commit_object_count(beyond.path()),
|
|
0,
|
|
"the refused chain must not create an unreachable commit object"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapper_reapplies_agent_identity_over_repo_config() {
|
|
// The env-var / repo-config override vector: repo-local config names a
|
|
// human, yet the wrapper re-appends the agent identity at the highest
|
|
// GIT_CONFIG_* index, so the resulting commit is agent-authored. Deleting
|
|
// `apply_authority_env` makes this commit land as `human@example.com`.
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
std::fs::write(repo.path().join("f"), "two").unwrap();
|
|
wrapper(&path, repo.path(), &["add", "f"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["commit", "-m", "agent authored"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"ordinary commit should succeed; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
|
|
let author = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.path().to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&author.stdout).trim(),
|
|
email,
|
|
"commit must be authored as the agent identity, not the repo-local human"
|
|
);
|
|
}
|
|
|
|
/// A fixture agent key persisted the way the harness persists it.
|
|
struct AgentKey {
|
|
keyfile_path: String,
|
|
pubkey_hex: String,
|
|
npub: String,
|
|
}
|
|
|
|
/// Write `nsec` to an owner-only keyfile in `dir` and derive its identity.
|
|
fn write_agent_key(dir: &Path, nsec: &str) -> Option<AgentKey> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let keys = nostr::Keys::parse(nsec).ok()?;
|
|
let keyfile = dir.join(".nostr-key");
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&keyfile)
|
|
.and_then(|mut f| std::io::Write::write_all(&mut f, nsec.as_bytes()))
|
|
.ok()?;
|
|
Some(AgentKey {
|
|
keyfile_path: keyfile.to_str()?.to_owned(),
|
|
pubkey_hex: keys.public_key().to_hex(),
|
|
npub: keys.public_key().to_bech32().ok()?,
|
|
})
|
|
}
|
|
|
|
/// The complete manifest a managed install writes for `id` on `relay.test`.
|
|
fn manifest_entries(id: &AgentKey) -> Vec<(String, String)> {
|
|
let mut entries = vec![
|
|
("user.name".to_owned(), id.npub.clone()),
|
|
(
|
|
"user.email".to_owned(),
|
|
format!("{}@relay.test", id.pubkey_hex),
|
|
),
|
|
];
|
|
entries.extend(
|
|
buzz_git_identity::FIXED_SIGNING_ENTRIES
|
|
.iter()
|
|
.map(|&(k, v)| (k.to_owned(), v.to_owned())),
|
|
);
|
|
entries.push(("user.signingkey".to_owned(), id.pubkey_hex.clone()));
|
|
entries.push(("nostr.keyfile".to_owned(), id.keyfile_path.clone()));
|
|
entries
|
|
}
|
|
|
|
/// Build a shim dir wired for REAL signing: `git` and `git-sign-nostr` both
|
|
/// symlink to the buzz-acp multicall, and the `.git-identity` manifest carries
|
|
/// the full identity + signing config (`commit.gpgSign=true`, the signer
|
|
/// program, `user.signingkey`, and the keyfile) for a freshly generated key.
|
|
/// Returns (shim TempDir, PATH string, expected author email, keyfile-holding
|
|
/// TempDir). Signing itself needs no network — `BUZZ_AUTH_TAG` is left unset so
|
|
/// the signer works offline.
|
|
fn signed_shim_env() -> (tempfile::TempDir, String, String, tempfile::TempDir) {
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
|
|
// Keyfile + derived identity live in their own 0700 dir (the manifest's
|
|
// `nostr.keyfile` points here). Kept separate from the shim so the shim
|
|
// holds only the git symlinks + manifest, as the harness installs them.
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let expected_email = format!("{}@relay.test", id.pubkey_hex);
|
|
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name)).unwrap();
|
|
}
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &manifest_entries(&id)).unwrap();
|
|
|
|
let real = real_git_dir();
|
|
let path = std::env::join_paths([shim.path().to_path_buf(), real])
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
(shim, path, expected_email, keydir)
|
|
}
|
|
|
|
/// A repo whose local config names the AGENT identity (author is correct) and a
|
|
/// reachable bare remote, ready for one commit. Returns (work TempDir, repo
|
|
/// path, remote path). `commit.gpgSign` is left to the wrapper's injected config
|
|
/// so the commit shape is set per-test.
|
|
fn agent_repo_with_remote(agent_email: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
|
|
let work = tempfile::tempdir().unwrap();
|
|
let repo = work.path().join("repo");
|
|
std::fs::create_dir_all(&repo).unwrap();
|
|
let remote = work.path().join("remote.git");
|
|
let g = |cwd: &Path, args: &[&str]| {
|
|
assert!(hermetic_command("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
};
|
|
g(
|
|
work.path(),
|
|
&["init", "-q", "--bare", remote.to_str().unwrap()],
|
|
);
|
|
g(&repo, &["init", "-q", "-b", "main"]);
|
|
g(&repo, &["config", "user.name", "Agent"]);
|
|
g(&repo, &["config", "user.email", agent_email]);
|
|
g(
|
|
&repo,
|
|
&["remote", "add", "origin", remote.to_str().unwrap()],
|
|
);
|
|
(work, repo, remote)
|
|
}
|
|
|
|
/// L3b (real signer): a genuinely signed agent commit pushes cleanly. This
|
|
/// proves the push-gate signature check accepts a valid NIP-GS signature by the
|
|
/// agent key — the happy path that the reject tests below are measured against.
|
|
#[test]
|
|
fn wrapper_allows_push_of_signed_agent_commit() {
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let (_work, repo, _remote) = agent_repo_with_remote(&email);
|
|
std::fs::write(repo.join("f"), "x").unwrap();
|
|
wrapper(&path, &repo, &["add", "f"]);
|
|
// The wrapper injects commit.gpgSign=true + the signer, so this commit is
|
|
// signed by the agent key.
|
|
let out = wrapper(&path, &repo, &["commit", "-m", "agent signed"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"signed agent commit should succeed; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let out = wrapper(&path, &repo, &["push", "origin", "main"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"pushing a signed agent commit must be allowed; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
/// L3b (real signer): an agent-authored but UNSIGNED commit created via
|
|
/// `git merge --no-gpg-sign` — Carl's live repro of the P1 gap — must be refused
|
|
/// at push. The merge commit is correctly agent-authored, so only the signature
|
|
/// check catches it; `enforce` cannot, because `merge` is not in its signing
|
|
/// blocklist.
|
|
#[test]
|
|
fn wrapper_refuses_push_of_unsigned_merge_commit() {
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let (_work, repo, _remote) = agent_repo_with_remote(&email);
|
|
// Base signed commit on main.
|
|
std::fs::write(repo.join("base"), "b").unwrap();
|
|
wrapper(&path, &repo, &["add", "base"]);
|
|
assert!(wrapper(&path, &repo, &["commit", "-m", "base"])
|
|
.status
|
|
.success());
|
|
// A signed commit on a side branch.
|
|
wrapper(&path, &repo, &["checkout", "-q", "-b", "side"]);
|
|
std::fs::write(repo.join("side"), "s").unwrap();
|
|
wrapper(&path, &repo, &["add", "side"]);
|
|
assert!(wrapper(&path, &repo, &["commit", "-m", "side work"])
|
|
.status
|
|
.success());
|
|
// Back on main, merge the side branch WITHOUT signing — an agent-authored
|
|
// but unsigned merge commit. `--no-ff` forces a merge commit object.
|
|
wrapper(&path, &repo, &["checkout", "-q", "main"]);
|
|
let m = wrapper(
|
|
&path,
|
|
&repo,
|
|
&[
|
|
"merge",
|
|
"--no-ff",
|
|
"--no-gpg-sign",
|
|
"-m",
|
|
"merge side",
|
|
"side",
|
|
],
|
|
);
|
|
assert!(
|
|
m.status.success(),
|
|
"the unsigned merge itself should succeed; stderr={}",
|
|
String::from_utf8_lossy(&m.stderr),
|
|
);
|
|
let out = wrapper(&path, &repo, &["push", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"pushing an unsigned merge commit must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("no valid signature by your agent key"),
|
|
"expected the unsigned-commit rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
/// L3b (real signer): an agent-authored but UNSIGNED commit created via the
|
|
/// `commit-tree` plumbing — which bypasses `commit` entirely and so is never
|
|
/// touched by `enforce` — must be refused at push. Pins that the push-gate
|
|
/// check covers the plumbing path, not just porcelain.
|
|
#[test]
|
|
fn wrapper_refuses_push_of_unsigned_commit_tree() {
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let (_work, repo, _remote) = agent_repo_with_remote(&email);
|
|
// Seed a signed base so HEAD and the tree exist.
|
|
std::fs::write(repo.join("f"), "x").unwrap();
|
|
wrapper(&path, &repo, &["add", "f"]);
|
|
assert!(wrapper(&path, &repo, &["commit", "-m", "base"])
|
|
.status
|
|
.success());
|
|
// Build an unsigned commit object directly with `commit-tree` (no signing,
|
|
// agent identity via env), then move the branch to it.
|
|
let tree = wrapper(&path, &repo, &["write-tree"]);
|
|
let tree_sha = String::from_utf8_lossy(&tree.stdout).trim().to_string();
|
|
let parent = head_sha(&repo);
|
|
let out = hermetic_command("git")
|
|
.args(["commit-tree", &tree_sha, "-p", &parent, "-m", "plumbed"])
|
|
.current_dir(&repo)
|
|
.env("PATH", &path)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.env("GIT_AUTHOR_NAME", "Agent")
|
|
.env("GIT_AUTHOR_EMAIL", &email)
|
|
.env("GIT_COMMITTER_NAME", "Agent")
|
|
.env("GIT_COMMITTER_EMAIL", &email)
|
|
.output()
|
|
.expect("run commit-tree");
|
|
assert!(
|
|
out.status.success(),
|
|
"commit-tree should succeed; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let new_sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
wrapper(&path, &repo, &["update-ref", "refs/heads/main", &new_sha]);
|
|
|
|
let out = wrapper(&path, &repo, &["push", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"pushing an unsigned commit-tree commit must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("no valid signature by your agent key"),
|
|
"expected the unsigned-commit rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
/// Contract regression (Thufir rd-2 IMPORTANT): a `.git-identity` manifest that
|
|
/// names the agent but drops, falsifies, or misdirects the signing contract is
|
|
/// tampered — not a legitimate unsigned mode — and must fail closed for EVERY
|
|
/// command, not silently disable or redirect the push-gate signature check.
|
|
/// Six mutations, each a distinct silent-disable/misdirect the contract check
|
|
/// rejects at classification time (`run()` refuses before any dispatch), so
|
|
/// even a read-only `status` is refused. The duplicate-key and `include.path`
|
|
/// cases cover the last-value-wins redirect that a first-value-only validator
|
|
/// would miss.
|
|
#[test]
|
|
fn wrapper_refuses_every_command_when_manifest_signing_contract_is_tampered() {
|
|
let variants: [ManifestMutation; 6] = [
|
|
// `commit.gpgSign` removed → the signature gate would never fire.
|
|
("commit.gpgSign removed", |m| {
|
|
m.lines()
|
|
.filter(|l| !l.starts_with("commit.gpgSign="))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}),
|
|
// `commit.gpgSign=false` → the same silent-disable, spelled out.
|
|
("commit.gpgSign=false", |m| {
|
|
m.replace("commit.gpgSign=true", "commit.gpgSign=false")
|
|
}),
|
|
// `user.signingkey` swapped to a key the author email does not encode →
|
|
// the probe would trust the wrong key.
|
|
("user.signingkey swapped", |m| {
|
|
m.lines()
|
|
.map(|l| {
|
|
if l.starts_with("user.signingkey=") {
|
|
format!("user.signingkey={}", "b".repeat(64))
|
|
} else {
|
|
l.to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}),
|
|
// A DUPLICATE later `user.signingkey=B` appended after the canonical
|
|
// first value → git config is last-value-wins, so the probe would
|
|
// resolve B. classify() rejects any duplicate canonical key.
|
|
("duplicate user.signingkey", |m| {
|
|
format!("{m}\nuser.signingkey={}", "b".repeat(64))
|
|
}),
|
|
// An appended `include.path` → an unknown key that could pull in another
|
|
// key file whose later `user.signingkey` wins. classify() rejects any
|
|
// unknown key.
|
|
("appended include.path", |m| {
|
|
format!("{m}\ninclude.path=/tmp/evil.inc")
|
|
}),
|
|
// `gpg.x509.program` differing ONLY in case. On a case-sensitive host
|
|
// `GIT-SIGN-NOSTR` resolves past the managed install to an agent-
|
|
// controlled fake verifier later on PATH; the probe would then accept
|
|
// an unsigned commit. classify() compares fixed values byte for byte.
|
|
("gpg.x509.program cased", |m| {
|
|
m.replace(
|
|
"gpg.x509.program=git-sign-nostr",
|
|
"gpg.x509.program=GIT-SIGN-NOSTR",
|
|
)
|
|
}),
|
|
];
|
|
|
|
for (label, mutate) in variants {
|
|
let (shim, path, _email, _keydir) = signed_shim_env();
|
|
let manifest_path = shim.path().join(".git-identity");
|
|
let original = std::fs::read_to_string(&manifest_path).unwrap();
|
|
std::fs::write(&manifest_path, mutate(&original)).unwrap();
|
|
|
|
let repo = human_repo();
|
|
let out = wrapper(&path, repo.path(), &["status"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"[{label}] a tampered manifest must refuse every command; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("complete signing contract"),
|
|
"[{label}] expected the tampered-manifest refusal; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// L3b (real signer): the wrong-key case Thufir called out — a commit correctly
|
|
/// authored as the agent (key A) but VALIDLY signed by a DIFFERENT key B must be
|
|
/// refused at push. `git-sign-nostr` verifies B's signature as cryptographically
|
|
/// good, but the push probe injects the authority's `user.signingkey=A`, so the
|
|
/// verified key ≠ the expected key → not `TRUST_FULLY` → `%G?` ≠ `G`. A valid
|
|
/// signature by the wrong key is not a valid agent signature.
|
|
#[test]
|
|
fn wrapper_refuses_push_of_commit_validly_signed_by_wrong_key() {
|
|
let (_shim, path, email_a, _keydir_a) = signed_shim_env();
|
|
let (_work, repo, _remote) = agent_repo_with_remote(&email_a);
|
|
|
|
// A second, unrelated signing identity (key B) with its own keyfile.
|
|
let keys_b = nostr::Keys::generate();
|
|
let nsec_b = keys_b.secret_key().to_bech32().unwrap();
|
|
let keydir_b = tempfile::tempdir().unwrap();
|
|
let id_b = write_agent_key(keydir_b.path(), &nsec_b).expect("write B keyfile");
|
|
|
|
// Create a commit authored as agent A but signed with key B, bypassing the
|
|
// wrapper's `enforce` by invoking the real git binary directly with B's
|
|
// signing config. `git-sign-nostr` resolves from the shim on PATH.
|
|
let real_git = real_git_dir().join("git");
|
|
let out = hermetic_command(&real_git)
|
|
.args([
|
|
"-C",
|
|
repo.to_str().unwrap(),
|
|
"-c",
|
|
"gpg.format=x509",
|
|
"-c",
|
|
"gpg.x509.program=git-sign-nostr",
|
|
"-c",
|
|
"commit.gpgSign=true",
|
|
"-c",
|
|
&format!("user.signingkey={}", id_b.pubkey_hex),
|
|
"-c",
|
|
&format!("nostr.keyfile={}", id_b.keyfile_path),
|
|
"commit",
|
|
"--allow-empty",
|
|
"-m",
|
|
"authored by A, signed by B",
|
|
])
|
|
.current_dir(&repo)
|
|
.env("PATH", &path)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.env("GIT_AUTHOR_NAME", "Agent")
|
|
.env("GIT_AUTHOR_EMAIL", &email_a)
|
|
.env("GIT_COMMITTER_NAME", "Agent")
|
|
.env("GIT_COMMITTER_EMAIL", &email_a)
|
|
.env_remove("NOSTR_PRIVATE_KEY")
|
|
.env_remove("BUZZ_PRIVATE_KEY")
|
|
.env_remove("BUZZ_AUTH_TAG")
|
|
.output()
|
|
.expect("create B-signed commit");
|
|
assert!(
|
|
out.status.success(),
|
|
"the B-signed commit itself should be created; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Sanity: it is agent-authored, so the push gate demands a valid agent
|
|
// signature on it (rather than skipping it as someone else's commit).
|
|
let author = hermetic_command(&real_git)
|
|
.args([
|
|
"-C",
|
|
repo.to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(String::from_utf8_lossy(&author.stdout).trim(), email_a);
|
|
|
|
let out = wrapper(&path, &repo, &["push", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"a commit signed by the wrong key must be refused at push; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("no valid signature by your agent key"),
|
|
"expected the wrong-key rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
/// Run the real `buzz-acp` harness with a script adapter and `BUZZ_GIT_IDENTITY`
|
|
/// set to `mode` (unset for `None`). The adapter runs `probe` in `work`, writes
|
|
/// `done`, and idles until the harness is terminated. Returns the harness exit
|
|
/// status and its log. Isolated from operator global/system Git config, so any
|
|
/// identity the probe sees came from the harness or the fixture's repo config.
|
|
fn run_harness(
|
|
work: &Path,
|
|
mode: Option<&str>,
|
|
probe: &str,
|
|
tmpdir: Option<&Path>,
|
|
inherited: &[(&str, &str)],
|
|
path_prefix: Option<&Path>,
|
|
) -> (std::process::ExitStatus, String) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::time::{Duration, Instant};
|
|
|
|
let adapter = work.join("adapter.sh");
|
|
std::fs::write(
|
|
&adapter,
|
|
format!(
|
|
"#!/bin/sh\nset -eu\ncd \"$PROBE_DIR\"\n{probe}\nprintf done > done\nexec sleep 60\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let log = std::fs::File::create(work.join("harness.log")).unwrap();
|
|
let mut cmd = hermetic_command(env!("CARGO_BIN_EXE_buzz-acp"));
|
|
let path = std::env::join_paths(path_prefix.map(Path::to_path_buf).into_iter().chain(
|
|
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
|
|
))
|
|
.unwrap();
|
|
cmd.env_clear()
|
|
.env("PATH", path)
|
|
.env("PROBE_DIR", work)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.args([
|
|
"--private-key",
|
|
&nostr::Keys::generate().secret_key().to_secret_hex(),
|
|
"--relay-url",
|
|
"wss://relay.test",
|
|
"--agent-command",
|
|
adapter.to_str().unwrap(),
|
|
"--agent-args",
|
|
"",
|
|
])
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(log.try_clone().unwrap())
|
|
.stderr(log);
|
|
if let Some(mode) = mode {
|
|
cmd.env("BUZZ_GIT_IDENTITY", mode);
|
|
}
|
|
if let Some(tmpdir) = tmpdir {
|
|
cmd.env("TMPDIR", tmpdir);
|
|
}
|
|
if !inherited.is_empty() {
|
|
cmd.env("GIT_CONFIG_COUNT", inherited.len().to_string());
|
|
for (i, (key, value)) in inherited.iter().enumerate() {
|
|
cmd.env(format!("GIT_CONFIG_KEY_{i}"), key)
|
|
.env(format!("GIT_CONFIG_VALUE_{i}"), value);
|
|
}
|
|
}
|
|
let mut child = cmd.spawn().unwrap();
|
|
let pid = nix::unistd::Pid::from_raw(child.id() as i32);
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while !work.join("done").exists() && Instant::now() < deadline {
|
|
if child.try_wait().unwrap().is_some() {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
// Always terminate the exact child so a failure leaves no harness behind.
|
|
nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM).ok();
|
|
let exit_deadline = Instant::now() + Duration::from_secs(5);
|
|
while child.try_wait().unwrap().is_none() && Instant::now() < exit_deadline {
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
if child.try_wait().unwrap().is_none() {
|
|
child.kill().unwrap();
|
|
}
|
|
let status = child.wait().unwrap();
|
|
(
|
|
status,
|
|
std::fs::read_to_string(work.join("harness.log")).unwrap(),
|
|
)
|
|
}
|
|
|
|
/// I4: the harness's `GitEnvironment` must put the enforcement wrapper ahead of
|
|
/// real git in the adapter's native shell. The script configures a human
|
|
/// identity in the repo; a bare commit must still land as the agent, and a
|
|
/// `-c user.email=` override must be refused. Removing the wrapper symlink from
|
|
/// the install turns this RED on the refusal only: the `GIT_CONFIG_*` block
|
|
/// still attributes the bare commit to the agent, but the override succeeds.
|
|
#[test]
|
|
fn harness_native_shell_commits_as_agent_and_refuses_identity_override() {
|
|
let work = tempfile::tempdir().unwrap();
|
|
let (status, logs) = run_harness(
|
|
work.path(),
|
|
None,
|
|
r#"git init -q -b main repo
|
|
cd repo
|
|
git config user.name 'Human Dev'
|
|
git config user.email human@example.com
|
|
git commit -q --allow-empty -m 'agent authored'
|
|
git show -s --format=%ae HEAD > ../author
|
|
git verify-commit HEAD
|
|
if git -c user.email=human@example.com commit -q --allow-empty -m override 2> ../override-stderr; then
|
|
echo accepted > ../override
|
|
else
|
|
echo refused > ../override
|
|
fi
|
|
cd .."#,
|
|
None,
|
|
&[],
|
|
None,
|
|
);
|
|
assert!(work.path().join("done").exists(), "probe failed: {logs}");
|
|
assert!(status.success(), "harness shutdown failed: {logs}");
|
|
let author = std::fs::read_to_string(work.path().join("author")).unwrap();
|
|
assert!(
|
|
author.trim().ends_with("@relay.test") && author.trim().len() == 64 + "@relay.test".len(),
|
|
"bare commit must be authored by the agent key, got {author:?}"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(work.path().join("override"))
|
|
.unwrap()
|
|
.trim(),
|
|
"refused",
|
|
"stderr: {}",
|
|
std::fs::read_to_string(work.path().join("override-stderr")).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
/// `user` mode installs only relay credentials: the nostr helper answers for
|
|
/// the relay, but there is no wrapper, no signer, no manifest and no injected
|
|
/// identity, so git resolves the operator's own configuration.
|
|
#[test]
|
|
fn harness_user_mode_installs_only_relay_credentials() {
|
|
let work = tempfile::tempdir().unwrap();
|
|
let (status, logs) = run_harness(
|
|
work.path(),
|
|
Some("user"),
|
|
r#"absent() { if git config "$1"; then echo "$1 is set" >&2; exit 1; fi; }
|
|
helper=$(command -v git-credential-nostr)
|
|
dir=$(dirname "$helper")
|
|
test "$(git config --get-urlmatch credential.helper https://relay.test/git/o/r)" = nostr
|
|
test -n "$(git config nostr.keyfile)"
|
|
test ! -e "$dir/git"
|
|
test ! -e "$dir/git-sign-nostr"
|
|
test ! -e "$dir/.git-identity"
|
|
test "$(dirname "$(command -v git)")" != "$dir"
|
|
absent user.name
|
|
absent user.email
|
|
absent user.signingkey
|
|
absent commit.gpgSign"#,
|
|
None,
|
|
&[],
|
|
None,
|
|
);
|
|
assert!(work.path().join("done").exists(), "probe failed: {logs}");
|
|
assert!(status.success(), "harness shutdown failed: {logs}");
|
|
}
|
|
|
|
/// `user` mode drops inherited `GIT_CONFIG_*` identity, `author.*` /
|
|
/// `committer.*` and signing keys and config includes (which could carry
|
|
/// identity), in any case spelling, so the operator's own configuration
|
|
/// applies; unrelated inherited entries still reach the adapter.
|
|
#[test]
|
|
fn harness_user_mode_drops_inherited_identity_and_signing() {
|
|
let work = tempfile::tempdir().unwrap();
|
|
for args in [
|
|
&["init", "-q"][..],
|
|
&["config", "user.name", "Operator"],
|
|
&["config", "user.email", "operator@example.invalid"],
|
|
] {
|
|
let status = hermetic_command("git")
|
|
.arg("-C")
|
|
.arg(work.path())
|
|
.args(args)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.status()
|
|
.unwrap();
|
|
assert!(status.success(), "git {args:?}");
|
|
}
|
|
let include_file = work.path().join("identity.inc");
|
|
std::fs::write(
|
|
&include_file,
|
|
"[user]\n\temail = included@example.invalid\n",
|
|
)
|
|
.unwrap();
|
|
let include = include_file.to_str().unwrap().to_owned();
|
|
// A parent harness's install dir ahead of real git on the inherited PATH.
|
|
let parent = work.path().join("parent-install");
|
|
std::fs::create_dir(&parent).unwrap();
|
|
std::fs::write(parent.join(".git-identity"), "").unwrap();
|
|
std::fs::write(parent.join("git"), "#!/bin/sh\necho parent-wrapper\n").unwrap();
|
|
std::fs::set_permissions(
|
|
parent.join("git"),
|
|
std::os::unix::fs::PermissionsExt::from_mode(0o755),
|
|
)
|
|
.unwrap();
|
|
let (status, logs) = run_harness(
|
|
work.path(),
|
|
Some("user"),
|
|
r#"case "$(command -v git)" in */parent-install/*) echo "git resolves to the parent wrapper" >&2; exit 1;; esac
|
|
absent() { if git config "$1"; then echo "$1 is set" >&2; exit 1; fi; }
|
|
operator() { case "$(git var "$1")" in "Operator <operator@example.invalid> "*) ;; *) echo "$1 is not the operator" >&2; exit 1;; esac; }
|
|
operator GIT_AUTHOR_IDENT
|
|
operator GIT_COMMITTER_IDENT
|
|
absent user.signingkey
|
|
absent gpg.format
|
|
absent gpg.x509.program
|
|
absent commit.gpgSign
|
|
absent tag.gpgSign
|
|
test "$(git config core.abbrev)" = 12
|
|
test "$(git config gpg.X509.program)" = distinct-subsection"#,
|
|
None,
|
|
&[
|
|
("user.name", "Inherited Agent"),
|
|
("USER.EMAIL", "inherited@example.invalid"),
|
|
("user.signingKey", "inherited-key"),
|
|
("GPG.Format", "openpgp"),
|
|
("GPG.x509.PROGRAM", "inherited-signer"),
|
|
("gpg.X509.program", "distinct-subsection"),
|
|
("commit.gpgsign", "true"),
|
|
("TAG.GPGSIGN", "true"),
|
|
("Author.Name", "Inherited Author"),
|
|
("Author.Email", "author@example.invalid"),
|
|
("COMMITTER.name", "Inherited Committer"),
|
|
("committer.EMAIL", "committer@example.invalid"),
|
|
("Include.Path", &include),
|
|
("INCLUDEIF.gitdir:/.PATH", &include),
|
|
("core.abbrev", "12"),
|
|
],
|
|
Some(&parent),
|
|
);
|
|
assert!(work.path().join("done").exists(), "probe failed: {logs}");
|
|
assert!(status.success(), "harness shutdown failed: {logs}");
|
|
}
|
|
|
|
/// An unrecognized mode stops the harness before any adapter runs.
|
|
#[test]
|
|
fn harness_invalid_mode_fails_startup() {
|
|
let work = tempfile::tempdir().unwrap();
|
|
let (status, logs) = run_harness(work.path(), Some("usr"), "true", None, &[], None);
|
|
assert!(!status.success(), "invalid mode must fail startup: {logs}");
|
|
assert!(!work.path().join("done").exists(), "adapter ran: {logs}");
|
|
assert!(
|
|
logs.contains("BUZZ_GIT_IDENTITY"),
|
|
"error must name the var: {logs}"
|
|
);
|
|
}
|
|
|
|
/// A configured key whose Git install fails stops the harness; the adapter
|
|
/// never runs with the ambient identity.
|
|
#[test]
|
|
fn harness_git_install_failure_fails_startup() {
|
|
let work = tempfile::tempdir().unwrap();
|
|
let missing = work.path().join("missing-tmp");
|
|
let (status, logs) = run_harness(work.path(), None, "true", Some(&missing), &[], None);
|
|
assert!(
|
|
!status.success(),
|
|
"install failure must fail startup: {logs}"
|
|
);
|
|
assert!(!work.path().join("done").exists(), "adapter ran: {logs}");
|
|
}
|
|
|
|
/// Wes (5055999359) P1 — real-wrapper regression: `--receive-pack` (custom
|
|
/// receivepack program) is refused through the ACTUAL `buzz-acp`-as-`git`
|
|
/// multicall. Also covers `remote.origin.receivepack` config spelling.
|
|
///
|
|
/// Bypass shape (what happens WITHOUT the guard):
|
|
///
|
|
/// `origin` URL → `decoy` (bare repo seeded with the offending human HEAD).
|
|
/// receivepack script → ignores its `<url>` argument; exec's
|
|
/// `git-receive-pack <actual>` instead.
|
|
///
|
|
/// 1. `resolve_push_sources` runs `git push --dry-run --porcelain`.
|
|
/// Git invokes the script as the receive-pack process; negotiation
|
|
/// happens against `actual` (empty). The porcelain `To` header shows
|
|
/// the `origin` URL (`decoy`).
|
|
/// 2. `remote_object_ids` calls `git ls-remote decoy` → returns HEAD's IDs
|
|
/// → those IDs are used as the exclusion set → HEAD is treated as
|
|
/// already-remote → `partition_outgoing` finds zero offenders.
|
|
/// 3. The real push runs; git calls the script again → data flows to
|
|
/// `actual` → HEAD populates `actual`.
|
|
///
|
|
/// With guard: `reject_receive_pack_override` fires before any dry-run →
|
|
/// `actual` stays empty.
|
|
/// Without guard: decoy supplies exemption; push lands in `actual`.
|
|
///
|
|
/// Mutation-sensitive: removing `reject_receive_pack_override` from
|
|
/// `verify_push` makes the bypass succeed; `actual/refs/heads/main` appears
|
|
/// and the `show-ref` assertion fires.
|
|
#[test]
|
|
fn wrapper_refuses_receive_pack_flag_and_leaves_target_empty() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
// `decoy` already holds the offending HEAD — ls-remote here supplies the
|
|
// exclusion set that exempts HEAD from outgoing verification.
|
|
let decoy = tempfile::tempdir().unwrap();
|
|
// `actual` starts empty — this is where data lands under the bypass.
|
|
let actual = tempfile::tempdir().unwrap();
|
|
|
|
// Init both bare repos.
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", decoy.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", actual.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
|
|
// Seed decoy with the human HEAD commit.
|
|
// ls-remote on decoy returns HEAD's IDs; without the guard those IDs
|
|
// exempt HEAD and the push succeeds to actual.
|
|
assert!(hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.path().to_str().unwrap(),
|
|
"push",
|
|
"-q",
|
|
decoy.path().to_str().unwrap(),
|
|
"HEAD:refs/heads/main",
|
|
])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
|
|
// Wire origin → decoy. The porcelain To header will show the decoy URL;
|
|
// ls-remote on that URL returns HEAD's IDs (the bypass exclusion source).
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", decoy.path().to_str().unwrap()],
|
|
);
|
|
|
|
// The receivepack script ignores its <url> argument and routes all
|
|
// receive-pack traffic to `actual` instead. Without the guard, git's
|
|
// dry-run + real push both talk to actual via this script while ls-remote
|
|
// exempts HEAD by reading decoy.
|
|
let actual_path = actual.path().to_str().unwrap().to_owned();
|
|
let script = repo.path().join("rp.sh");
|
|
std::fs::write(
|
|
&script,
|
|
format!("#!/bin/sh\nexec git-receive-pack {actual_path}\n"),
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
// ── Test 1a: --receive-pack=<script> flag form ────────────────────────────
|
|
let rp_arg = format!("--receive-pack={}", script.display());
|
|
let out = wrapper(&path, repo.path(), &["push", &rp_arg, "origin", "main"]);
|
|
|
|
assert!(
|
|
!out.status.success(),
|
|
"push with --receive-pack must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("receive-pack"),
|
|
"expected the receive-pack managed-mode refusal; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
|
|
// `actual` must be empty — the push was refused before any transport.
|
|
let refs = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
actual.path().to_str().unwrap(),
|
|
"show-ref",
|
|
"--verify",
|
|
"refs/heads/main",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
!refs.status.success(),
|
|
"actual must be empty after refused push; show-ref found: {}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
|
|
// ── Test 1b: remote.origin.receivepack config spelling ───────────────────
|
|
// Set the config key (Wes's explicit reproduction path); origin URL stays
|
|
// pointing at decoy so ls-remote still supplies the exemption set.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&[
|
|
"config",
|
|
"remote.origin.receivepack",
|
|
script.to_str().unwrap(),
|
|
],
|
|
);
|
|
|
|
let out2 = wrapper(&path, repo.path(), &["push", "origin", "main"]);
|
|
assert!(
|
|
!out2.status.success(),
|
|
"push with remote.origin.receivepack config must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out2.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out2.stderr).contains("receive-pack")
|
|
|| String::from_utf8_lossy(&out2.stderr).contains("receivepack"),
|
|
"expected the receivepack-config managed-mode refusal; stderr={}",
|
|
String::from_utf8_lossy(&out2.stderr),
|
|
);
|
|
let refs2 = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
actual.path().to_str().unwrap(),
|
|
"show-ref",
|
|
"--verify",
|
|
"refs/heads/main",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
!refs2.status.success(),
|
|
"actual must still be empty after config-based receivepack refusal; found: {}",
|
|
String::from_utf8_lossy(&refs2.stdout),
|
|
);
|
|
}
|
|
|
|
/// Wes (5055999359) P1 — real-wrapper regression: an alias that carries
|
|
/// `--rece <script>` (abbreviated `--receive-pack`, separate-value form) is
|
|
/// refused through the actual `buzz-acp`-as-`git` multicall.
|
|
///
|
|
/// Bypass shape (same orientation as the flag test above):
|
|
///
|
|
/// `origin` URL → `decoy` (seeded with offending HEAD — supplies exclusion set).
|
|
/// receivepack script → ignores its `<url>` argument; exec's
|
|
/// `git-receive-pack <actual>` instead.
|
|
/// alias body: `push --rece <script_path> origin main`
|
|
///
|
|
/// Without the guard: alias expands to `push --rece <script> origin main`;
|
|
/// git interprets `--rece` as `--receive-pack=<script>`; script routes all
|
|
/// receive-pack traffic to `actual`; ls-remote on decoy exempts HEAD →
|
|
/// `actual` receives the push.
|
|
///
|
|
/// With guard: `is_receive_pack_or_exec_flag("--rece")` fires before any push
|
|
/// → `actual` stays empty.
|
|
///
|
|
/// Mutation-sensitive: removing the `--rece` prefix check from
|
|
/// `is_receive_pack_or_exec_flag` makes the bypass succeed; `actual` becomes
|
|
/// populated and the `show-ref` assertion fires.
|
|
///
|
|
/// Note: `is_safe_alias_token("--rece")` returns `true` (starts with `-` but
|
|
/// contains no `=` and no quote), so the token passes the alias-safety filter
|
|
/// and reaches the receive-pack guard. An unrelated alias rejection would not
|
|
/// produce a "receive-pack" stderr message; the assertion below distinguishes
|
|
/// the two.
|
|
#[test]
|
|
fn wrapper_refuses_alias_with_abbreviated_receive_pack_flag() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
// decoy: seeded with HEAD; ls-remote here is the exemption source.
|
|
let decoy = tempfile::tempdir().unwrap();
|
|
// actual: starts empty; populated by the bypass if the guard is absent.
|
|
let actual = tempfile::tempdir().unwrap();
|
|
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", decoy.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", actual.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
|
|
// Seed decoy with HEAD so its IDs would exempt the commit under bypass.
|
|
assert!(hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.path().to_str().unwrap(),
|
|
"push",
|
|
"-q",
|
|
decoy.path().to_str().unwrap(),
|
|
"HEAD:refs/heads/main",
|
|
])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
|
|
// Wire origin → decoy (the exclusion-set source, not the write target).
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", decoy.path().to_str().unwrap()],
|
|
);
|
|
|
|
// The receivepack script ignores its <url> argument and routes all
|
|
// receive-pack traffic to `actual`. No spaces in the path (tempdir on
|
|
// macOS/Linux is under /private/var/folders/... or /tmp — no spaces).
|
|
let actual_path = actual.path().to_str().unwrap().to_owned();
|
|
let script = repo.path().join("rp.sh");
|
|
std::fs::write(
|
|
&script,
|
|
format!("#!/bin/sh\nexec git-receive-pack {actual_path}\n"),
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
// Configure the alias using the separate-value abbreviated form `--rece`.
|
|
// `is_safe_alias_token("--rece")` passes (starts with `-` but no `=`, no
|
|
// quote), so the alias expander emits it into effective_argv, where
|
|
// `is_receive_pack_or_exec_flag` must catch it.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&[
|
|
"config",
|
|
"alias.p",
|
|
&format!("push --rece {} origin main", script.display()),
|
|
],
|
|
);
|
|
|
|
let out = wrapper(&path, repo.path(), &["p"]);
|
|
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias-carried --rece must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Must be the receive-pack managed-mode refusal specifically, not an
|
|
// unrelated alias rejection (which would not mention "receive-pack").
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("receive-pack"),
|
|
"expected the receive-pack managed-mode refusal (not an unrelated alias \
|
|
rejection); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
|
|
// actual must be empty — the push was refused before any data was sent.
|
|
let refs = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
actual.path().to_str().unwrap(),
|
|
"show-ref",
|
|
"--verify",
|
|
"refs/heads/main",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
!refs.status.success(),
|
|
"actual must be empty after refused push; show-ref found: {}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R9 real-wrapper regression: `alias.push = status` (a builtin-shadowing alias
|
|
/// git silently ignores) must NOT suppress push verification.
|
|
///
|
|
/// **Bypass shape (without the fix):**
|
|
/// `verify_alias_safety` previously performed an unconditional alias lookup on
|
|
/// every command word, including builtins. With `alias.push=status` set it
|
|
/// expanded `push` → `status` and returned `effective_argv` with subcommand
|
|
/// `status` → `is_push_command` returned `NotPush` → `verify_push` was skipped
|
|
/// → `exec_real_git` ran the original `push` argv → git ignored the alias (it
|
|
/// is a builtin) → real push succeeded with the human-authored HEAD.
|
|
///
|
|
/// **Mutation evidence:**
|
|
/// Removing the `!builtins.is_empty() && builtins.contains(name.as_str())` break
|
|
/// from `verify_alias_safety` recreates the bypass: the alias expands, the
|
|
/// subcommand reads `status`, verification is skipped, and the remote receives
|
|
/// the commit. The `for-each-ref` assertion below would then fire.
|
|
#[test]
|
|
fn wrapper_refuses_push_via_builtin_shadowing_alias() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Set alias.push=status — a builtin-shadowing alias git silently ignores.
|
|
// The wrapper must honour git's builtin-first precedence and refuse the
|
|
// human-authored commit exactly as it would without this alias.
|
|
wrapper(&path, repo.path(), &["config", "alias.push", "status"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["push", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"push with alias.push=status must be refused (builtin-shadowing alias must not \
|
|
suppress verification); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection (not an alias safety error or status output); \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Destination must be empty — the push was refused before anything was sent.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R10/P1-a real-wrapper regression: `git pub` with `alias.pub.command = push`
|
|
/// (the Git 2.54 subsection form, no plain `alias.pub`) must be expanded and
|
|
/// refused — a human-authored commit must not reach the remote.
|
|
///
|
|
/// **Bypass shape (without the fix):**
|
|
/// The wrapper probed only `config --get alias.pub`, which exits non-zero for a
|
|
/// `.command`-only alias. Both `verify_alias_safety` and `is_push_command`
|
|
/// returned early as if `pub` were a real subcommand; `enforce` saw no alias to
|
|
/// expand, skipped the push gate, and `exec_real_git` ran the original argv.
|
|
/// Git then expanded `alias.pub.command=push` internally and published the
|
|
/// human-authored commit.
|
|
///
|
|
/// **Fix:** `git_supports_subsection_alias` probes actual dispatch (not config
|
|
/// storage); `resolve_alias` enumerates all matching entries in traversal order
|
|
/// via `config --get-regexp`; the last definition wins.
|
|
///
|
|
/// **Self-gate:** skips on binaries where the dispatch probe returns `Unsupported` —
|
|
/// those binaries don't execute `.command` aliases.
|
|
#[test]
|
|
fn wrapper_refuses_push_via_subsection_command_alias() {
|
|
// Dispatch probe: does this binary execute alias._probe_.command=version?
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Set ONLY the .command form (no plain alias.pub).
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub.command=push must be expanded and refused (human-authored HEAD); \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Destination must be empty.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via .command alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R10/P1-b real-wrapper regression: when both plain `alias.pub = status` and
|
|
/// subsection `alias.pub.command = push` are present (subsection last), the
|
|
/// last definition wins and the push must be refused.
|
|
///
|
|
/// **Bypass shape (without the fix):**
|
|
/// The wrapper only queried `config --get alias.pub` (the plain form), saw
|
|
/// `status`, followed it to `NotPush`, and skipped push verification. Real git
|
|
/// 2.54 resolved in traversal order; with `.command` last, it dispatched `push`
|
|
/// and published the commit.
|
|
///
|
|
/// **Fix:** `resolve_alias` enumerates all matching forms in traversal order
|
|
/// via `config --get-regexp`; the last entry's value is the effective alias.
|
|
///
|
|
/// **Self-gate:** skips on binaries without subsection alias dispatch support.
|
|
#[test]
|
|
fn wrapper_refuses_push_subsection_command_overrides_plain_alias() {
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Plain form would redirect to status (NotPush); command form correctly
|
|
// points to push. git 2.54 resolves .command first — the wrapper must too.
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", "status"]);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub.command=push must override alias.pub=status and be refused; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection (not a status execution); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via .command-override alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R10/P2 real-wrapper positive control: a genuine `git status` with
|
|
/// `alias.status = push` must NOT be blocked — the builtin-shadowing alias
|
|
/// must not cause `verify_push` to run a push probe on a status invocation.
|
|
///
|
|
/// **Defect shape (without the fix):**
|
|
/// `is_push_command` did an unconditional alias walk. With `alias.status=push`
|
|
/// set it returned `Push`, triggering `verify_push` which probed the original
|
|
/// `status` argv with `--dry-run --porcelain --no-verify`. Git status rejects
|
|
/// push-only flags → exit non-zero → legitimate `status` was blocked.
|
|
///
|
|
/// **Fix:** `is_push_command` now short-circuits at non-deprecated builtins
|
|
/// before consulting alias config.
|
|
///
|
|
/// **Mutation evidence:** removing the `is_nondeprecated_builtin` early-return
|
|
/// from `is_push_command` makes this test FAIL (status is incorrectly blocked).
|
|
#[test]
|
|
fn wrapper_allows_status_with_builtin_shadowing_push_alias() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
// Set alias.status=push — a builtin-shadowing alias git silently ignores.
|
|
wrapper(&path, repo.path(), &["config", "alias.status", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["status", "--short"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"git status with a builtin-shadowing alias.status=push must succeed; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Confirm it actually produced status output (not an error or push output).
|
|
assert!(
|
|
!String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"status must not have hit the push gate; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
/// R9/P5 real-wrapper regression: `alias.whatchanged = -p push origin main`
|
|
/// must be expanded and held to policy on binaries where `--list-cmds=deprecated`
|
|
/// succeeds and lists `whatchanged` (i.e. deprecated builtins are alias-first).
|
|
/// at `whatchanged` (it is in `--list-cmds=builtins`) and returns `Ok(None)`.
|
|
/// The fallback `is_push_command` then reads `alias.whatchanged = -p push` and
|
|
/// takes `-p` as the first command word (no `alias.-p` → `NotPush`). Real git,
|
|
/// however, reparses `-p` as a global option and executes `push` — the command
|
|
/// executes unverified. The destination acquires commits and the
|
|
/// `for-each-ref` assertion below fires.
|
|
///
|
|
/// **Why `-p push` and not plain `push`:**
|
|
/// With a plain `alias.whatchanged = push` body the fallback `is_push_command`
|
|
/// (git_wrapper.rs:318-332) also follows the alias and returns `Push` on its own,
|
|
/// so `verify_push` still runs and the test stays green even without the
|
|
/// deprecated-aware fix. The `-p push` body breaks that secondary path while
|
|
/// remaining transparent to real git, making this test genuinely sensitive to
|
|
/// the deprecated-exclusion fix.
|
|
///
|
|
/// **Fix:** `git_deprecated_commands(real_git)` queries `--list-cmds=deprecated`
|
|
/// for the same binary. The short-circuit now requires `builtin AND NOT
|
|
/// deprecated`: when `whatchanged` is in both sets, alias expansion continues,
|
|
/// resolves to `push`, and policy refuses the human-authored commit.
|
|
///
|
|
/// Self-gate: skips cleanly on binaries where `--list-cmds=deprecated` exits
|
|
/// non-zero or does not list `whatchanged` — those binaries do not have the
|
|
/// alias-first deprecated dispatch, so there is no bypass to test.
|
|
#[test]
|
|
fn wrapper_refuses_push_via_deprecated_builtin_alias() {
|
|
// Probe whether the PATH git supports --list-cmds=deprecated and actually
|
|
// lists whatchanged. Skip on binaries where the deprecated-builtin
|
|
// alias-first path does not exist.
|
|
let probe = hermetic_command("git")
|
|
.args(["--list-cmds=deprecated"])
|
|
.output()
|
|
.unwrap();
|
|
if !probe.status.success() {
|
|
eprintln!("skip: git --list-cmds=deprecated unsupported on this binary");
|
|
return;
|
|
}
|
|
let deprecated_list = String::from_utf8_lossy(&probe.stdout);
|
|
if !deprecated_list.lines().any(|l| l.trim() == "whatchanged") {
|
|
eprintln!("skip: `whatchanged` not in --list-cmds=deprecated output on this binary");
|
|
return;
|
|
}
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Set alias.whatchanged = -p push.
|
|
// `-p` is a safe bare-word token (no `-c`, no `=`, no quote) that real git
|
|
// reparses as the --paginate global, so `git whatchanged -p push origin
|
|
// main` is equivalent to `git -p push origin main` at exec time.
|
|
// With the deprecated-aware fix, `verify_alias_safety` follows the alias
|
|
// (whatchanged is deprecated → alias-first), expands to effective argv with
|
|
// subcommand `push`, and policy refuses.
|
|
// Without the fix, the builtin short-circuit fires at `whatchanged`,
|
|
// verify_alias_safety returns Ok(None), and the fallback is_push_command
|
|
// reads alias.whatchanged = -p push but takes `-p` as the command word
|
|
// (no alias.-p exists → NotPush) — verification is skipped, real git
|
|
// reparses -p as a global and executes the push unverified.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.whatchanged", "-p push"],
|
|
);
|
|
|
|
// Invoke via the deprecated builtin name `whatchanged`.
|
|
// Pass `origin main` as trailing argv so verify_push / resolve_push_sources
|
|
// can identify the destination.
|
|
let out = wrapper(&path, repo.path(), &["whatchanged", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.whatchanged=-p push must be expanded and refused (deprecated builtin is \
|
|
alias-first on this binary); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection via whatchanged alias; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Destination must be empty — no commit should have reached the remote.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via whatchanged alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Thufir R11 reverse-order: when plain `alias.pub = push` is defined **after**
|
|
/// `alias.pub.command = status` (so plain is the last definition), the last
|
|
/// definition wins and the push must be refused.
|
|
///
|
|
/// This confirms the wrapper implements true last-wins ordering rather than
|
|
/// hard-coding `.command`-first precedence. An old `.command`-first fix would
|
|
/// see `status` (the `.command` value) and classify the command as NotPush,
|
|
/// letting a real push through unverified.
|
|
///
|
|
/// **Self-gate:** skips on binaries without subsection alias dispatch support.
|
|
#[test]
|
|
fn wrapper_refuses_push_plain_last_overrides_subsection_command() {
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// command=status first, plain=push last — last definition wins → push.
|
|
// A .command-first resolver would see status and wrongly classify as NotPush.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.pub.command", "status"],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub=push (last) must override alias.pub.command=status (first) and be refused; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection (not status output); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Mutation evidence: deleting the last-wins branch from `resolve_alias`
|
|
// and reverting to `.command`-first makes this test PASS (push reaches the
|
|
// remote) instead of asserting the push was refused.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via plain-last alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Thufir R11 empty-subsection: `alias..pub = push` (empty-subsection form)
|
|
/// must be refused on binaries that dispatch it (Git 2.54). Git treats
|
|
/// `alias..<name>` as plain configuration and includes it in last-wins ordering.
|
|
///
|
|
/// **Self-gate:** skips on binaries that do not dispatch the empty-subsection
|
|
/// form — those binaries treat it as an unknown alias name, so no bypass exists.
|
|
#[test]
|
|
fn wrapper_refuses_push_via_empty_subsection_alias() {
|
|
// Two-step gate: dispatch probe AND empty-subsection dispatch (both isolated).
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
match isolated_empty_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias..<name> form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("empty-subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Empty-subsection form alias..pub = push.
|
|
// `git config` cannot set an empty-subsection key via command-line arguments;
|
|
// write the config fragment directly.
|
|
let git_config = repo.path().join(".git").join("config");
|
|
let existing = std::fs::read_to_string(&git_config).unwrap_or_default();
|
|
std::fs::write(
|
|
&git_config,
|
|
format!("{existing}\n[alias \"\"]\n\tpub = push\n"),
|
|
)
|
|
.unwrap();
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias..pub=push (empty-subsection) must be expanded and refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected the push-gate rejection; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Mutation evidence: removing empty-subsection matching from `resolve_alias`
|
|
// leaves `pub` unresolved; the wrapper treats it as a real (non-push)
|
|
// command; git dispatches the push; the remote acquires the commit.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via empty-subsection alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Thufir R11 positive control: a legitimate agent-authored push via a
|
|
/// subsection alias (`alias.pub.command = push`) is allowed through — the
|
|
/// wrapper must not block valid subsection-alias pushes.
|
|
///
|
|
/// **Self-gate:** skips on binaries without subsection alias dispatch support.
|
|
#[test]
|
|
fn wrapper_allows_agent_push_via_subsection_command_alias() {
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
let (_shim, path, email, _keydir) = signed_shim_env();
|
|
let (_work, repo, remote) = agent_repo_with_remote(&email);
|
|
// Make an agent-signed commit so there is something to push.
|
|
std::fs::write(repo.join("f"), "x").unwrap();
|
|
wrapper(&path, &repo, &["add", "f"]);
|
|
let commit_out = wrapper(&path, &repo, &["commit", "-m", "agent commit"]);
|
|
assert!(
|
|
commit_out.status.success(),
|
|
"agent commit must succeed; stderr={}",
|
|
String::from_utf8_lossy(&commit_out.stderr),
|
|
);
|
|
// Set ONLY the subsection form; no plain alias.pub.
|
|
wrapper(&path, &repo, &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, &repo, &["pub", "origin", "main"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"agent-authored commit via subsection alias.pub.command=push must succeed; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Remote must have received the commit.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
!refs.stdout.is_empty(),
|
|
"remote must have received the agent commit via subsection alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
// ── Thufir pass-2 new regression shapes ──────────────────────────────────────
|
|
|
|
/// R12-a: Uppercase invocation — `git PUB` where `alias.pub = push` must be
|
|
/// refused. Git normalises section-variable names to lowercase, so the plain
|
|
/// alias key is `alias.pub` regardless of how the command was invoked. The
|
|
/// wrapper must case-insensitively compare the invocation name to the key; a
|
|
/// case-sensitive compare would return `NotPush` and bypass verification.
|
|
///
|
|
/// The invocation passes `PUB` (uppercased) as the subcommand. On a
|
|
/// supporting binary the NUL-framed walk compares case-insensitively; on an
|
|
/// unsupporting binary `git config --get alias.PUB` would fail (git normalises
|
|
/// on write, so the key is stored as `alias.pub`). The resolver handles both:
|
|
/// the plain-form path on unsupporting binaries falls through to `config --get`
|
|
/// which already does the right thing (git's own key lookup is case-insensitive
|
|
/// for the variable part).
|
|
#[test]
|
|
fn wrapper_refuses_push_via_uppercase_alias_invocation() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", "push"]);
|
|
|
|
// Invoke as "PUB" (uppercase) — same underlying alias, different casing.
|
|
let out = wrapper(&path, repo.path(), &["PUB", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub=push invoked as PUB must be refused (human-authored HEAD); \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected push-gate author refusal; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via uppercase alias invocation; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R12-b: Regex-metachar subsection name — `alias.pub[1].command = push`
|
|
/// (subsection `pub[1]`) invoked as `pub[1]`. The name `pub[1]` would be
|
|
/// mismatched by a regex-interpolated pattern (the `[1]` is a character class
|
|
/// in ERE); the structural-parse resolver must match it literally.
|
|
///
|
|
/// Self-gate: skips on binaries that don't dispatch `.command` form aliases.
|
|
#[test]
|
|
fn wrapper_refuses_push_via_regex_metachar_subsection_alias() {
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Write alias.pub[1].command = push directly into repo config (git config
|
|
// CLI won't accept metachar subsection names as arguments).
|
|
let git_config = repo.path().join(".git").join("config");
|
|
let existing = std::fs::read_to_string(&git_config).unwrap_or_default();
|
|
std::fs::write(
|
|
&git_config,
|
|
format!("{existing}\n[alias \"pub[1]\"]\n\tcommand = push\n"),
|
|
)
|
|
.unwrap();
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub[1]", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub[1].command=push must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected push-gate author refusal; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via regex-metachar subsection alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R12-c/d: Dotted-command alias regression — two cases.
|
|
///
|
|
/// Case A: `alias.pub.command.command = push` + `alias.pub.command = status`.
|
|
/// Invoking `pub.command` should resolve the subsection alias for `pub.command`
|
|
/// (defined by `alias.pub.command.command=push`) and refuse. The presence of
|
|
/// `alias.pub.command=status` must not obscure the push.
|
|
///
|
|
/// Case B (CRIT-1): `alias..pub.command = push` invoked as `.pub`.
|
|
/// Git 2.54 parses this as subsection `.pub`, variable `command` — dispatches
|
|
/// `.pub` via the subsection alias path. The wrapper must classify the
|
|
/// subsection as ".pub" (leading dot preserved) and refuse.
|
|
///
|
|
/// Both cases must produce an author-refusal + empty remote.
|
|
///
|
|
/// Self-gate: skips on binaries that don't dispatch `.command` form aliases.
|
|
#[test]
|
|
fn wrapper_does_not_misresove_dotted_command_name_as_subsection_alias() {
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
// ── Case A: alias.pub.command.command=push, alias.pub.command=status ──
|
|
{
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
|
|
// Both entries present: the push lives in the deeper subsection key.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.pub.command.command", "push"],
|
|
);
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.pub.command", "status"],
|
|
);
|
|
|
|
// Invoke pub.command — should resolve to push via the subsection alias;
|
|
// must be refused.
|
|
let out = wrapper(&path, repo.path(), &["pub.command", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub.command.command=push must be refused for `pub.command`; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected push-gate author refusal for dotted-name alias; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused dotted-command push; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
// ── Case B: CRIT-1 — alias..pub.command=push invoked as `.pub` ──
|
|
{
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
|
|
let out = wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&[
|
|
"-c",
|
|
"alias..pub.command=push",
|
|
".pub",
|
|
remote.path().to_str().unwrap(),
|
|
"main",
|
|
],
|
|
);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias..pub.command=push must be refused when invoked as `.pub`; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected push-gate author refusal for leading-dot subsection alias; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused leading-dot subsection push; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// R12-d: Stored-newline value — `alias.pub = push\norigin` (a literal newline
|
|
/// in the value, stored in repo config, NOT in argv). Git treats the newline
|
|
/// as whitespace when parsing the alias body, so `git pub main` dispatches
|
|
/// `git push origin main`. The wrapper must resolve the full multi-line value
|
|
/// from the NUL-framed output and detect the push subcommand in the body.
|
|
///
|
|
/// This is distinct from the argv-newline guard (which rejects `\n` in push
|
|
/// URLs): here the newline lives in the stored alias body, not on the command
|
|
/// line.
|
|
///
|
|
/// Self-gate: skips on binaries that don't dispatch `.command` form aliases
|
|
/// (NUL-framed enumeration is only used on supporting binaries; the old-git
|
|
/// path falls back to `config --get` which also returns the full value).
|
|
#[test]
|
|
fn wrapper_refuses_push_via_multiline_alias_value() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
|
|
// Store alias.pub = "push\norigin" — a value containing a literal newline.
|
|
// `git config` can set multi-line values; the value is preserved as-is.
|
|
let git_config = repo.path().join(".git").join("config");
|
|
let existing = std::fs::read_to_string(&git_config).unwrap_or_default();
|
|
std::fs::write(
|
|
&git_config,
|
|
format!("{existing}\n[alias]\n\tpub = push\\norigin\n"),
|
|
)
|
|
.unwrap();
|
|
|
|
// git pub main — git expands "push\norigin" treating \n as whitespace →
|
|
// effectively `git push origin main`.
|
|
let out = wrapper(&path, repo.path(), &["pub", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub with newline-in-body (push\\norigin) must be refused; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// The wrapper must have caught the push alias, not silently let git run it.
|
|
// Either a push-gate refusal OR an alias-safety refusal (the body may
|
|
// contain a backslash or other unsafe token) is acceptable here — the key
|
|
// is that the push did NOT reach the remote.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push via multiline alias value; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// R12-e: Probe-poisoning — a `git-_probe_` helper placed BESIDE the selected
|
|
/// git binary must NOT flip the capability verdict. The new probe uses a
|
|
/// probe-only private directory (containing only a controlled `git` symlink)
|
|
/// for both `PATH` and `GIT_EXEC_PATH`, so git's exec-path search cannot find
|
|
/// any adjacent helper.
|
|
///
|
|
/// This test exercises the INSTALLED WRAPPER (not the test-only probe fn):
|
|
/// - Positive control: sibling helper exits 42 + prints "git version sentinel
|
|
/// helper" → wrapper still refuses the push with author-refusal (probe was
|
|
/// not fooled; capability verdict is correct).
|
|
/// - Negative control: same alias without any sibling helper → also refused.
|
|
///
|
|
/// Both controls must produce author-refusal + empty remote.
|
|
/// Does NOT mutate the global PATH — uses explicit PATH env on wrapper() instead.
|
|
#[test]
|
|
fn probe_isolation_rejects_sibling_helper_poisoning() {
|
|
match isolated_subsection_probe() {
|
|
ProbeVerdict::Supported => {}
|
|
ProbeVerdict::Unsupported => {
|
|
eprintln!("skip: installed git does not dispatch alias.<name>.command form");
|
|
return;
|
|
}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
|
|
// Find the real git binary's absolute path.
|
|
let git_dir = real_git_dir();
|
|
let real_git_abs = {
|
|
let candidate = git_dir.join("git");
|
|
candidate.canonicalize().unwrap_or(candidate)
|
|
};
|
|
|
|
// Create a "sibling dir": a directory that contains both `git` (symlink to
|
|
// the real binary) and `git-_probe_` (a helper that exits 42 and prints
|
|
// the sentinel string — the classic PATH-poisoning payload).
|
|
// When we build a PATH with sibling_dir before shim_dir, find_real_git()
|
|
// inside the wrapper finds sibling_dir/git as the "real" git, so the
|
|
// probe's isolation logic is exercised against a helper adjacent to the
|
|
// selected binary.
|
|
let sibling_dir = tempfile::tempdir().unwrap();
|
|
let git_link_in_sibling = sibling_dir.path().join("git");
|
|
std::os::unix::fs::symlink(&real_git_abs, &git_link_in_sibling).unwrap();
|
|
let helper_path = sibling_dir.path().join("git-_probe_");
|
|
{
|
|
use std::io::Write;
|
|
let mut f = std::fs::File::create(&helper_path).unwrap();
|
|
write!(
|
|
f,
|
|
"#!/bin/sh\nprintf 'git version sentinel helper\\n'\nexit 42\n"
|
|
)
|
|
.unwrap();
|
|
}
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&helper_path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&helper_path, perms).unwrap();
|
|
}
|
|
|
|
// Build the shim environment manually, using sibling_dir as the real-git
|
|
// dir in the PATH string. This avoids mutating the process-global PATH,
|
|
// which would cause races with parallel tests.
|
|
//
|
|
// PATH = shim_dir : sibling_dir : (original PATH minus real git dir)
|
|
//
|
|
// find_real_git() inside the wrapper will:
|
|
// 1. See shim_dir/git — skip it (that's itself, buzz-acp).
|
|
// 2. See sibling_dir/git — pick it up as the "real" git.
|
|
// (sibling_dir/git is a symlink to real_git_abs, so execution is correct.)
|
|
let make_path_with_sibling = |shim: &tempfile::TempDir| -> String {
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let filtered: Vec<PathBuf> = std::env::split_paths(&original_path)
|
|
.filter(|d| d.canonicalize().ok() != git_dir.canonicalize().ok())
|
|
.collect();
|
|
std::env::join_paths(
|
|
std::iter::once(shim.path())
|
|
.chain(std::iter::once(sibling_dir.path()))
|
|
.chain(filtered.iter().map(|d| d.as_path())),
|
|
)
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap()
|
|
};
|
|
|
|
// ── Positive control: sibling helper present ──
|
|
{
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name))
|
|
.unwrap();
|
|
}
|
|
let entries = manifest_entries(&id);
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap();
|
|
let path = make_path_with_sibling(&shim);
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
// Use the shim path for setup commands too.
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"push via subsection alias must be refused despite sibling helper; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected author-refusal despite sibling helper; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty (positive control); refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
// ── Negative control: no helper, same PATH setup ──
|
|
std::fs::remove_file(&helper_path).unwrap();
|
|
{
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name))
|
|
.unwrap();
|
|
}
|
|
let entries = manifest_entries(&id);
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap();
|
|
let path = make_path_with_sibling(&shim);
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"push via subsection alias must be refused without sibling helper; \
|
|
stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected author-refusal (negative control); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty (negative control); refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// R12-f (primary binary): Mixed-definition on the primary git binary — when BOTH
|
|
/// `alias.pub` (plain form, push) AND `alias.pub.command` (subsection form, status)
|
|
/// are set, the effective alias depends on capability.
|
|
///
|
|
/// **Definition order:** plain=push first, `.command=status` second. This order
|
|
/// distinguishes the two semantics: on a supporting binary last-wins applies and
|
|
/// `.command=status` overrides the plain form; on a non-supporting binary the
|
|
/// `.command` form is invisible and `plain=push` is the only effective definition.
|
|
///
|
|
/// On supporting git (2.54): `.command=status` wins (last-wins). The wrapper
|
|
/// allows the alias (no push → no push-gate refusal).
|
|
/// On non-supporting git (2.50): `.command` invisible; plain=push is effective
|
|
/// → push is refused with the author-policy error, remote stays empty, and
|
|
/// `for-each-ref` exits 0 confirming the ref check itself succeeded.
|
|
///
|
|
/// Runs unconditionally: does not require a second git binary. For two-binary
|
|
/// capability-matrix coverage see `wrapper_refuses_push_plain_last_wins_alt_binary`.
|
|
#[test]
|
|
fn wrapper_refuses_push_plain_last_wins_primary_binary() {
|
|
// ── Primary binary ──
|
|
let primary_supports_subsection = isolated_subsection_probe();
|
|
match primary_supports_subsection {
|
|
ProbeVerdict::Supported | ProbeVerdict::Unsupported => {}
|
|
ProbeVerdict::Failure => panic!("subsection probe failed (setup/spawn/timeout)"),
|
|
}
|
|
eprintln!(
|
|
"primary binary: {} subsection aliases",
|
|
if primary_supports_subsection == ProbeVerdict::Supported {
|
|
"SUPPORTS"
|
|
} else {
|
|
"does NOT support"
|
|
}
|
|
);
|
|
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Write BOTH definitions: plain=push first, .command=status second.
|
|
// Definition order distinguishes the two semantics:
|
|
// - Supporting binary (last-wins): .command=status overrides plain=push
|
|
// → alias expands to status → no push-gate refusal.
|
|
// - Non-supporting binary: .command form is invisible; plain=push is the
|
|
// only effective definition → push → push-gate refusal.
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", "push"]);
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.pub.command", "status"],
|
|
);
|
|
|
|
match primary_supports_subsection {
|
|
ProbeVerdict::Supported => {
|
|
// Supporting binary: .command=status wins; the push gate does not
|
|
// fire. The invocation must succeed, and the remote stays empty
|
|
// because status never pushed anything.
|
|
let out = wrapper(&path, repo.path(), &["pub"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"supporting binary (.command wins): status alias must succeed; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert_remote_empty(remote.path());
|
|
}
|
|
ProbeVerdict::Unsupported => {
|
|
// Non-supporting binary: .command is invisible; plain=push fires.
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub=push (plain, non-supporting binary) must be refused; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr)
|
|
.contains("not authored by your agent identity"),
|
|
"expected push-gate author refusal on non-supporting primary binary; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.status.success(),
|
|
"for-each-ref must exit 0; status={:?}",
|
|
refs.status,
|
|
);
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty on non-supporting primary binary; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
ProbeVerdict::Failure => unreachable!("Failure already caught above"),
|
|
}
|
|
}
|
|
|
|
/// R12-f (alternate binary): same mixed-definition refusal exercised against a
|
|
/// second git installation (e.g. Homebrew git alongside Apple git).
|
|
///
|
|
/// **Requires two distinct git binaries at `/usr/bin/git` and
|
|
/// `/opt/homebrew/bin/git`.** Run with `--run-ignored` on a machine that has
|
|
/// both installations; the required `just test-unit` CI lane (Ubuntu) does not
|
|
/// provision a second git and would fail deterministically without `#[ignore]`.
|
|
#[test]
|
|
#[ignore = "requires two git installations (e.g. /opt/homebrew/bin/git + /usr/bin/git)"]
|
|
fn wrapper_refuses_push_plain_last_wins_alt_binary() {
|
|
let apple_git = std::path::Path::new("/usr/bin/git");
|
|
let brew_git = std::path::Path::new("/opt/homebrew/bin/git");
|
|
let primary = real_git_dir().join("git");
|
|
let alt_git_dir: Option<std::path::PathBuf> = [apple_git, brew_git]
|
|
.iter()
|
|
.find(|p| p.is_file() && p.canonicalize().ok() != primary.canonicalize().ok())
|
|
.map(|p| p.parent().unwrap().to_path_buf());
|
|
|
|
let alt_dir = match alt_git_dir {
|
|
Some(d) => d,
|
|
None => {
|
|
eprintln!(
|
|
"wrapper_refuses_push_plain_last_wins_alt_binary: skipping — \
|
|
no second git binary found at /usr/bin/git or /opt/homebrew/bin/git \
|
|
distinct from the primary; provision two git installations to run this test"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let alt_git = alt_dir.join("git");
|
|
let alt_ver = hermetic_command(&alt_git)
|
|
.arg("--version")
|
|
.output()
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.unwrap_or_else(|_| "unknown".to_string());
|
|
|
|
// Probe the alt binary with the same isolation model as the primary.
|
|
let alt_supports_subsection =
|
|
run_isolated_probe_for(&alt_git, "alias._probe_.command=version", "_probe_");
|
|
assert!(
|
|
alt_supports_subsection != ProbeVerdict::Failure,
|
|
"alt-git subsection probe failed (setup/spawn/timeout/unclassifiable)"
|
|
);
|
|
let primary_supports_subsection = isolated_subsection_probe();
|
|
assert!(
|
|
primary_supports_subsection != ProbeVerdict::Failure,
|
|
"primary-git subsection probe failed (setup/spawn/timeout/unclassifiable)"
|
|
);
|
|
assert_ne!(
|
|
alt_supports_subsection, primary_supports_subsection,
|
|
"prerequisite: two git installations with DIFFERENT subsection-alias capability"
|
|
);
|
|
eprintln!(
|
|
"alternate binary ({alt_ver} @ {}): {} subsection aliases",
|
|
alt_git.display(),
|
|
if alt_supports_subsection == ProbeVerdict::Supported {
|
|
"SUPPORTS"
|
|
} else {
|
|
"does NOT support"
|
|
}
|
|
);
|
|
|
|
// Build the shim PATH string with alt_dir as the real-git dir — no global
|
|
// PATH mutation needed. PATH = shim_dir : alt_dir : (original minus primary)
|
|
// find_real_git() in the wrapper subprocess sees shim_dir/git (itself, skip)
|
|
// then alt_dir/git (the alt binary — pick it up).
|
|
let primary_dir = real_git_dir();
|
|
let make_alt_path = |shim: &tempfile::TempDir| -> String {
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let filtered: Vec<PathBuf> = std::env::split_paths(&original_path)
|
|
.filter(|d| d.canonicalize().ok() != primary_dir.canonicalize().ok())
|
|
.collect();
|
|
std::env::join_paths(
|
|
std::iter::once(shim.path())
|
|
.chain(std::iter::once(alt_dir.as_path()))
|
|
.chain(filtered.iter().map(|d| d.as_path())),
|
|
)
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap()
|
|
};
|
|
|
|
let result = std::panic::catch_unwind(|| {
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name))
|
|
.unwrap();
|
|
}
|
|
let entries = manifest_entries(&id);
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap();
|
|
let path = make_alt_path(&shim);
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command(&alt_git)
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
// Write BOTH definitions on the alt binary too: plain=push first,
|
|
// .command=status second. The alt binary has the OPPOSITE capability
|
|
// from primary (enforced by the prerequisite assertion above), so this
|
|
// ordering exercises the branch that the primary test cannot reach.
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", "push"]);
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "alias.pub.command", "status"],
|
|
);
|
|
|
|
match alt_supports_subsection {
|
|
ProbeVerdict::Supported => {
|
|
// Supporting alt binary: .command=status wins; the invocation
|
|
// must succeed and push nothing.
|
|
let out = wrapper(&path, repo.path(), &["pub"]);
|
|
assert!(
|
|
out.status.success(),
|
|
"alt binary ({alt_ver}, .command wins): status alias must succeed; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert_remote_empty(remote.path());
|
|
}
|
|
ProbeVerdict::Unsupported => {
|
|
// Non-supporting alt binary: .command invisible; plain=push.
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"alias.pub=push must be refused on alt git ({alt_ver}); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr)
|
|
.contains("not authored by your agent identity"),
|
|
"expected push-gate author refusal on alt git ({alt_ver}); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command(&alt_git)
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.status.success(),
|
|
"for-each-ref must exit 0 on alt git; status={:?}",
|
|
refs.status,
|
|
);
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty on alt git ({alt_ver}); refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
ProbeVerdict::Failure => {
|
|
panic!("alt-git subsection probe failed unexpectedly inside catch_unwind")
|
|
}
|
|
}
|
|
});
|
|
|
|
result.unwrap();
|
|
}
|
|
|
|
/// Regression: empty-value-then-valid ordering — last-wins allows the valid expansion.
|
|
///
|
|
/// `git -c alias.pub= -c alias.pub=version pub` executes `git version` on both
|
|
/// real git binaries (exit 0). The wrapper must also allow this invocation
|
|
/// (the push gate refuses it for identity reasons, not alias-resolution reasons).
|
|
///
|
|
/// Self-gate: skips on binaries that don't support plain aliases (all do).
|
|
#[test]
|
|
fn wrapper_allows_empty_then_valid_alias_override() {
|
|
// Both git binaries accept this ordering. Verify the wrapper's resolver
|
|
// does not prematurely bail on the first empty record.
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
|
|
// Write empty first, valid second — last-wins means "push" is effective.
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", ""]);
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["config", "--add", "alias.pub", "push"],
|
|
);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
// The push gate fires (author-identity check) — the alias WAS resolved.
|
|
assert!(
|
|
!out.status.success(),
|
|
"wrapper must refuse (author gate), not fail on alias resolution; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"),
|
|
"expected author-gate refusal after resolving empty-then-valid alias; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// Destination must be empty — the push was refused before anything landed.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused push; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Regression: valid-then-empty ordering — last-wins means empty wins, which
|
|
/// must be refused by the wrapper (fail closed on empty final expansion).
|
|
///
|
|
/// `git -c alias.pub=version -c alias.pub= pub` fails on both real git binaries
|
|
/// ("'' is not a git command"). The wrapper must refuse this too.
|
|
///
|
|
/// Self-gate: skips on binaries that don't support plain aliases (all do).
|
|
#[test]
|
|
fn wrapper_refuses_valid_then_empty_alias_override() {
|
|
let (_shim, path, _email, _keydir) = signed_shim_env();
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
|
|
// Write valid first, empty second — last-wins means empty is effective.
|
|
wrapper(&path, repo.path(), &["config", "alias.pub", "push"]);
|
|
wrapper(&path, repo.path(), &["config", "--add", "alias.pub", ""]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"wrapper must refuse empty final alias expansion; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
// The remote must be untouched — refused before any push.
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after refused empty-final alias; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Regression: probe classifier — exit 1 with version-looking stdout must NOT
|
|
/// classify as Unsupported; it must result in ProbeFailure (fail closed).
|
|
///
|
|
/// This test verifies the installed wrapper treats such a probe result as
|
|
/// ProbeFailure by constructing a PATH where the selected `git` binary is a
|
|
/// helper that exits 1 but prints "git version sentinel" to stdout.
|
|
#[test]
|
|
fn wrapper_treats_exit1_with_stdout_as_probe_failure_not_unsupported() {
|
|
// Build a fake "git" that exits 1 and prints "git version exit1" to stdout.
|
|
let fake_dir = tempfile::tempdir().unwrap();
|
|
let fake_git = fake_dir.path().join("git");
|
|
{
|
|
use std::io::Write;
|
|
let mut f = std::fs::File::create(&fake_git).unwrap();
|
|
write!(f, "#!/bin/sh\nprintf 'git version exit1\\n'\nexit 1\n").unwrap();
|
|
}
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&fake_git).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&fake_git, perms).unwrap();
|
|
}
|
|
|
|
// Build a shim PATH: shim_dir → fake_dir (fake git) → rest.
|
|
// find_real_git() will skip shim_dir (itself) and pick up fake_dir/git.
|
|
// The production probe for fake_dir/git will get exit 1 + "git version"
|
|
// stdout — this must yield ProbeFailure, causing the wrapper to refuse
|
|
// with "alias capability probe failed".
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name)).unwrap();
|
|
}
|
|
let entries = manifest_entries(&id);
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap();
|
|
|
|
// PATH: shim_dir : fake_dir : (original minus real-git dir)
|
|
let real_dir = real_git_dir();
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let filtered: Vec<PathBuf> = std::env::split_paths(&original_path)
|
|
.filter(|d| d.canonicalize().ok() != real_dir.canonicalize().ok())
|
|
.collect();
|
|
let path = std::env::join_paths(
|
|
std::iter::once(shim.path())
|
|
.chain(std::iter::once(fake_dir.path()))
|
|
.chain(filtered.iter().map(|d| d.as_path())),
|
|
)
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"wrapper must refuse when probe yields exit-1+stdout; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("alias capability probe failed"),
|
|
"expected ProbeFailure message for exit-1+stdout probe; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after probe-failure refusal; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Regression: probe classifier — exit 1 + empty stdout + UNRELATED stderr must NOT
|
|
/// classify as Unsupported; it must result in ProbeFailure (fail closed).
|
|
///
|
|
/// The Unsupported branch requires stderr to start with old git's sentinel
|
|
/// diagnostic (`git: '_probe_' is not a git command`); any other message is
|
|
/// not a recognized refusal.
|
|
#[test]
|
|
fn wrapper_treats_exit1_with_unrelated_stderr_as_probe_failure_not_unsupported() {
|
|
// Build a fake "git" that exits 1 with unrelated stderr and empty stdout.
|
|
let fake_dir = tempfile::tempdir().unwrap();
|
|
let fake_git = fake_dir.path().join("git");
|
|
{
|
|
use std::io::Write;
|
|
let mut f = std::fs::File::create(&fake_git).unwrap();
|
|
// Exits 1, stdout empty, stderr = unrelated message (no "is not a git command")
|
|
write!(f, "#!/bin/sh\nprintf 'unrelated error\\n' >&2\nexit 1\n").unwrap();
|
|
}
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&fake_git).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&fake_git, perms).unwrap();
|
|
}
|
|
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name)).unwrap();
|
|
}
|
|
let entries = manifest_entries(&id);
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap();
|
|
|
|
// PATH: shim_dir : fake_dir : (original minus real-git dir)
|
|
let real_dir = real_git_dir();
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let filtered: Vec<PathBuf> = std::env::split_paths(&original_path)
|
|
.filter(|d| d.canonicalize().ok() != real_dir.canonicalize().ok())
|
|
.collect();
|
|
let path = std::env::join_paths(
|
|
std::iter::once(shim.path())
|
|
.chain(std::iter::once(fake_dir.path()))
|
|
.chain(filtered.iter().map(|d| d.as_path())),
|
|
)
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"wrapper must refuse when probe yields exit-1+unrelated-stderr; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("alias capability probe failed"),
|
|
"expected ProbeFailure message for exit-1+unrelated-stderr probe; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after probe-failure refusal; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
/// Regression: probe classifier — exit 1 + empty stdout + EMPTY stderr must NOT
|
|
/// classify as Unsupported; it must result in ProbeFailure (fail closed).
|
|
///
|
|
/// A silent exit-1 (no stdout, no stderr) is not recognizable as old-git's
|
|
/// unknown-command refusal and must be treated as ProbeFailure.
|
|
#[test]
|
|
fn wrapper_treats_exit1_with_empty_stderr_as_probe_failure_not_unsupported() {
|
|
// Build a fake "git" that exits 1 with no stdout and no stderr.
|
|
let fake_dir = tempfile::tempdir().unwrap();
|
|
let fake_git = fake_dir.path().join("git");
|
|
{
|
|
use std::io::Write;
|
|
let mut f = std::fs::File::create(&fake_git).unwrap();
|
|
// Exits 1, stdout empty, stderr empty
|
|
write!(f, "#!/bin/sh\nexit 1\n").unwrap();
|
|
}
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&fake_git).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&fake_git, perms).unwrap();
|
|
}
|
|
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let shim = tempfile::tempdir().unwrap();
|
|
for name in ["git", "git-sign-nostr"] {
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name)).unwrap();
|
|
}
|
|
let entries = manifest_entries(&id);
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap();
|
|
|
|
// PATH: shim_dir : fake_dir : (original minus real-git dir)
|
|
let real_dir = real_git_dir();
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let filtered: Vec<PathBuf> = std::env::split_paths(&original_path)
|
|
.filter(|d| d.canonicalize().ok() != real_dir.canonicalize().ok())
|
|
.collect();
|
|
let path = std::env::join_paths(
|
|
std::iter::once(shim.path())
|
|
.chain(std::iter::once(fake_dir.path()))
|
|
.chain(filtered.iter().map(|d| d.as_path())),
|
|
)
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
let repo = human_repo();
|
|
let remote = tempfile::tempdir().unwrap();
|
|
assert!(hermetic_command("git")
|
|
.args(["init", "-q", "--bare", remote.path().to_str().unwrap()])
|
|
.status()
|
|
.unwrap()
|
|
.success());
|
|
wrapper(
|
|
&path,
|
|
repo.path(),
|
|
&["remote", "add", "origin", remote.path().to_str().unwrap()],
|
|
);
|
|
wrapper(&path, repo.path(), &["config", "alias.pub.command", "push"]);
|
|
|
|
let out = wrapper(&path, repo.path(), &["pub", "origin", "main"]);
|
|
assert!(
|
|
!out.status.success(),
|
|
"wrapper must refuse when probe yields exit-1+empty-stderr; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("alias capability probe failed"),
|
|
"expected ProbeFailure message for exit-1+empty-stderr probe; stderr={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote.path().to_str().unwrap(), "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"remote must be empty after probe-failure refusal; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|
|
|
|
// ── Nested-agent process-level tests ─────────────────────────────────────────
|
|
//
|
|
// These tests use REAL wrapper installations (the buzz-acp multicall binary)
|
|
// with valid parent and child manifests to verify nested-agent execution.
|
|
// They exercise actual `git init`, `git add`, and `git commit` operations and
|
|
// verify that the resulting commit carries the child's author identity.
|
|
//
|
|
// The child install directory uses a **copy** of the buzz-acp binary so its
|
|
// canonical path differs from the parent's, exercising distinct-executable-path
|
|
// nesting. The parent directory uses a symlink as in normal installs.
|
|
|
|
/// Build a nested wrapper install directory.
|
|
///
|
|
/// If `copy_binary` is true, the `git` and `git-sign-nostr` entries are copies
|
|
/// of the buzz-acp binary (distinct canonical executable); if false they are
|
|
/// symlinks. Both layouts carry a valid `.git-identity` manifest.
|
|
/// Returns (TempDir for the install, TempDir for the keyfile, expected email).
|
|
#[cfg(unix)]
|
|
fn nested_shim(copy_binary: bool) -> (tempfile::TempDir, tempfile::TempDir, String) {
|
|
use nostr::ToBech32;
|
|
let keys = nostr::Keys::generate();
|
|
let nsec = keys.secret_key().to_bech32().unwrap();
|
|
let keydir = tempfile::tempdir().unwrap();
|
|
let id = write_agent_key(keydir.path(), &nsec).expect("write keyfile");
|
|
let expected_email = format!("{}@relay.test", id.pubkey_hex);
|
|
|
|
let shim = tempfile::tempdir().unwrap();
|
|
let bin = std::path::Path::new(env!("CARGO_BIN_EXE_buzz-acp"));
|
|
for name in ["git", "git-sign-nostr"] {
|
|
let dest = shim.path().join(name);
|
|
if copy_binary {
|
|
std::fs::copy(bin, &dest).unwrap();
|
|
// Ensure the copy is executable.
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
} else {
|
|
std::os::unix::fs::symlink(bin, &dest).unwrap();
|
|
}
|
|
}
|
|
buzz_git_identity::write_identity_manifest(shim.path(), &manifest_entries(&id)).unwrap();
|
|
(shim, keydir, expected_email)
|
|
}
|
|
|
|
/// Nested-agent: distinct canonical executables, child first on PATH.
|
|
///
|
|
/// Child install dir uses a COPY of buzz-acp (distinct inode/canonical path).
|
|
/// Parent install dir uses a symlink (normal layout).
|
|
/// PATH = child_dir : parent_dir : real_git_dir.
|
|
///
|
|
/// `find_real_git` in the child wrapper: skips child dir (self-skip via
|
|
/// canonicalization), skips parent dir (marker), selects real git. An ordinary
|
|
/// `git commit` completes successfully and the commit carries the CHILD's author
|
|
/// identity, not the parent's. Bounded: the whole sequence must complete within
|
|
/// 30 seconds (no hang).
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn nested_agent_distinct_binaries_child_first_uses_child_identity() {
|
|
let (child_shim, _child_keydir, child_email) = nested_shim(true); // copy = distinct canonical
|
|
let (parent_shim, _parent_keydir, _parent_email) = nested_shim(false); // symlink
|
|
|
|
let real = real_git_dir();
|
|
let path = std::env::join_paths([
|
|
child_shim.path().to_path_buf(),
|
|
parent_shim.path().to_path_buf(),
|
|
real,
|
|
])
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
// Set repo's local identity to an unrelated human so only the wrapper's
|
|
// injection can produce the child agent email. If injection does not happen,
|
|
// %ae/%ce will be "human@example.invalid", not child_email.
|
|
let (_work, repo, _remote) = agent_repo_with_remote("human@example.invalid");
|
|
|
|
let timeout = std::time::Duration::from_secs(30);
|
|
|
|
// status: confirms ordinary git operations complete without hang.
|
|
let status = wrapper_bounded(&path, &repo, &["status"], timeout);
|
|
assert!(
|
|
status.status.success(),
|
|
"git status must succeed in nested context; stderr={}",
|
|
String::from_utf8_lossy(&status.stderr),
|
|
);
|
|
|
|
std::fs::write(repo.join("nested.txt"), b"nested\n").unwrap();
|
|
let add = wrapper_bounded(&path, &repo, &["add", "nested.txt"], timeout);
|
|
assert!(
|
|
add.status.success(),
|
|
"git add must succeed in nested context; stderr={}",
|
|
String::from_utf8_lossy(&add.stderr),
|
|
);
|
|
|
|
let commit = wrapper_bounded(
|
|
&path,
|
|
&repo,
|
|
&["commit", "-m", "nested agent commit"],
|
|
timeout,
|
|
);
|
|
assert!(
|
|
commit.status.success(),
|
|
"git commit must succeed in nested context; stderr={}; stdout={}",
|
|
String::from_utf8_lossy(&commit.stderr),
|
|
String::from_utf8_lossy(&commit.stdout),
|
|
);
|
|
|
|
// Both author and committer emails must be the child's agent email.
|
|
// %ae alone could pass if local repo config happened to set the child email;
|
|
// requiring %ce (set independently by the wrapper) eliminates that.
|
|
let identity = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae%n%ce",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
let identity_str = String::from_utf8_lossy(&identity.stdout);
|
|
let lines: Vec<&str> = identity_str.trim().splitn(2, '\n').collect();
|
|
assert_eq!(lines.len(), 2, "expected two identity lines from git show");
|
|
assert_eq!(
|
|
lines[0].trim(),
|
|
child_email,
|
|
"author email must be child agent email"
|
|
);
|
|
assert_eq!(
|
|
lines[1].trim(),
|
|
child_email,
|
|
"committer email must be child agent email"
|
|
);
|
|
}
|
|
|
|
/// Nested-agent: same canonical executable, child first on PATH.
|
|
///
|
|
/// Both child and parent install dirs use symlinks to the same buzz-acp binary;
|
|
/// their canonical paths are identical. The child's own dir is skipped by the
|
|
/// self-skip (canonicalization) check, which runs before the marker check, so
|
|
/// it skips the parent's dir too. Real git is selected, and the child's identity (injected by
|
|
/// the child wrapper's manifest) is used for the commit.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn nested_agent_same_binary_child_first_uses_child_identity() {
|
|
let (child_shim, _child_keydir, child_email) = nested_shim(false); // symlink
|
|
let (parent_shim, _parent_keydir, _parent_email) = nested_shim(false); // symlink
|
|
|
|
let real = real_git_dir();
|
|
let path = std::env::join_paths([
|
|
child_shim.path().to_path_buf(),
|
|
parent_shim.path().to_path_buf(),
|
|
real,
|
|
])
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
let (_work, repo, _remote) = agent_repo_with_remote("human@example.invalid");
|
|
|
|
let timeout = std::time::Duration::from_secs(30);
|
|
|
|
let status = wrapper_bounded(&path, &repo, &["status"], timeout);
|
|
assert!(
|
|
status.status.success(),
|
|
"git status must succeed (same-binary nested); stderr={}",
|
|
String::from_utf8_lossy(&status.stderr)
|
|
);
|
|
|
|
std::fs::write(repo.join("same.txt"), b"same\n").unwrap();
|
|
let add = wrapper_bounded(&path, &repo, &["add", "same.txt"], timeout);
|
|
assert!(
|
|
add.status.success(),
|
|
"git add must succeed; stderr={}",
|
|
String::from_utf8_lossy(&add.stderr)
|
|
);
|
|
|
|
let commit = wrapper_bounded(
|
|
&path,
|
|
&repo,
|
|
&["commit", "-m", "same-binary nested commit"],
|
|
timeout,
|
|
);
|
|
assert!(
|
|
commit.status.success(),
|
|
"git commit must succeed (same-binary nested); stderr={}",
|
|
String::from_utf8_lossy(&commit.stderr),
|
|
);
|
|
|
|
let identity = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae%n%ce",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
let identity_str = String::from_utf8_lossy(&identity.stdout);
|
|
let lines: Vec<&str> = identity_str.trim().splitn(2, '\n').collect();
|
|
assert_eq!(lines.len(), 2, "expected two identity lines (same-binary)");
|
|
assert_eq!(
|
|
lines[0].trim(),
|
|
child_email,
|
|
"author email must be child agent email (same-binary)"
|
|
);
|
|
assert_eq!(
|
|
lines[1].trim(),
|
|
child_email,
|
|
"committer email must be child agent email (same-binary)"
|
|
);
|
|
}
|
|
|
|
/// Nested-agent: reverse ordering — parent invoked with child dir also on PATH.
|
|
///
|
|
/// PATH = parent_dir : child_dir : real_git_dir. The parent wrapper is the
|
|
/// outermost invocation. `find_real_git` in the parent skips parent dir
|
|
/// (self-skip), skips child dir (marker), selects real git. The child dir must
|
|
/// never be selected as real git in this ordering.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn nested_agent_reverse_ordering_parent_first_never_selects_child_as_real_git() {
|
|
let (child_shim, _child_keydir, child_email) = nested_shim(true); // copy = distinct canonical
|
|
let (parent_shim, _parent_keydir, parent_email) = nested_shim(false); // symlink
|
|
|
|
let real = real_git_dir();
|
|
// Reverse: parent first, child second.
|
|
let path = std::env::join_paths([
|
|
parent_shim.path().to_path_buf(),
|
|
child_shim.path().to_path_buf(),
|
|
real,
|
|
])
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
// Repo uses an unrelated human identity; only the parent wrapper's injection
|
|
// can produce parent_email.
|
|
let (_work, repo, _remote) = agent_repo_with_remote("human@example.invalid");
|
|
|
|
let timeout = std::time::Duration::from_secs(30);
|
|
|
|
let status = wrapper_bounded(&path, &repo, &["status"], timeout);
|
|
assert!(
|
|
status.status.success(),
|
|
"git status must succeed (reverse ordering); stderr={}",
|
|
String::from_utf8_lossy(&status.stderr)
|
|
);
|
|
|
|
std::fs::write(repo.join("rev.txt"), b"reverse\n").unwrap();
|
|
let add = wrapper_bounded(&path, &repo, &["add", "rev.txt"], timeout);
|
|
assert!(
|
|
add.status.success(),
|
|
"git add must succeed (reverse); stderr={}",
|
|
String::from_utf8_lossy(&add.stderr)
|
|
);
|
|
|
|
let commit = wrapper_bounded(
|
|
&path,
|
|
&repo,
|
|
&["commit", "-m", "reverse-order nested commit"],
|
|
timeout,
|
|
);
|
|
assert!(
|
|
commit.status.success(),
|
|
"git commit must succeed (reverse ordering); stderr={}",
|
|
String::from_utf8_lossy(&commit.stderr),
|
|
);
|
|
|
|
// The parent wrapper is first on PATH; commit must carry PARENT identity.
|
|
// Critically: the child dir (which is a Buzz wrapper with its own manifest)
|
|
// must NOT have been selected as real git.
|
|
let identity = hermetic_command("git")
|
|
.args([
|
|
"-C",
|
|
repo.to_str().unwrap(),
|
|
"show",
|
|
"-s",
|
|
"--format=%ae%n%ce",
|
|
"HEAD",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
let identity_str = String::from_utf8_lossy(&identity.stdout);
|
|
let lines: Vec<&str> = identity_str.trim().splitn(2, '\n').collect();
|
|
assert_eq!(lines.len(), 2, "expected two identity lines (reverse)");
|
|
let actual_ae = lines[0].trim();
|
|
let actual_ce = lines[1].trim();
|
|
assert_eq!(
|
|
actual_ae, parent_email,
|
|
"reverse: author must be parent agent email (not child {})",
|
|
child_email
|
|
);
|
|
assert_eq!(
|
|
actual_ce, parent_email,
|
|
"reverse: committer must be parent agent email"
|
|
);
|
|
assert_ne!(
|
|
actual_ae, child_email,
|
|
"reverse-order: child dir must not be selected as real git"
|
|
);
|
|
}
|
|
|
|
// ── Relative-PATH absolutize regression ───────────────────────────────────────
|
|
//
|
|
// When the real-git directory is on PATH as a RELATIVE entry, find_real_git()
|
|
// must still return an absolute path. If it returns a relative path, the
|
|
// capability probe's absolute-path guard fires (ProbeFailure), and all aliases
|
|
// fail closed — including safe aliases like `alias.st=status`.
|
|
//
|
|
// This is the process-level regression for the absolutize fix in find_real_git.
|
|
// See also the unit-level `find_real_git_returns_absolute_path_for_relative_entry`
|
|
// in buzz-git-identity.
|
|
|
|
/// Absolutize regression: safe alias and push-gate refusal both work with a
|
|
/// relative real-git directory on PATH.
|
|
///
|
|
/// PATH = shim_dir (absolute) : `./real-git-dirname` (relative).
|
|
/// The wrapper process runs with cwd = parent of the real-git dir, so the
|
|
/// relative entry resolves to the real git binary. The `-C repo` flag points
|
|
/// git to the test repo regardless of the wrapper's cwd.
|
|
///
|
|
/// Asserts:
|
|
/// (a) `git -c alias.st=status st` exits 0 — the alias probe ran against
|
|
/// real git and resolved the alias (R10-1 would have caused ProbeFailure
|
|
/// here if the absolute guarantee were broken).
|
|
/// (b) `git push` with a human-authored commit produces the author-policy
|
|
/// refusal and `for-each-ref` exits 0 with empty refs.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn wrapper_handles_relative_real_git_entry_via_absolutize() {
|
|
let timeout = std::time::Duration::from_secs(60);
|
|
// The real-git dir becomes a *relative* PATH entry (`./real-git`) that
|
|
// resolves only because the wrapper process runs with cwd = rel_parent.
|
|
let rel_parent = tempfile::tempdir().unwrap();
|
|
let rel_dir_name = "real-git";
|
|
let rel_dir = rel_parent.path().join(rel_dir_name);
|
|
std::fs::create_dir_all(&rel_dir).unwrap();
|
|
std::os::unix::fs::symlink(real_git_dir().join("git"), rel_dir.join("git")).unwrap();
|
|
|
|
let (shim, _abs_path, _agent_email, _keydir) = signed_shim_env();
|
|
let path = std::env::join_paths([shim.path().to_path_buf(), Path::new(".").join(rel_dir_name)])
|
|
.unwrap()
|
|
.into_string()
|
|
.unwrap();
|
|
|
|
// (a) Safe alias: without the absolutize fix the probe fails closed with
|
|
// "alias capability probe failed".
|
|
let repo = human_repo();
|
|
let alias_out = wrapper_bounded(
|
|
&path,
|
|
rel_parent.path(),
|
|
&[
|
|
"-C",
|
|
repo.path().to_str().unwrap(),
|
|
"-c",
|
|
"alias.st=status",
|
|
"st",
|
|
],
|
|
timeout,
|
|
);
|
|
assert!(
|
|
alias_out.status.success(),
|
|
"(a) safe alias must succeed with relative real-git dir; stderr={}",
|
|
String::from_utf8_lossy(&alias_out.stderr),
|
|
);
|
|
|
|
// (b) A human-authored commit, made through real git, pushed through the
|
|
// wrapper to an empty bare origin must hit the author-policy refusal.
|
|
let work = tempfile::tempdir().unwrap();
|
|
let push_repo = work.path().join("repo");
|
|
let remote = work.path().join("remote.git");
|
|
std::fs::create_dir_all(&push_repo).unwrap();
|
|
let g = |cwd: &Path, args: &[&str]| {
|
|
let ok = hermetic_command("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
.status()
|
|
.unwrap()
|
|
.success();
|
|
assert!(ok, "git {args:?} failed");
|
|
};
|
|
g(
|
|
work.path(),
|
|
&["init", "-q", "--bare", remote.to_str().unwrap()],
|
|
);
|
|
g(&push_repo, &["init", "-q", "-b", "main"]);
|
|
g(&push_repo, &["config", "user.name", "Human Dev"]);
|
|
g(
|
|
&push_repo,
|
|
&["config", "user.email", "human@example.invalid"],
|
|
);
|
|
g(&push_repo, &["config", "commit.gpgSign", "false"]);
|
|
std::fs::write(push_repo.join("f.txt"), b"rel\n").unwrap();
|
|
g(&push_repo, &["add", "f.txt"]);
|
|
g(&push_repo, &["commit", "-qm", "human commit"]);
|
|
|
|
let remote_path = remote.to_str().unwrap();
|
|
let push = wrapper_bounded(
|
|
&path,
|
|
rel_parent.path(),
|
|
&[
|
|
"-C",
|
|
push_repo.to_str().unwrap(),
|
|
"push",
|
|
remote_path,
|
|
"main",
|
|
],
|
|
timeout,
|
|
);
|
|
let stderr = String::from_utf8_lossy(&push.stderr);
|
|
assert!(
|
|
!push.status.success(),
|
|
"(b) push must be refused; stderr={stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("not authored by your agent identity"),
|
|
"(b) expected push-gate author refusal; stderr={stderr}",
|
|
);
|
|
let refs = hermetic_command("git")
|
|
.args(["-C", remote_path, "for-each-ref"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
refs.status.success(),
|
|
"(b) for-each-ref must exit 0; status={:?}",
|
|
refs.status
|
|
);
|
|
assert!(
|
|
refs.stdout.is_empty(),
|
|
"(b) remote must be empty after refusal; refs={}",
|
|
String::from_utf8_lossy(&refs.stdout),
|
|
);
|
|
}
|