Files
buzz/crates/buzz-test-client/tests/e2e_git.rs
788b3c002b fix(git): channel binding tooling + author remediation for unbound repos (#3626)
Closes #3527.

Repos announced via vanilla NIP-34 (kind:30617 without a `buzz-channel`
tag) 404 forever: the SEC-005 read gate requires a channel-membership
ACL, and nothing tells the author why or how to fix it. Per the ruling
in the originating thread, this ships **bind/rebind tooling plus a
narrow author-only remediation carve-out** — the shelved owner-circle
approach is intentionally absent.

## Relay
- **`api/git/binding.rs` (new):** shared tri-state binding resolver —
`Bound(uuid)` / `NotBound` / `Broken`. First-tag, fail-closed: a
malformed `buzz-channel` tag is `Broken`, never conflated with "no tag".
Both gates use it.
- **Read gate (`transport.rs`):** a **never-bound** repo read by **its
own announcement author** still returns 404 (status byte-identical to
the generic denial) but the body carries remediation: `run: buzz repos
bind --id <repo> --channel <channel-uuid> — …`. This leaks nothing — the
author announced the repo, and only the author can rebind (30617 is
keyed by `(author, d)`). `Broken` bindings stay generic-denial for
everyone, including the author (revocation shape).
Bound-to-nonexistent-channel stays generic (phase 1; ingest validation
is phase 2).
- **Push gate (`policy.rs`):** unbound denial now returns
`GIT_NO_CHANNEL_BINDING_BODY`. A deploy-skew test pins that the body
carries both the new token (`no_channel_binding`) and the legacy phrase
(`"no channel binding"`) so already-shipped desktops keep matching.
**(Review r1, blocker 2)** `Broken` no longer collapses into "unbound":
it denies 403 `invalid channel binding` for *everyone — including the
announcement owner —* **before** the owner short-circuit, matching the
read gate's fail-closed posture. The remediation token stays
NotBound-only.
- **`ingest.rs`:** side-effect failure `warn!` → `error!` — prod runs
`RUST_LOG=error`, so these failures were invisible during triage.

## Contract
- **`buzz-core/git_perms.rs`:** `GIT_NO_CHANNEL_BINDING_TOKEN` /
`GIT_NO_CHANNEL_BINDING_BODY` consts as the declared cross-component
contract; relay tests and desktop matcher both build on them.

## CLI
- **`buzz repos bind --id <repo> --channel <uuid>`** — rebinds an
existing announcement, preserving other tags.
- **(Review r1, blocker 1)** **`--channel` on `buzz repos create`** —
optional; injects exactly one shape-validated `buzz-channel` tag at
creation via a pure `build_create_announcement` builder, so the primary
create command stops producing repos the relay 404s. UUID
existence/membership stays the relay's authority at git-access time
(same TOCTOU posture as `repos bind`). Overlaps with #3594 (open, head
6bbe38459) — happy to reconcile whichever lands first; this branch also
carries the bind path and tag preservation.

## Desktop
- **Rust:** new `commands/project_git_merge_error.rs` (extracted from
`project_git_workflow.rs` to respect the 1000-line ratchet); maps the
token to a structured `no_channel_binding` error carrying the bind
command.
- **TS:** new `features/projects/lib/projectBranchErrors.ts` + tests —
dual matcher (new token AND legacy spaced phrase);
`ProjectBranchDialogs.tsx` uses it.

