feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "git-credential-nostr"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Git credential helper that produces NIP-98 auth headers for Buzz's git server"
[lib]
name = "git_credential_nostr"
path = "src/lib.rs"
[[bin]]
name = "git-credential-nostr"
path = "src/main.rs"
[dependencies]
nostr = { workspace = true }
serde_json = { workspace = true }
zeroize = { workspace = true }
base64 = "0.22"
+69
View File
@@ -0,0 +1,69 @@
# git-credential-nostr
NIP-98 credential helper for git — signs HTTP auth events with your Nostr key so git can push/pull from Buzz's git server without passwords.
## Requirements
- **git 2.46+** (requires `authtype` capability in the credential protocol)
- **Rust toolchain** (for building from source)
## Installation
```bash
cargo install --path crates/git-credential-nostr
```
## Setup
```bash
# 1. Register the helper and enable per-path credentials.
git config --global credential.helper nostr
git config --global credential.useHttpPath true
# 2. Store your nsec in a key file (must be 0600).
mkdir -p ~/.nostr
echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key
git config --global nostr.keyfile ~/.nostr/key
```
That's it. Use git normally — `git clone`, `git push`, `git fetch`.
## CI / CD
Set `$NOSTR_PRIVATE_KEY` instead of a key file. The env var takes precedence
over `nostr.keyfile` and avoids touching the filesystem:
```bash
export NOSTR_PRIVATE_KEY=nsec1...
git clone https://relay.example.com/git/owner/repo.git
```
## How It Works
When a Buzz git server returns `HTTP 401` with a
`WWW-Authenticate: Nostr realm="...", method="GET"` header, git calls this
helper with the request details on stdin. The helper loads your Nostr private
key, builds a [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md)
kind-27235 event signed over the request URL and method, base64-encodes it, and
writes it back to stdout. Git then retries the request with
`Authorization: Nostr <token>`, which the server verifies by checking the event
signature.
```
git ──stdin──▶ git-credential-nostr ──stdout──▶ git
sign kind:27235 event
(NIP-98 HTTP Auth)
```
## Troubleshooting
| Error | Cause | Fix |
|-------|-------|-----|
| `no nostr key configured` | Neither `$NOSTR_PRIVATE_KEY` nor `nostr.keyfile` is set | Follow the Setup steps above |
| `insecure permissions` | Key file is readable by group/others | `chmod 600 ~/.nostr/key` |
| `method hint` | Server's `WWW-Authenticate` header is missing `method="..."` | Upgrade the Buzz server |
| `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` |
| Empty output / no auth | git version is older than 2.46 | Upgrade git |
| `clock skew` / auth rejected | System clock is off by more than 60 s | Sync your system clock (`ntpdate`, `timedatectl`) |
+266
View File
@@ -0,0 +1,266 @@
//! git-credential-nostr — NIP-98 git credential helper for Buzz.
//!
//! Git calls this via the credential helper protocol (stdin/stdout).
//! We read the request, sign a kind:27235 event, and return the base64-encoded
//! event as the credential value. Git then sends:
//! Authorization: Nostr <credential>
use std::io::{self, BufRead, Write};
use base64::Engine as _;
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr::types::Url;
use nostr::{EventBuilder, Keys, Tag};
use zeroize::Zeroize;
fn git_config(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
#[cfg(unix)]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o177 != 0 {
return Err(format!(
"keyfile {path} has insecure permissions (expected 0600)"
));
}
Ok(())
}
#[cfg(not(unix))]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
eprintln!("warning: cannot check keyfile permissions on this platform ({path})");
Ok(())
}
/// Max keyfile size — nsec1 is 63 bytes; hex keys are 64 bytes. 256 is generous.
const MAX_KEYFILE_BYTES: u64 = 256;
fn load_key() -> Result<String, String> {
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
if !val.is_empty() {
return Ok(val);
}
}
let path = git_config("nostr.keyfile").ok_or_else(|| {
"no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string()
})?;
check_keyfile_permissions(&path)?;
let meta = std::fs::metadata(&path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
if !meta.is_file() {
return Err(format!("keyfile {path} is not a regular file"));
}
if meta.len() > MAX_KEYFILE_BYTES {
return Err(format!(
"keyfile {path} exceeds {MAX_KEYFILE_BYTES}-byte size limit"
));
}
let raw =
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
Ok(raw.trim().to_string())
}
/// Load the NIP-OA owner attestation injected by Buzz Desktop/ACP.
///
/// The tag must be part of the signed NIP-98 event: Git's credential protocol
/// can return an Authorization value, but it cannot add a separate HTTP header.
fn load_auth_tag() -> Result<Option<Tag>, String> {
let raw = std::env::var("BUZZ_AUTH_TAG")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| git_config("nostr.authtag"));
raw.map(|value| {
let parts: Vec<String> =
serde_json::from_str(&value).map_err(|e| format!("invalid NIP-OA auth tag: {e}"))?;
if parts.len() != 4 || parts.first().map(String::as_str) != Some("auth") {
return Err(
"invalid NIP-OA auth tag: expected [auth, owner, conditions, signature]"
.to_string(),
);
}
Tag::parse(parts).map_err(|e| format!("invalid NIP-OA auth tag: {e}"))
})
.transpose()
}
#[derive(Default)]
struct CredRequest {
has_authtype_capability: bool,
protocol: Option<String>,
host: Option<String>,
path: Option<String>,
wwwauth: Option<String>,
}
fn parse_stdin() -> CredRequest {
let stdin = io::stdin();
let mut req = CredRequest::default();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
break;
}
if line == "capability[]=authtype" {
req.has_authtype_capability = true;
} else if let Some(v) = line.strip_prefix("protocol=") {
req.protocol = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("host=") {
req.host = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("path=") {
req.path = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("wwwauth[]=") {
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
req.wwwauth = Some(v.to_string());
}
}
}
req
}
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
// Strip the scheme prefix ("Nostr ") if present, then split on commas.
// Handles variations: `Nostr method="GET", realm="buzz"` and
// `Nostr method="GET",realm="buzz"` (with or without space after comma).
let params = wwwauth.strip_prefix("Nostr ").unwrap_or(wwwauth);
for param in params.split(',') {
let param = param.trim();
if let Some(rest) = param.strip_prefix("method=\"") {
let end = rest.find('"')?;
return rest[..end].parse().ok();
}
}
None
}
/// Run the credential helper. Returns exit code.
/// Reads from stdin, writes to stdout. Errors go to stderr only.
pub fn run() -> i32 {
match std::env::args().nth(1).as_deref() {
Some("get") | None => {}
Some(_) => return 0, // store, erase, or unknown → silent exit 0
}
let req = parse_stdin();
if !req.has_authtype_capability {
println!();
let _ = io::stdout().flush();
return 0;
}
macro_rules! require {
($opt:expr, $msg:expr) => {
match $opt {
Some(v) => v,
None => {
eprintln!("error: {}", $msg);
return 1;
}
}
};
}
// No Nostr challenge from the server — this isn't a Buzz remote.
// Exit silently so git falls through to the next credential helper.
// This check comes FIRST so non-Buzz remotes never hit validation errors.
let wwwauth = match req.wwwauth.as_deref() {
Some(v) => v,
None => return 0,
};
let method = match parse_method(wwwauth) {
Some(m) => m,
None => return 0,
};
let protocol = require!(
req.protocol.as_deref(),
"missing protocol in credential request"
);
let host = require!(req.host.as_deref(), "missing host in credential request");
let path = require!(
req.path.as_deref(),
"credential.useHttpPath must be true for NIP-98 auth"
);
let repo_path = path
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path.strip_suffix("/git-upload-pack"))
.or_else(|| path.strip_suffix("/git-receive-pack"))
.unwrap_or(path);
let url = format!("{protocol}://{host}/{repo_path}");
let mut raw_key = match load_key() {
Ok(k) => k,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
let keys = match Keys::parse(&raw_key) {
Ok(k) => k,
Err(e) => {
raw_key.zeroize();
eprintln!("error: invalid nostr private key: {e}");
return 1;
}
};
raw_key.zeroize();
let parsed_url = Url::parse(&url).unwrap_or_else(|e| panic!("invalid URL {url:?}: {e}"));
let http_data = HttpData::new(parsed_url, method);
let auth_tag = match load_auth_tag() {
Ok(tag) => tag,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
let builder = EventBuilder::http_auth(http_data);
let builder = match auth_tag {
Some(tag) => builder.tag(tag),
None => builder,
};
let event = match builder.sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => {
eprintln!("error: failed to sign NIP-98 event: {e}");
return 1;
}
};
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(e) => {
eprintln!("error: failed to serialize event: {e}");
return 1;
}
};
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
println!("capability[]=authtype");
println!("authtype=Nostr");
println!("credential={credential}");
println!("ephemeral=true");
println!("quit=true");
println!();
let _ = io::stdout().flush();
0
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
std::process::exit(git_credential_nostr::run());
}
@@ -0,0 +1,356 @@
//! Integration tests for git-credential-nostr.
//!
//! Each test spawns the compiled binary as a subprocess, feeds it the
//! credential-helper protocol on stdin, and asserts on stdout/stderr/exit-code.
use std::io::Write;
use std::process::{Command, Stdio};
use base64::Engine as _;
use nostr::{Keys, ToBech32};
/// Spawn the binary, write `input` to stdin, collect output.
/// `env_vars` are added on top of the inherited environment.
/// `NOSTR_PRIVATE_KEY` is always cleared first to prevent test pollution.
fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output {
let bin = env!("CARGO_BIN_EXE_git-credential-nostr");
let mut cmd = Command::new(bin);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(std::env::temp_dir())
.env_remove("NOSTR_PRIVATE_KEY")
.env_remove("BUZZ_AUTH_TAG")
.env_remove("GIT_CONFIG_COUNT")
// Prevent git config on the test machine from supplying credentials.
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("HOME", std::env::temp_dir());
for (k, v) in env_vars {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("failed to spawn git-credential-nostr");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().expect("failed to wait on child")
}
/// Generate a fresh nsec string for use in tests.
fn fresh_nsec() -> String {
let keys = Keys::generate();
keys.secret_key().to_bech32().unwrap()
}
/// Standard valid credential-helper input (includes authtype capability).
fn valid_input() -> String {
"capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
wwwauth[]=Nostr realm=\"buzz\", method=\"GET\"\n\
\n"
.to_string()
}
/// Happy path: valid key + valid input → well-formed credential response with
/// a base64-encoded kind:27235 JSON event.
#[test]
fn happy_path() {
let nsec = fresh_nsec();
let out = run_helper(&valid_input(), &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0, got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines.contains(&"capability[]=authtype"),
"missing capability[]=authtype in:\n{stdout}"
);
assert!(
lines.contains(&"authtype=Nostr"),
"missing authtype=Nostr in:\n{stdout}"
);
assert!(
lines.contains(&"ephemeral=true"),
"missing ephemeral=true in:\n{stdout}"
);
assert!(
lines.contains(&"quit=true"),
"missing quit=true in:\n{stdout}"
);
// Extract and validate the credential value.
let cred_line = lines
.iter()
.find(|l| l.starts_with("credential="))
.expect("no credential= line in output");
let b64 = cred_line.strip_prefix("credential=").unwrap();
let json_bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("credential is not valid base64");
let json_str = String::from_utf8(json_bytes).expect("credential is not valid UTF-8");
let event: serde_json::Value =
serde_json::from_str(&json_str).expect("credential does not decode to JSON");
assert_eq!(
event["kind"],
serde_json::json!(27235),
"expected kind 27235, got {}",
event["kind"]
);
// Sanity-check a few more fields the NIP-98 event must have.
assert!(event["id"].is_string(), "event missing 'id'");
assert!(event["pubkey"].is_string(), "event missing 'pubkey'");
assert!(event["sig"].is_string(), "event missing 'sig'");
assert!(event["tags"].is_array(), "event missing 'tags'");
}
/// A Buzz-managed agent must carry its NIP-OA owner attestation inside the
/// signed NIP-98 event so the relay can admit it through the owner's membership.
#[test]
fn includes_nip_oa_auth_tag_in_signed_event() {
let agent_keys = Keys::generate();
let owner_keys = Keys::generate();
let nsec = agent_keys.secret_key().to_bech32().unwrap();
let auth_tag = serde_json::to_string(&[
"auth",
owner_keys.public_key().to_hex().as_str(),
"",
&"00".repeat(64),
])
.expect("serialize auth tag");
let out = run_helper(
&valid_input(),
&[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", &auth_tag)],
);
assert!(
out.status.success(),
"helper failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let credential = stdout
.lines()
.find_map(|line| line.strip_prefix("credential="))
.expect("credential output");
let event_json = base64::engine::general_purpose::STANDARD
.decode(credential)
.expect("base64 credential");
let event: nostr::Event = serde_json::from_slice(&event_json).expect("NIP-98 event");
assert!(
event.verify().is_ok(),
"auth tag must be covered by the event signature"
);
assert!(event.tags.iter().any(|tag| tag.as_slice()
== [
"auth",
owner_keys.public_key().to_hex().as_str(),
"",
serde_json::from_str::<Vec<String>>(&auth_tag).unwrap()[3].as_str(),
]));
}
/// A configured but malformed owner attestation must fail closed rather than
/// silently authenticating the agent without delegation.
#[test]
fn malformed_nip_oa_auth_tag_fails_closed() {
let nsec = fresh_nsec();
let out = run_helper(
&valid_input(),
&[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", "not-json")],
);
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid NIP-OA auth tag"));
assert!(!String::from_utf8_lossy(&out.stdout).contains("credential="));
}
/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0.
#[test]
fn old_git_no_authtype_capability() {
let input = "protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0 for old-git path, got {:?}",
out.status.code()
);
let stdout = String::from_utf8_lossy(&out.stdout);
// Output should be just a blank line — no credential data.
assert_eq!(
stdout.trim(),
"",
"expected empty output for old-git path, got:\n{stdout}"
);
assert!(
!stdout.contains("credential="),
"should not emit credential= for old git"
);
}
/// No key configured at all → exit 1, stderr mentions "no nostr key configured".
#[test]
fn missing_key() {
// run_helper already clears NOSTR_PRIVATE_KEY and points HOME at a temp dir
// that has no git config, so no keyfile will be found.
let out = run_helper(&valid_input(), &[]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing key"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no nostr key configured"),
"expected 'no nostr key configured' in stderr, got:\n{stderr}"
);
}
/// `wwwauth[]` present but missing `method="..."` → exit 0, no credential emitted.
/// The helper gracefully declines rather than erroring, so git can fall through
/// to the next credential helper (safe for global credential.helper config).
#[test]
fn missing_method_hint() {
let input = "capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
path=git/owner/repo.git/info/refs\n\
wwwauth[]=Nostr realm=\"buzz\"\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert!(
out.status.success(),
"expected exit 0 for missing method hint (graceful decline), got {:?}",
out.status.code()
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("credential="),
"should not emit credential= when method hint is missing"
);
}
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
/// The relay requires the full repo-root URL for NIP-98 verification, so the
/// credential helper cannot function without the path component.
#[test]
fn missing_path() {
let input = "capability[]=authtype\n\
capability[]=state\n\
protocol=https\n\
host=relay.example.com\n\
wwwauth[]=Nostr realm=\"buzz\", method=\"GET\"\n\
\n";
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing path"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("useHttpPath"),
"expected 'useHttpPath' in stderr, got:\n{stderr}"
);
}
/// Keyfile with 0644 permissions → exit 1, stderr mentions "insecure permissions".
#[cfg(unix)]
#[test]
fn bad_keyfile_permissions() {
use std::os::unix::fs::PermissionsExt;
let nsec = fresh_nsec();
// Write keyfile to a temp path.
let tmp_dir = std::env::temp_dir();
let keyfile = tmp_dir.join(format!(
"nostr-test-key-{}.nsec",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::write(&keyfile, &nsec).expect("failed to write temp keyfile");
// Set insecure permissions (0644).
std::fs::set_permissions(&keyfile, std::fs::Permissions::from_mode(0o644))
.expect("failed to set permissions");
// Point a scratch git config at the keyfile.
let git_config_dir = tmp_dir.join(format!(
"nostr-test-gitconfig-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::create_dir_all(&git_config_dir).unwrap();
let git_config_file = git_config_dir.join(".gitconfig");
std::fs::write(
&git_config_file,
format!("[nostr]\n\tkeyfile = {}\n", keyfile.display()),
)
.expect("failed to write git config");
let out = run_helper(
&valid_input(),
&[
("HOME", git_config_dir.to_str().unwrap()),
("GIT_CONFIG_GLOBAL", git_config_file.to_str().unwrap()),
],
);
// Clean up regardless of outcome.
let _ = std::fs::remove_file(&keyfile);
let _ = std::fs::remove_file(&git_config_file);
let _ = std::fs::remove_dir(&git_config_dir);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for insecure keyfile permissions"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("insecure permissions"),
"expected 'insecure permissions' in stderr, got:\n{stderr}"
);
}