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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
129 lines
5.0 KiB
Rust
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));
|
|
}
|
|
}
|