Files
buzz/crates/buzz-acp/src/git.rs
Logan Johnson ea1e97e65f feat(acp): run one prepared task from a file or stdin (#7851)
🤖
## Summary
Scripts, scheduled jobs, and other automation need to give an agent one
prepared task and wait for a result without starting a long-running chat
listener. This adds `buzz-acp run`: it reads a task from a local file or
stdin, runs one fresh agent session with the configured tools and
instructions, cleans up, and exits with a machine-readable status.

```sh
buzz-acp run --task ./task.json
cat task.json | buzz-acp run --task -
```

- The versioned JSON task specifies a task ID, intended agent public
key, prepared prompt, and execution time limit. Invalid input or an
identity mismatch is rejected before agent launch. Credentials,
executable, tools, and permissions remain controlled by trusted launch
settings, not the task document.
- Each invocation uses the normal agent equipment: base/persona/team
instructions, enabled memory, tools, model and effort settings,
permissions, and Git identity, credentials, and signing. Both task and
conversational modes obtain these capabilities from one shared runtime
owner; new shared setup belongs there rather than in separate entry
points.
- Launch configuration reads have a separate ten-second bound and can be
cancelled even when a prompt file blocks. A deadline covers adapter
startup through the task turn. Interrupts and termination signals
trigger bounded cleanup. Stdout contains one terminal JSON record;
diagnostics stay on stderr. A normal turn ending is not proof that the
task achieved its goal.
- Normal invocation remains the conversational service. Task mode does
not start chat subscriptions, presence, heartbeat turns, or a persistent
agent pool. Tasks are trusted executable instructions, not sandboxed
input. Reusing a task ID does not prevent duplicate execution, and
uncertain outcomes must not be blindly retried.

See [the task contract](crates/buzz-acp/TASKS.md) for the document
format, exit codes, limits, and examples. URL retrieval, a task server,
and a persistent execution ledger are outside this change.

### Related issue
None found in the existing issue and PR search.

### Testing
- At `4e86bbc97`: 964 unit tests and 22 integration tests passed, with
three existing ignored tests. The package suite ran serially with
inherited harness/Git options cleared. A final test-only shutdown-result
assertion was then checked with the focused memory test and all-target
Clippy.
- All eight real-binary task tests independently passed on the exact
candidate, including blocked configuration reads, file/stdin validation,
session equipment, deadlines, cancellation, and cleanup. The memory test
passed 20 repeated runs. Removing the oversized-input guard fails the
new error assertion; restoring it passes.
- Package all-target Clippy, formatting, file-size checks, and pre-push
Rust/Tauri checks passed. All executed hosted checks passed on the
updated head (54 successful, 28 skipped). The separate Codex review
comment still requests authorization for this exact range.
- Earlier revisions passed the release build and a real Goose 1.51.0
tool-use smoke test. No new release artifact or installed Janet runtime
is claimed for this feedback revision.
- Repository-wide `just ci` was previously attempted; workspace Clippy
passed, then the desktop dependency check stopped on a local pnpm fetch
failure. Hosted checks cover the applicable PR lanes.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-24 10:58:15 -04:00

623 lines
22 KiB
Rust

//! Harness-owned, ephemeral Git identity, credentials and signing for every runtime.
use nostr::ToBech32;
use std::path::Path;
use tempfile::TempDir;
use zeroize::Zeroizing;
/// Keep this guard alive until the adapter pool has shut down.
pub(crate) struct GitEnvironment {
_dir: TempDir,
pub(crate) env: Vec<(String, String)>,
}
impl GitEnvironment {
pub(crate) fn for_config(config: &mut crate::config::Config) -> anyhow::Result<Self> {
let environment =
Self::install(&config.keys, &config.relay_url, &std::env::current_exe()?)?;
config
.persona_env_vars
.retain(|(name, _)| !is_managed_env(name));
config
.persona_env_vars
.extend(environment.env.iter().cloned());
Ok(environment)
}
pub(crate) fn install(
keys: &nostr::Keys,
relay_url: &str,
executable: &Path,
) -> anyhow::Result<Self> {
let dir = tempfile::Builder::new().prefix("buzz-acp-git-").tempdir()?;
set_owner_only(dir.path())?;
for name in ["git-credential-nostr", "git-sign-nostr"] {
symlink(executable, &dir.path().join(name))?;
}
let keyfile = dir.path().join(".nostr-key");
let secret = Zeroizing::new(keys.secret_key().to_secret_hex());
write_keyfile_atomic(&keyfile, secret.as_bytes())?;
let info = KeyInfo {
keyfile_path: keyfile
.to_str()
.ok_or_else(|| anyhow::anyhow!("Git keyfile path is not UTF-8"))?
.to_owned(),
pubkey_hex: keys.public_key().to_hex(),
npub: keys.public_key().to_bech32()?,
};
let mut relay = url::Url::parse(relay_url)?;
let scheme = match relay.scheme() {
"wss" | "https" => "https",
"ws" | "http" => "http",
_ => anyhow::bail!("unsupported Git relay URL scheme"),
};
relay
.set_scheme(scheme)
.map_err(|_| anyhow::anyhow!("invalid Git relay URL"))?;
relay.set_query(None);
relay.set_fragment(None);
let inherited = inherited_config()?;
let display_name = std::env::var("BUZZ_ACP_DISPLAY_NAME").ok();
let mut env = build_git_env(
&info,
relay.as_str().trim_end_matches('/'),
display_name.as_deref(),
inherited,
);
let mut paths = vec![dir.path().to_path_buf()];
paths.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
env.push((
"PATH".into(),
std::env::join_paths(paths)?
.into_string()
.map_err(|_| anyhow::anyhow!("Git PATH is not UTF-8"))?,
));
env.push(("GIT_TERMINAL_PROMPT".into(), "0".into()));
// CLI flags must work even when the caller did not export these variables.
env.push(("BUZZ_PRIVATE_KEY".into(), secret.to_string()));
env.push(("BUZZ_RELAY_URL".into(), relay_url.to_owned()));
Ok(Self { _dir: dir, env })
}
}
struct KeyInfo {
keyfile_path: String,
pubkey_hex: String,
npub: String,
}
fn inherited_config() -> anyhow::Result<Vec<(String, String)>> {
let count = match std::env::var("GIT_CONFIG_COUNT") {
Ok(value) => value.parse::<usize>()?,
Err(std::env::VarError::NotPresent) => 0,
Err(error) => return Err(error.into()),
};
anyhow::ensure!(count <= 1024, "too many inherited Git config entries");
(0..count)
.map(|i| {
Ok((
std::env::var(format!("GIT_CONFIG_KEY_{i}"))?,
std::env::var(format!("GIT_CONFIG_VALUE_{i}"))?,
))
})
.collect()
}
/// Write `data` to `path` with 0600 permissions set at creation time via
/// `OpenOptions::mode()` (no window where the file is world-readable).
/// Non-Unix: plain write — acceptable inside our 0700 tempdir.
#[cfg(unix)]
fn write_keyfile_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
f.write_all(data)
}
#[cfg(not(unix))]
fn write_keyfile_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
std::fs::write(path, data)
}
/// Stable identity contract for git attribution: the bare agent display name,
/// never channel-qualified, safe to embed in commit history.
///
/// Deliberately distinct from `BUZZ_ACP_SESSION_TITLE`, which is per-session UI
/// chrome and may be composed (`Agent · #channel`) by consumers. Commits
/// outlive sessions, so git attribution must not follow a mutable title.
///
/// Max characters in a git author name. Nostr display names are unbounded.
const MAX_GIT_USER_NAME_CHARS: usize = 80;
/// Characters git's `ident.c` treats as "crud": stripped from both ends of a
/// name, and — when a name is *nothing but* these — rejected outright with
/// `fatal: name consists only of disallowed characters`.
///
/// Verified empirically against git 2.54.0 by committing with each ASCII byte
/// 32..=126 as the entire `user.name`: exactly space, `"`, `'`, `,`, `:`, `;`,
/// `<`, `>`, and `\` abort. Control characters abort too (the predicate is
/// `c <= 32`). Note `.` is *not* crud in this version despite older lore.
fn is_git_crud(c: char) -> bool {
c <= ' ' || matches!(c, '"' | '\'' | ',' | ':' | ';' | '<' | '>' | '\\')
}
/// Characters in Unicode general category `Cf` (format): zero-width space and
/// joiners, bidi embedding/override marks, invisible math operators, interlinear
/// annotations, and tag characters.
///
/// `char::is_control` covers only `Cc`, so every one of these survives it — and
/// none is whitespace or [`is_git_crud`]. A display name of nothing but U+200B
/// ZERO WIDTH SPACE would therefore satisfy the "at least one non-crud
/// character" gate and hand git a visually blank author instead of falling back
/// to the npub. An embedded U+202E RIGHT-TO-LEFT OVERRIDE is worse: it makes a
/// commit's persisted author line render as something other than what it says,
/// the same confusion the angle-bracket filter exists to prevent.
///
/// The whole category is rejected rather than the two known-bad marks, because
/// the boundary that matters is "invisible or reorders text", not "the codepoint
/// someone thought of". Ranges transcribed from the UCD's
/// `DerivedGeneralCategory.txt` (17.0.0) and independently cross-checked against
/// Python's `unicodedata` (16.0.0); both yield exactly these 21 ranges. Inlined
/// rather than taking a Unicode-tables dependency for one predicate.
fn is_unicode_format(c: char) -> bool {
matches!(c,
'\u{00AD}'
| '\u{0600}'..='\u{0605}'
| '\u{061C}'
| '\u{06DD}'
| '\u{070F}'
| '\u{0890}'..='\u{0891}'
| '\u{08E2}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{110BD}'
| '\u{110CD}'
| '\u{13430}'..='\u{1343F}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0001}'
| '\u{E0020}'..='\u{E007F}'
)
}
/// Normalize a Buzz display name into a git author name, or `None` to fall
/// back to the npub.
///
/// Strips control and Unicode format characters plus angle brackets, collapses
/// whitespace runs, trims, and caps at [`MAX_GIT_USER_NAME_CHARS`] by `chars()`
/// so a multi-byte name cannot be split mid-UTF-8. Angle brackets go because git
/// silently drops them rather than erroring — `Duncan <evil@x.com>` would
/// render as `Duncan evil@x.com <hex@relay>`, which forges nothing but reads as
/// though it might.
///
/// Returns `None` unless at least one non-crud character survives. A bare
/// emptiness check is not sufficient: git rejects a name built only of crud,
/// so a display name of `;;` or `""` would abort **every commit** the agent
/// makes. Falling back to the npub keeps the agent able to commit.
fn sanitize_git_user_name(raw: &str) -> Option<String> {
let collapsed = raw
.split_whitespace()
.map(|word| {
word.chars()
.filter(|c| !c.is_control() && !is_unicode_format(*c) && *c != '<' && *c != '>')
.collect::<String>()
})
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ");
let name: String = collapsed
.chars()
.take(MAX_GIT_USER_NAME_CHARS)
.collect::<String>()
.trim_end()
.to_string();
name.chars().any(|c| !is_git_crud(c)).then_some(name)
}
/// Compose a complete config block, including the caller's entries, for env-cleared MCP children.
fn build_git_env(
info: &KeyInfo,
relay: &str,
display_name: Option<&str>,
mut entries: Vec<(String, String)>,
) -> Vec<(String, String)> {
let host = url::Url::parse(relay)
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
.filter(|host| !host.starts_with("localhost") && !host.starts_with("127."))
.unwrap_or_else(|| "buzz".into());
let scope = format!("credential.{relay}/git");
entries.extend([
(
"user.name".into(),
display_name
.and_then(sanitize_git_user_name)
.unwrap_or_else(|| info.npub.clone()),
),
("user.email".into(), format!("{}@{host}", info.pubkey_hex)),
// Reset helpers only inside this relay's Git URL namespace. Unrelated
// remotes retain their own helper chain and never receive this key.
(format!("{scope}.helper"), String::new()),
(format!("{scope}.helper"), "nostr".into()),
(format!("{scope}.useHttpPath"), "true".into()),
("nostr.keyfile".into(), info.keyfile_path.clone()),
("gpg.format".into(), "x509".into()),
("gpg.x509.program".into(), "git-sign-nostr".into()),
("commit.gpgSign".into(), "true".into()),
("tag.gpgSign".into(), "true".into()),
("user.signingkey".into(), info.pubkey_hex.clone()),
]);
let mut env = vec![("GIT_CONFIG_COUNT".into(), entries.len().to_string())];
for (i, (key, value)) in entries.into_iter().enumerate() {
env.push((format!("GIT_CONFIG_KEY_{i}"), key));
env.push((format!("GIT_CONFIG_VALUE_{i}"), value));
}
env
}
/// Variables owned by the harness and forwarded across the MCP env-clear boundary.
pub(crate) fn is_managed_env(name: &str) -> bool {
name.starts_with("GIT_CONFIG_")
|| matches!(
name,
"PATH" | "GIT_TERMINAL_PROMPT" | "BUZZ_PRIVATE_KEY" | "BUZZ_RELAY_URL"
)
}
#[cfg(unix)]
fn set_owner_only(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o700);
std::fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn set_owner_only(_: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}
#[cfg(not(unix))]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
// No symlinks without elevation on Windows; copy instead. The target needs
// a .exe extension or PATH lookup (via PATHEXT) won't treat it as runnable.
let dst = dst.with_extension("exe");
std::fs::copy(src, dst).map(|_| ())
}
#[cfg(test)]
mod git_user_name_tests {
use super::{
build_git_env, is_git_crud, is_unicode_format, sanitize_git_user_name, KeyInfo,
MAX_GIT_USER_NAME_CHARS,
};
const PUBKEY_HEX: &str = "dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95";
const NPUB: &str = "npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7";
fn key_info() -> KeyInfo {
KeyInfo {
keyfile_path: "/tmp/.nostr-key".into(),
pubkey_hex: PUBKEY_HEX.into(),
npub: NPUB.into(),
}
}
/// Read a git config value back out of the flat GIT_CONFIG_KEY_n/VALUE_n pairs.
fn git_config(env: &[(String, String)], key: &str) -> Option<String> {
let idx = env
.iter()
.find(|(k, v)| k.starts_with("GIT_CONFIG_KEY_") && v == key)?
.0
.strip_prefix("GIT_CONFIG_KEY_")?
.to_owned();
env.iter()
.find(|(k, _)| *k == format!("GIT_CONFIG_VALUE_{idx}"))
.map(|(_, v)| v.clone())
}
#[test]
fn test_ordinary_name_passes_through_unchanged() {
assert_eq!(sanitize_git_user_name("Duncan"), Some("Duncan".into()));
}
#[test]
fn test_angle_brackets_are_stripped_so_no_second_email_is_rendered() {
// git drops the brackets itself and renders `Duncan evil@x.com
// <hex@relay>` — no forgery, but a confusing author line.
assert_eq!(
sanitize_git_user_name("Duncan <evil@x.com>"),
Some("Duncan evil@x.com".into())
);
}
#[test]
fn test_whitespace_control_characters_become_a_single_separator() {
// Newline, tab and carriage return are whitespace: they collapse to one
// space like any other run, so a multi-line name stays readable.
assert_eq!(
sanitize_git_user_name("Dun\ncan\tThe\r\nIdaho"),
Some("Dun can The Idaho".into())
);
}
#[test]
fn test_non_whitespace_control_characters_are_dropped_outright() {
// NUL is the important one: an interior NUL makes `Command::env` fail
// the entire spawn upstream, so it must never survive to git config.
let got = sanitize_git_user_name("Idaho\0Blade\u{7}").expect("non-empty");
assert_eq!(got, "IdahoBlade");
assert!(!got.chars().any(char::is_control));
}
#[test]
fn test_internal_whitespace_runs_collapse_to_one_space() {
assert_eq!(
sanitize_git_user_name(" Duncan Idaho "),
Some("Duncan Idaho".into())
);
}
#[test]
fn test_whitespace_only_name_falls_back_to_npub() {
assert_eq!(sanitize_git_user_name(" \t\n "), None);
}
#[test]
fn test_empty_name_falls_back_to_npub() {
assert_eq!(sanitize_git_user_name(""), None);
}
#[test]
fn test_crud_only_name_falls_back_rather_than_aborting_every_commit() {
// git rejects a name built only of crud with `fatal: name consists
// only of disallowed characters`, which would break EVERY commit the
// agent makes. Verified against git 2.54.0.
for raw in ["<>", ";;", "\"\"", "''", ",", ":", "\\", ",;:"] {
assert_eq!(
sanitize_git_user_name(raw),
None,
"crud-only name {raw:?} must fall back to the npub"
);
}
}
#[test]
fn test_crud_mixed_with_real_characters_is_kept() {
// Legitimate names contain crud; only an all-crud result is fatal.
assert_eq!(sanitize_git_user_name("O'Brien"), Some("O'Brien".into()));
assert_eq!(
sanitize_git_user_name("Smith, Jr."),
Some("Smith, Jr.".into())
);
}
#[test]
fn test_over_length_name_is_truncated_to_the_cap() {
let long = "a".repeat(200);
let got = sanitize_git_user_name(&long).expect("non-empty");
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
}
#[test]
fn test_truncation_never_splits_a_multibyte_character() {
let long = "🐝".repeat(200);
let got = sanitize_git_user_name(&long).expect("non-empty");
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
assert!(got.chars().all(|c| c == '🐝'), "no replacement chars");
}
#[test]
fn test_truncation_does_not_leave_a_trailing_space() {
// Cutting mid-word would otherwise strand the separator at the end.
let raw = format!("{} tail", "a".repeat(MAX_GIT_USER_NAME_CHARS - 1));
let got = sanitize_git_user_name(&raw).expect("non-empty");
assert!(!got.ends_with(' '), "got {got:?}");
}
#[test]
fn test_non_ascii_names_survive() {
assert_eq!(
sanitize_git_user_name("Élodie 🐝"),
Some("Élodie 🐝".into())
);
}
#[test]
fn test_format_only_name_falls_back_to_npub() {
// U+200B is neither control, nor whitespace, nor crud, so before Cf
// filtering this passed the non-crud gate and handed git a visually
// blank author instead of falling back.
assert_eq!(sanitize_git_user_name("\u{200B}\u{200B}"), None);
// Same class, different marks: joiner, word joiner, BOM, bidi override.
for raw in ["\u{200D}", "\u{2060}", "\u{FEFF}", "\u{202E}", "\u{00AD}"] {
assert_eq!(
sanitize_git_user_name(raw),
None,
"format-only name {raw:?} must fall back to the npub"
);
}
}
#[test]
fn test_bidi_override_is_stripped_and_the_name_is_kept() {
// A trailing RLO would reorder everything after it in `git log`, so the
// mark goes and the readable name stays.
assert_eq!(
sanitize_git_user_name("Duncan\u{202E}"),
Some("Duncan".into())
);
assert_eq!(
sanitize_git_user_name("Dun\u{202E}can Idaho"),
Some("Duncan Idaho".into())
);
}
#[test]
fn test_zero_width_space_inside_a_word_is_removed_without_splitting_it() {
// U+200B is not whitespace, so it must not become a separator: the word
// rejoins rather than turning into "Dun can".
assert_eq!(
sanitize_git_user_name("Dun\u{200B}can"),
Some("Duncan".into())
);
}
#[test]
fn test_format_characters_do_not_consume_the_length_budget() {
// Filtering happens before truncation, so invisible padding cannot
// shorten the visible name.
let raw = format!("{}{}", "\u{200B}".repeat(200), "a".repeat(90));
let got = sanitize_git_user_name(&raw).expect("non-empty");
assert_eq!(got.chars().count(), MAX_GIT_USER_NAME_CHARS);
assert!(got.chars().all(|c| c == 'a'), "got {got:?}");
}
#[test]
fn test_unicode_format_covers_every_cf_range_and_nothing_adjacent() {
// Both endpoints of each of the 21 `Cf` ranges in UCD 17.0.0. Endpoints
// are what a transcription error moves, so they are what gets asserted.
for c in [
'\u{00AD}',
'\u{0600}',
'\u{0605}',
'\u{061C}',
'\u{06DD}',
'\u{070F}',
'\u{0890}',
'\u{0891}',
'\u{08E2}',
'\u{180E}',
'\u{200B}',
'\u{200F}',
'\u{202A}',
'\u{202E}',
'\u{2060}',
'\u{2064}',
'\u{2066}',
'\u{206F}',
'\u{FEFF}',
'\u{FFF9}',
'\u{FFFB}',
'\u{110BD}',
'\u{110CD}',
'\u{13430}',
'\u{1343F}',
'\u{1BCA0}',
'\u{1BCA3}',
'\u{1D173}',
'\u{1D17A}',
'\u{E0001}',
'\u{E0020}',
'\u{E007F}',
] {
assert!(is_unicode_format(c), "U+{:04X} is Cf", c as u32);
}
// Codepoints immediately outside those ranges, plus ordinary characters.
// U+2065 is the notable one: it sits *inside* the 2060..206F block but
// is unassigned, not `Cf`.
for c in [
'\u{00AC}',
'\u{00AE}',
'\u{05FF}',
'\u{0606}',
'\u{061B}',
'\u{061D}',
'\u{200A}',
'\u{2010}',
'\u{2029}',
'\u{202F}',
'\u{2065}',
'\u{205F}',
'\u{2070}',
'\u{FEFE}',
'\u{FFF8}',
'\u{FFFC}',
'\u{110BC}',
'\u{1342F}',
'\u{E0000}',
'\u{E0080}',
'a',
' ',
'🐝',
'É',
] {
assert!(!is_unicode_format(c), "U+{:04X} is not Cf", c as u32);
}
}
#[test]
fn test_build_git_env_uses_display_name_and_leaves_email_on_the_pubkey() {
let env = build_git_env(
&key_info(),
"https://localhost:3000",
Some("Duncan"),
vec![],
);
assert_eq!(git_config(&env, "user.name").as_deref(), Some("Duncan"));
// The pubkey — the thing NIP-98 auth, NIP-GS signing, and contributor
// matching key on — must stay in the email untouched.
assert_eq!(
git_config(&env, "user.email").as_deref(),
Some(format!("{PUBKEY_HEX}@buzz").as_str())
);
assert_eq!(
git_config(&env, "user.signingkey").as_deref(),
Some(PUBKEY_HEX)
);
}
#[test]
fn test_build_git_env_falls_back_to_npub_when_display_name_unset() {
let env = build_git_env(&key_info(), "https://localhost:3000", None, vec![]);
// Without a display name, attribution falls back to the npub.
assert_eq!(git_config(&env, "user.name").as_deref(), Some(NPUB));
assert_eq!(
git_config(&env, "user.email").as_deref(),
Some(format!("{PUBKEY_HEX}@buzz").as_str())
);
}
#[test]
fn test_build_git_env_falls_back_to_npub_when_display_name_is_unusable() {
// Crud-only and format-only names both reach git as the npub — one
// would abort every commit, the other would render as blank.
for raw in ["<>", "\u{200B}"] {
let env = build_git_env(&key_info(), "https://localhost:3000", Some(raw), vec![]);
assert_eq!(
git_config(&env, "user.name").as_deref(),
Some(NPUB),
"unusable display name {raw:?} must reach git as the npub"
);
}
}
#[test]
fn test_git_crud_set_matches_observed_git_behavior() {
// Empirically derived from git 2.54.0: these bytes, alone, abort a commit.
for c in [' ', '"', '\'', ',', ':', ';', '<', '>', '\\', '\t', '\n'] {
assert!(is_git_crud(c), "{c:?} should be crud");
}
for c in ['.', '-', '_', '@', '(', 'a', '🐝'] {
assert!(!is_git_crud(c), "{c:?} should not be crud");
}
}
}