Files
buzz/crates/buzz-relay/src/api/git/binding.rs
T
cls 9dfa06ffee
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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

129 lines
5.0 KiB
Rust

//! Repo → channel binding resolution, shared by the read gate and push policy.
//!
//! The `buzz-channel` tag on a kind:30617 announcement IS the git ACL: the
//! read gate (SEC-005, `transport::authorize_git_read`) and the push policy
//! endpoint (`policy::hook_callback`) both authorize against membership in
//! the bound channel. Before this module they each parsed the tag with their
//! own code that agreed only by coincidence; the resolver makes the
//! agreement structural.
//!
//! # First-tag, fail-closed semantics
//!
//! Only the *first* `buzz-channel` tag is considered, and it must carry a
//! valid UUID. A malformed first binding resolves to [`RepoBinding::Broken`]
//! even if a later duplicate tag is valid — an ambiguous announcement must
//! fail closed, not silently resolve to whichever duplicate happens to
//! parse. If this ever became "find the first *parseable* tag", an author
//! who can append a second `buzz-channel` tag would pick the channel.
//!
//! # What this deliberately does NOT do
//!
//! No DB access. A well-formed UUID that names a nonexistent or deleted
//! channel still resolves to [`RepoBinding::Bound`]; each gate's own
//! membership lookup then denies (`get_member_role` joins
//! `channels … deleted_at IS NULL`, so a dead channel is indistinguishable
//! from a non-member — the info-leak-safe posture). Likewise each gate keeps
//! its own archived-channel policy: push denies on archived channels, read
//! does not, and this resolver must not unify that asymmetry as a side
//! effect.
use uuid::Uuid;
/// How a kind:30617 announcement binds (or fails to bind) a channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoBinding {
/// No `buzz-channel` tag at all. The announcement author (the only
/// identity that can rebind — 30617 is keyed by `(author, d)`) may be
/// offered remediation; everyone else gets the generic denial.
NotBound,
/// First `buzz-channel` tag carries a valid UUID.
Bound(Uuid),
/// First `buzz-channel` tag exists but its value is not a UUID.
/// Fail closed with the generic denial — never remediation, which
/// would leak that the repo exists.
Broken,
}
/// Resolve the channel binding of a kind:30617 announcement from its tags.
pub fn resolve_repo_binding(event: &nostr::Event) -> RepoBinding {
let Some(first) = event
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))
else {
return RepoBinding::NotBound;
};
match first.as_slice().get(1).map(|v| Uuid::parse_str(v)) {
Some(Ok(id)) => RepoBinding::Bound(id),
_ => RepoBinding::Broken,
}
}
#[cfg(test)]
mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag};
use super::{resolve_repo_binding, RepoBinding};
fn announcement(tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::Custom(30617), "")
.tags(tags)
.sign_with_keys(&Keys::generate())
.expect("sign 30617")
}
#[test]
fn extracts_valid_uuid() {
let ch = uuid::Uuid::new_v4();
let event = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&event), RepoBinding::Bound(ch));
}
#[test]
fn absent_tag_is_not_bound() {
let event = announcement(vec![Tag::parse(["d", "repo"]).unwrap()]);
assert_eq!(resolve_repo_binding(&event), RepoBinding::NotBound);
}
#[test]
fn malformed_and_empty_values_are_broken_not_absent() {
let malformed = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&malformed), RepoBinding::Broken);
let empty = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel"]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&empty), RepoBinding::Broken);
}
#[test]
fn fails_closed_on_ambiguous_duplicate_bindings() {
let ch = uuid::Uuid::new_v4();
let other = uuid::Uuid::new_v4();
// Malformed first + valid second: the ambiguity denies; the valid
// duplicate must NOT win, or the duplicate picks the channel.
let malformed_first = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&malformed_first), RepoBinding::Broken);
// Valid first + different second: first wins deterministically.
let valid_first = announcement(vec![
Tag::parse(["d", "repo"]).unwrap(),
Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
Tag::parse(["buzz-channel", &other.to_string()]).unwrap(),
]);
assert_eq!(resolve_repo_binding(&valid_first), RepoBinding::Bound(ch));
}
}