## Tests / verification (at head f914c7066, base 581baa625)
- Workspace `cargo test` green; `clippy -D warnings` clean; desktop Rust
1859 pass; TS 3780 pass; tsc/biome/file-size checks pass. Pre-push hooks
re-ran all suites at the pushed head.
- Postgres-gated `sec005_read_gate_tests`: all 6 pass, including
`read_gate_gives_author_of_unbound_repo_remediation_body` — asserts 404
status, `text/plain` content-type, and exact body bytes, distinguishing
remediation from generic denial (a blind `is_err()` can't).
- **New (review r1):** `buzz-cli` emitted-event tests —
`create_with_channel_emits_exactly_one_binding_tag`,
`create_without_channel_emits_no_binding_tag`,
`create_rejects_malformed_channel_uuid` (266/266 pass). Postgres-gated
`push_gate_denies_owner_through_broken_binding` — owner +
malformed-first/valid-second binding → 403 generic body without the
remediation token; never-bound control stays 200, pinning the denial to
`Broken` specifically.
- e2e git tests now bind announcements to a real channel via a
`create_test_channel` helper.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-29 20:07:55 -04:00

556 lines
19 KiB
Rust

//! End-to-end git-over-object-storage tests.
//!
//! Drives the real `git` binary (clone / push / fetch / force-push / tag,
//! plus a best-effort concurrent push race) against a running relay backed by
//! S3/MinIO, exercising the full manifest-pointer CAS commit path described in
//! `docs/git-on-object-storage.md`.
//!
//! Requires: relay at localhost:3000 with git + S3/MinIO configured, `git` on
//! PATH, and the `git-credential-nostr` helper built. All tests are `#[ignore]`
//! so they don't run in CI by default.
//!
//! # Running
//!
//! ```text
//! cargo build --release -p git-credential-nostr
//! GIT_CREDENTIAL_NOSTR_BIN=$PWD/target/release/git-credential-nostr \
//! cargo test -p buzz-test-client --test e2e_git -- --ignored --nocapture
//! ```
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use buzz_media::S3AddressingStyle;
use nostr::{EventBuilder, Keys, Kind, Tag};
use s3::creds::Credentials;
use s3::{Bucket, Region};
fn relay_http_url() -> String {
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
}
/// Path to the compiled credential helper. Defaults to the workspace release
/// build; override with `GIT_CREDENTIAL_NOSTR_BIN`.
fn credential_helper() -> PathBuf {
if let Ok(p) = std::env::var("GIT_CREDENTIAL_NOSTR_BIN") {
return PathBuf::from(p);
}
// tests run from the crate dir; the workspace target is two levels up.
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.pop();
p.pop();
p.push("target/release/git-credential-nostr");
p
}
/// Submit a signed event to the relay's HTTP bridge (`POST /events`).
async fn post_event(event: &nostr::Event) {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", event.pubkey.to_hex())
.header("Content-Type", "application/json")
.body(serde_json::to_string(event).unwrap())
.send()
.await
.expect("post event");
assert!(
resp.status().is_success(),
"event rejected: {}",
resp.text().await.unwrap_or_default()
);
}
/// Create a channel (kind:9007) owned by `keys` and return its UUID.
///
/// The git read gate (SEC-005) authorizes against membership in the channel
/// named by the announcement's `buzz-channel` tag, so every repo these tests
/// announce must be bound to a channel its owner belongs to — creating the
/// channel makes the creator its owner-member.
async fn create_test_channel(keys: &Keys) -> String {
let channel_uuid = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid]).unwrap(),
Tag::parse(["name", &format!("git-e2e-{channel_uuid}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
post_event(&event).await;
channel_uuid
}
/// Run `git` with the Buzz credential helper and isolated config.
fn git_status(args: &[&str], cwd: &Path, owner_nsec: &str) -> std::process::Output {
let helper = credential_helper();
Command::new("git")
.args([
"-c",
"credential.useHttpPath=true",
"-c",
&format!("credential.helper={}", helper.display()),
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgsign=false",
"-c",
"user.name=E2E",
"-c",
"user.email=e2e@example.com",
])
.args(args)
.current_dir(cwd)
// Isolate from any machine/agent git config (signing, etc.).
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_CONFIG_COUNT")
.env("NOSTR_PRIVATE_KEY", owner_nsec)
.output()
.expect("spawn git")
}
/// Run `git` with the Buzz credential helper and isolated config. Asserts the
/// command succeeds; returns stdout.
fn git(args: &[&str], cwd: &Path, owner_nsec: &str) -> String {
let out = git_status(args, cwd, owner_nsec);
assert!(
out.status.success(),
"git {:?} failed:\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
struct GitS3Probe {
bucket: Box<Bucket>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PointerSnapshot {
etag: String,
digest: String,
}
impl GitS3Probe {
fn bucket(
endpoint: String,
access_key: &str,
secret_key: &str,
bucket_name: &str,
region_name: String,
addressing_style: S3AddressingStyle,
) -> Box<Bucket> {
let region = Region::Custom {
region: region_name,
endpoint,
};
let creds = Credentials::new(Some(access_key), Some(secret_key), None, None, None)
.expect("S3 credentials");
let bucket = Bucket::new(bucket_name, region, creds).expect("S3 bucket");
match addressing_style {
S3AddressingStyle::Path => bucket.with_path_style(),
S3AddressingStyle::Virtual => bucket,
}
}
fn from_env() -> Self {
// These E2E assertions inspect the relay's backing bucket directly, so
// they must receive the same provider connection and URL style as the
// relay. Unit/live MinIO probes in buzz-relay keep explicit local
// fixtures and do not need provider overrides.
let endpoint = std::env::var("BUZZ_S3_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string());
let access_key =
std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_string());
let secret_key =
std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_string());
let bucket_name =
std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string());
let region_name =
std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE")
.unwrap_or_else(|_| "path".to_string())
.parse::<S3AddressingStyle>()
.expect("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'");
let bucket = Self::bucket(
endpoint,
&access_key,
&secret_key,
&bucket_name,
region_name,
addressing_style,
);
Self { bucket }
}
fn pointer_key(owner: &str, repo: &str) -> String {
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if let Ok(community) = std::env::var("BUZZ_E2E_GIT_COMMUNITY_ID") {
return format!("repos/{community}/{owner}/{repo}/pointer");
}
format!("repos/{owner}/{repo}/pointer")
}
async fn pointer(&self, owner: &str, repo: &str) -> Option<PointerSnapshot> {
let key = Self::pointer_key(owner, repo);
match self.bucket.get_object(&key).await {
Ok(resp) => {
let etag = resp
.headers()
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("etag"))
.map(|(_, v)| v.to_string())
.expect("pointer GET must include ETag");
let digest = String::from_utf8(resp.to_vec()).expect("pointer body utf-8");
assert_eq!(digest.len(), 64, "pointer body is manifest digest");
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
Some(PointerSnapshot { etag, digest })
}
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => None,
Err(e) => panic!("GET S3 pointer {key} failed: {e}"),
}
}
async fn require_pointer(&self, owner: &str, repo: &str) -> PointerSnapshot {
for _ in 0..40 {
if let Some(p) = self.pointer(owner, repo).await {
self.assert_manifest_exists(&p.digest).await;
return p;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
panic!(
"S3 manifest pointer {} never appeared; git may have fallen back to disk",
Self::pointer_key(owner, repo)
);
}
async fn assert_manifest_exists(&self, digest: &str) {
let key = format!("manifests/{digest}");
match self.bucket.get_object(&key).await {
Ok(_) => {}
Err(e) => panic!("pointer named manifest {key}, but GET failed: {e}"),
}
}
}
#[test]
fn git_s3_probe_builds_both_addressing_styles() {
let path = GitS3Probe::bucket(
"https://storage.example".to_string(),
"access",
"secret",
"buzz-media",
"us-east-1".to_string(),
S3AddressingStyle::Path,
);
assert!(path.is_path_style());
assert_eq!(path.url(), "https://storage.example/buzz-media");
let virtual_hosted = GitS3Probe::bucket(
"https://storage.example".to_string(),
"access",
"secret",
"buzz-media",
"auto".to_string(),
S3AddressingStyle::Virtual,
);
assert!(virtual_hosted.is_subdomain_style());
assert_eq!(virtual_hosted.url(), "https://buzz-media.storage.example");
}
#[tokio::test]
#[ignore = "requires live relay + MinIO + git"]
async fn git_clone_push_fetch_force_roundtrip() {
use nostr::ToBech32;
let owner = Keys::generate();
let owner_hex = owner.public_key().to_hex();
let owner_nsec = owner.secret_key().to_bech32().unwrap();
let repo = format!("e2e-git-{}", std::process::id());
let s3 = GitS3Probe::from_env();
// Announce the repo (kind:30617) so the relay creates the bare repo + hook.
// The `buzz-channel` binding is the repo's ACL: without it the read gate
// 404s even for the owner (issue #3527), so bind to a channel the owner
// just created (and therefore belongs to).
let channel = create_test_channel(&owner).await;
let announce = EventBuilder::new(Kind::from(30617), "")
.tags(vec![
Tag::parse(["d", &repo]).unwrap(),
Tag::parse(["name", "e2e git repo"]).unwrap(),
Tag::parse(["buzz-channel", &channel]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
post_event(&announce).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let tmp = tempdir();
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
// 1. Clone the empty repo.
git(
&["clone", "--quiet", &url, "clone1"],
tmp.path(),
&owner_nsec,
);
let clone1 = tmp.path().join("clone1");
assert!(clone1.exists(), "clone1 created");
let empty_pointer = s3.require_pointer(&owner_hex, &repo).await;
// 2. Push an initial commit.
std::fs::write(clone1.join("README.md"), "hello\n").unwrap();
git(&["add", "."], &clone1, &owner_nsec);
git(
&["commit", "--quiet", "-m", "initial"],
&clone1,
&owner_nsec,
);
git(&["branch", "-M", "main"], &clone1, &owner_nsec);
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
let p1 = s3.require_pointer(&owner_hex, &repo).await;
assert_ne!(
p1, empty_pointer,
"initial push must advance S3 manifest pointer"
);
let sha1 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
.trim()
.to_string();
// 3. A fresh clone observes the pushed content and exact SHA.
git(
&["clone", "--quiet", &url, "clone2"],
tmp.path(),
&owner_nsec,
);
let clone2 = tmp.path().join("clone2");
assert_eq!(
std::fs::read_to_string(clone2.join("README.md")).unwrap(),
"hello\n",
"fresh clone sees pushed content"
);
assert_eq!(
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
sha1,
"fresh clone main == pushed SHA"
);
// 4. Second commit, push, pull into the other clone.
std::fs::write(clone1.join("README.md"), "hello\nmore\n").unwrap();
git(
&["commit", "--quiet", "-am", "second"],
&clone1,
&owner_nsec,
);
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
let p2 = s3.require_pointer(&owner_hex, &repo).await;
assert_ne!(p2, p1, "second push must advance S3 manifest pointer");
let sha2 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
.trim()
.to_string();
git(&["pull", "--quiet", "origin", "main"], &clone2, &owner_nsec);
assert_eq!(
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
sha2,
"clone2 fetched second commit"
);
// 5. Force-push a rewritten history.
git(&["reset", "--quiet", "--hard", &sha1], &clone1, &owner_nsec);
std::fs::write(clone1.join("README.md"), "forced\n").unwrap();
git(
&["commit", "--quiet", "-am", "forced"],
&clone1,
&owner_nsec,
);
let sha_f = git(&["rev-parse", "main"], &clone1, &owner_nsec)
.trim()
.to_string();
git(
&["push", "--quiet", "--force", "origin", "main"],
&clone1,
&owner_nsec,
);
let p3 = s3.require_pointer(&owner_hex, &repo).await;
assert_ne!(p3, p2, "force push must advance S3 manifest pointer");
assert_ne!(sha_f, sha2);
// 6. A new clone after the force-push gets the rewritten history.
git(
&["clone", "--quiet", &url, "clone3"],
tmp.path(),
&owner_nsec,
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("clone3/README.md")).unwrap(),
"forced\n",
"clone3 has force-pushed content"
);
// 7. Tag push survives the round-trip.
git(&["tag", "v1.0"], &clone1, &owner_nsec);
git(&["push", "--quiet", "origin", "v1.0"], &clone1, &owner_nsec);
let p4 = s3.require_pointer(&owner_hex, &repo).await;
assert_ne!(p4, p3, "tag push must advance S3 manifest pointer");
git(
&["clone", "--quiet", &url, "clone4"],
tmp.path(),
&owner_nsec,
);
let tags = git(&["tag"], &tmp.path().join("clone4"), &owner_nsec);
assert!(tags.contains("v1.0"), "tag v1.0 cloned back: {tags}");
}
#[tokio::test]
#[ignore = "requires live relay + MinIO + git"]
async fn git_concurrent_push_one_wins_and_repo_recovers() {
use nostr::ToBech32;
let owner = Keys::generate();
let owner_hex = owner.public_key().to_hex();
let owner_nsec = owner.secret_key().to_bech32().unwrap();
let repo = format!("e2e-git-concurrent-{}", std::process::id());
let s3 = GitS3Probe::from_env();
let channel = create_test_channel(&owner).await;
let announce = EventBuilder::new(Kind::from(30617), "")
.tags(vec![
Tag::parse(["d", &repo]).unwrap(),
Tag::parse(["name", "e2e concurrent git repo"]).unwrap(),
Tag::parse(["buzz-channel", &channel]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
post_event(&announce).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let tmp = tempdir_named("buzz-e2e-git-concurrent");
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
git(&["clone", "--quiet", &url, "seed"], tmp.path(), &owner_nsec);
let seed = tmp.path().join("seed");
std::fs::write(seed.join("README.md"), "base\n").unwrap();
git(&["add", "."], &seed, &owner_nsec);
git(&["commit", "--quiet", "-m", "base"], &seed, &owner_nsec);
git(&["branch", "-M", "main"], &seed, &owner_nsec);
git(&["push", "--quiet", "origin", "main"], &seed, &owner_nsec);
let base_pointer = s3.require_pointer(&owner_hex, &repo).await;
let base_sha = git(&["rev-parse", "main"], &seed, &owner_nsec)
.trim()
.to_string();
let contenders = 8usize;
let mut contenders_info = Vec::new();
for i in 0..contenders {
let dir = format!("c{i}");
git(&["clone", "--quiet", &url, &dir], tmp.path(), &owner_nsec);
let worktree = tmp.path().join(&dir);
std::fs::write(
worktree.join(format!("file-{i}.txt")),
format!("winner? {i}\n"),
)
.unwrap();
git(&["add", "."], &worktree, &owner_nsec);
git(
&["commit", "--quiet", "-m", &format!("contender {i}")],
&worktree,
&owner_nsec,
);
let sha = git(&["rev-parse", "main"], &worktree, &owner_nsec)
.trim()
.to_string();
contenders_info.push((i, sha));
}
let mut children = Vec::new();
for i in 0..contenders {
let worktree = tmp.path().join(format!("c{i}"));
let owner_nsec = owner_nsec.clone();
children.push(std::thread::spawn(move || {
git_status(
&["push", "--quiet", "origin", "main"],
&worktree,
&owner_nsec,
)
}));
}
let mut successes = Vec::new();
let mut failures = 0usize;
for (i, child) in children.into_iter().enumerate() {
let out = child.join().expect("push thread panicked");
if out.status.success() {
successes.push(i);
} else {
failures += 1;
}
}
assert_eq!(successes.len(), 1, "exactly one concurrent push should win");
assert_eq!(failures, contenders - 1, "the rest should lose cleanly");
let winner_index = successes[0];
let winner_sha = contenders_info
.iter()
.find_map(|(i, sha)| (*i == winner_index).then_some(sha.clone()))
.expect("winner sha recorded");
let after_pointer = s3.require_pointer(&owner_hex, &repo).await;
assert_ne!(
after_pointer, base_pointer,
"winning push must advance S3 manifest pointer"
);
git(
&["clone", "--quiet", &url, "after"],
tmp.path(),
&owner_nsec,
);
let after = tmp.path().join("after");
let after_sha = git(&["rev-parse", "main"], &after, &owner_nsec)
.trim()
.to_string();
assert_ne!(after_sha, base_sha, "winner advanced main");
assert_eq!(
after_sha, winner_sha,
"published head must equal the successful contender's tip"
);
let log = git(
&["log", "--oneline", "--decorate", "-1"],
&after,
&owner_nsec,
);
assert!(
log.contains("contender"),
"published head is one contender: {log}"
);
}
struct TempDir(PathBuf);
impl TempDir {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tempdir() -> TempDir {
tempdir_named("buzz-e2e-git")
}
fn tempdir_named(prefix: &str) -> TempDir {
let mut p = std::env::temp_dir();
p.push(format!("{prefix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}