mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
## Summary - inject bounded project-home identity and repository context into managed agent sessions - add project-aware CLI flows for creating projects, repositories, issues, and related channels - match project homes through the existing relay query surface, then filter channel metadata client-side This is Part 1 of the channel-first Projects stack. Part 2 contains project creation and model foundations. ## Testing - `cargo fmt --all -- --check` - `cargo clippy -p buzz-cli -p buzz-acp --all-targets -- -D warnings` - `cargo test -p buzz-cli -p buzz-acp` — 1,184 tests passed, 1 doc test ignored - full pre-push gate passed ## Post-Deploy Monitoring & Validation - validate project-home agent context and project-aware CLI commands against a staging relay - healthy signals: project context matches the active channel, explicit repo coordinates remain stable, and normal channels receive no project block - failure signals: cross-channel project context, duplicate project creation, or commands targeting an unrelated repository; mitigate by reverting this PR --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
856 lines
29 KiB
Rust
856 lines
29 KiB
Rust
use buzz_core::{
|
|
git_perms::{parse_protection_tag, parse_protection_tags, RefPattern},
|
|
kind::KIND_GIT_REPO_ANNOUNCEMENT,
|
|
};
|
|
use nostr::{Event, EventBuilder, Tag, Timestamp};
|
|
|
|
use crate::client::BuzzClient;
|
|
use crate::commands::parse_write_response;
|
|
use crate::error::CliError;
|
|
use crate::validate::validate_repo_id;
|
|
|
|
fn parse_events(json: &str) -> Result<Vec<Event>, CliError> {
|
|
serde_json::from_str(json)
|
|
.map_err(|error| CliError::Other(format!("failed to parse relay response: {error}")))
|
|
}
|
|
|
|
pub(crate) async fn fetch_own_repo_announcement(
|
|
client: &BuzzClient,
|
|
repo_id: &str,
|
|
) -> Result<Option<Event>, CliError> {
|
|
let filter = serde_json::json!({
|
|
"kinds": [KIND_GIT_REPO_ANNOUNCEMENT],
|
|
"authors": [client.keys().public_key().to_hex()],
|
|
"#d": [repo_id],
|
|
"limit": 1,
|
|
});
|
|
let raw = client.query(&filter).await?;
|
|
let mut events = parse_events(&raw)?;
|
|
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
|
|
Ok(events.into_iter().next())
|
|
}
|
|
|
|
fn repo_id_from_event(event: &Event) -> Result<&str, CliError> {
|
|
event
|
|
.tags
|
|
.iter()
|
|
.find_map(|tag| {
|
|
let values = tag.as_slice();
|
|
(values.first().map(String::as_str) == Some("d"))
|
|
.then(|| values.get(1).map(String::as_str))
|
|
.flatten()
|
|
})
|
|
.ok_or_else(|| CliError::Other("repository announcement is missing its d tag".into()))
|
|
}
|
|
|
|
fn tag_error(error: impl std::fmt::Display) -> CliError {
|
|
CliError::Other(format!("failed to build protection tag: {error}"))
|
|
}
|
|
|
|
fn protection_pattern(tag: &Tag) -> Option<&str> {
|
|
let values = tag.as_slice();
|
|
(values.first().map(String::as_str) == Some("buzz-protect"))
|
|
.then(|| values.get(1).map(String::as_str))
|
|
.flatten()
|
|
}
|
|
|
|
fn has_tag_name(tag: &Tag, name: &str) -> bool {
|
|
tag.as_slice().first().map(String::as_str) == Some(name)
|
|
}
|
|
|
|
fn build_protection_tag(
|
|
ref_pattern: &str,
|
|
push_role: Option<&str>,
|
|
no_force_push: bool,
|
|
no_delete: bool,
|
|
require_patch: bool,
|
|
) -> Result<Tag, CliError> {
|
|
let mut values = vec!["buzz-protect".to_string(), ref_pattern.to_string()];
|
|
if let Some(role) = push_role {
|
|
values.push(format!("push:{role}"));
|
|
}
|
|
if no_force_push {
|
|
values.push("no-force-push".into());
|
|
}
|
|
if no_delete {
|
|
values.push("no-delete".into());
|
|
}
|
|
if require_patch {
|
|
values.push("require-patch".into());
|
|
}
|
|
let rule_values: Vec<&str> = values[1..].iter().map(String::as_str).collect();
|
|
parse_protection_tag(&rule_values)
|
|
.map_err(|error| CliError::Usage(format!("invalid protection rule: {error}")))?;
|
|
Tag::parse(values).map_err(tag_error)
|
|
}
|
|
|
|
enum RepoChange {
|
|
SetProtection(Box<Tag>),
|
|
RemoveProtection(String),
|
|
/// Bind (or rebind) the repo to a channel: replaces every existing
|
|
/// `buzz-channel` tag with exactly one carrying the validated UUID.
|
|
BindChannel(String),
|
|
}
|
|
|
|
fn build_updated_repo_announcement(
|
|
existing: &Event,
|
|
change: RepoChange,
|
|
) -> Result<EventBuilder, CliError> {
|
|
let repo_id = repo_id_from_event(existing)?;
|
|
// What to strip beyond `auth` (always stripped), and what to append.
|
|
let (removed_pattern, removed_channel, replacement) = match change {
|
|
RepoChange::SetProtection(tag) => {
|
|
let pattern = protection_pattern(&tag)
|
|
.ok_or_else(|| CliError::Other("replacement is not a protection tag".into()))?
|
|
.to_string();
|
|
(Some(pattern), false, Some(*tag))
|
|
}
|
|
RepoChange::RemoveProtection(pattern) => {
|
|
RefPattern::parse(&pattern)
|
|
.map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?;
|
|
(Some(pattern), false, None)
|
|
}
|
|
RepoChange::BindChannel(channel) => {
|
|
crate::validate::validate_uuid(&channel)?;
|
|
let tag = Tag::parse(["buzz-channel", channel.as_str()]).map_err(tag_error)?;
|
|
(None, true, Some(tag))
|
|
}
|
|
};
|
|
|
|
let mut tags: Vec<Tag> = existing
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| {
|
|
if has_tag_name(tag, "auth") {
|
|
return false;
|
|
}
|
|
if removed_channel && has_tag_name(tag, "buzz-channel") {
|
|
return false;
|
|
}
|
|
removed_pattern.is_none() || protection_pattern(tag) != removed_pattern.as_deref()
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
if let Some(tag) = replacement {
|
|
tags.push(tag);
|
|
}
|
|
|
|
let raw_tags: Vec<Vec<String>> = tags.iter().map(|tag| tag.as_slice().to_vec()).collect();
|
|
parse_protection_tags(&raw_tags).map_err(|error| {
|
|
CliError::Other(format!(
|
|
"repository contains invalid protection rules; refusing update: {error}"
|
|
))
|
|
})?;
|
|
|
|
// Advance only the observed head. Using wall-clock time here would let a
|
|
// delayed writer leapfrog an intervening update and silently erase metadata.
|
|
let next_created_at = existing
|
|
.created_at
|
|
.as_secs()
|
|
.checked_add(1)
|
|
.ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?;
|
|
buzz_sdk::build_repo_announcement_with_tags(repo_id, &existing.content, tags)
|
|
.map_err(|error| CliError::Other(format!("failed to build repository update: {error}")))
|
|
.map(|builder| builder.custom_created_at(Timestamp::from(next_created_at)))
|
|
}
|
|
|
|
fn protection_rules_json(event: &Event) -> Result<serde_json::Value, CliError> {
|
|
let raw_tags: Vec<Vec<String>> = event
|
|
.tags
|
|
.iter()
|
|
.map(|tag| tag.as_slice().to_vec())
|
|
.collect();
|
|
let (unknown_rules, validation_error) = match parse_protection_tags(&raw_tags) {
|
|
Ok(parsed) => (parsed.unknown_rules, None),
|
|
Err(error) => (Vec::new(), Some(error.to_string())),
|
|
};
|
|
let protections: Vec<serde_json::Value> = event
|
|
.tags
|
|
.iter()
|
|
.filter_map(|tag| {
|
|
let values = tag.as_slice();
|
|
(values.first().map(String::as_str) == Some("buzz-protect")).then(|| {
|
|
serde_json::json!({
|
|
"ref": values.get(1).map(String::as_str).unwrap_or(""),
|
|
"rules": values.get(2..).unwrap_or_default(),
|
|
})
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::json!({
|
|
"repo_id": repo_id_from_event(event)?,
|
|
"protections": protections,
|
|
"unknown_rules": unknown_rules,
|
|
"validation_error": validation_error,
|
|
}))
|
|
}
|
|
|
|
fn validate_write_response(raw: &str) -> Result<String, CliError> {
|
|
parse_write_response(
|
|
raw,
|
|
"repository changed concurrently; fetch the latest rules and retry",
|
|
)
|
|
}
|
|
|
|
async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> {
|
|
let event = client.sign_event(builder)?;
|
|
let raw = client.submit_event(event).await?;
|
|
println!("{}", validate_write_response(&raw)?);
|
|
Ok(())
|
|
}
|
|
|
|
/// Build the kind:30617 announcement for `repos create`, including the
|
|
/// `buzz-channel` binding when requested.
|
|
///
|
|
/// Pure (no I/O) so the emitted tags are unit-testable. Exactly one
|
|
/// validated `buzz-channel` tag is appended — the tag is the git ACL
|
|
/// (issue #3527: without it the relay 404s every clone/fetch/push), so the
|
|
/// UUID is shape-validated here and its existence/membership is the relay's
|
|
/// authority at git-access time, same posture as `repos bind`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn build_create_announcement(
|
|
repo_id: &str,
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
clone_urls: &[String],
|
|
web_url: Option<&str>,
|
|
relays: &[String],
|
|
channel: Option<&str>,
|
|
) -> Result<EventBuilder, CliError> {
|
|
validate_repo_id(repo_id)?;
|
|
|
|
let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect();
|
|
let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
|
|
|
|
let mut builder = buzz_sdk::build_repo_announcement(
|
|
repo_id,
|
|
name,
|
|
description,
|
|
&clone_refs,
|
|
web_url,
|
|
&relay_refs,
|
|
)
|
|
.map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?;
|
|
|
|
if let Some(channel) = channel {
|
|
crate::validate::validate_uuid(channel)?;
|
|
builder = builder.tag(Tag::parse(["buzz-channel", channel]).map_err(tag_error)?);
|
|
}
|
|
Ok(builder)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn cmd_create_repo(
|
|
client: &BuzzClient,
|
|
repo_id: &str,
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
clone_urls: &[String],
|
|
web_url: Option<&str>,
|
|
relays: &[String],
|
|
channel: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
let builder = build_create_announcement(
|
|
repo_id,
|
|
name,
|
|
description,
|
|
clone_urls,
|
|
web_url,
|
|
relays,
|
|
channel,
|
|
)?;
|
|
let event = client.sign_event(builder)?;
|
|
let owner = event.pubkey.to_hex();
|
|
let resp = client.submit_event(event).await?;
|
|
// `link` renders as a rich preview card in Buzz Desktop when included in
|
|
// a chat message — agents announce repos with it (see base_prompt.md).
|
|
let link = crate::links::repo_link(&owner, repo_id);
|
|
crate::client::print_create_response(&resp, "link", &link);
|
|
if let Some(channel) = channel {
|
|
// Best-effort: a repo announced into a project home channel should
|
|
// join that project instead of rendering as a second project card.
|
|
let _ = crate::commands::projects::try_add_own_repo_to_channel_project(
|
|
client, channel, repo_id,
|
|
)
|
|
.await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_get_repo(
|
|
client: &BuzzClient,
|
|
repo_id: &str,
|
|
owner: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
validate_repo_id(repo_id)?;
|
|
|
|
let mut filter = serde_json::json!({
|
|
"kinds": [30617],
|
|
"#d": [repo_id]
|
|
});
|
|
|
|
// If owner specified, filter by author pubkey; otherwise return any match.
|
|
// Note: without --owner, multiple repos with the same name (different owners) may be returned.
|
|
if let Some(pk) = owner {
|
|
crate::validate::validate_hex64(pk)?;
|
|
filter["authors"] = serde_json::json!([pk]);
|
|
}
|
|
|
|
let resp = client.query(&filter).await?;
|
|
println!("{resp}");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cmd_list_repos(
|
|
client: &BuzzClient,
|
|
owner: Option<&str>,
|
|
limit: Option<u32>,
|
|
) -> Result<(), CliError> {
|
|
// Default to self if no owner specified.
|
|
let pubkey = match owner {
|
|
Some(pk) => {
|
|
crate::validate::validate_hex64(pk)?;
|
|
pk.to_string()
|
|
}
|
|
None => client.keys().public_key().to_hex(),
|
|
};
|
|
|
|
let mut filter = serde_json::json!({
|
|
"kinds": [30617],
|
|
"authors": [pubkey]
|
|
});
|
|
|
|
if let Some(n) = limit {
|
|
filter["limit"] = serde_json::json!(n);
|
|
}
|
|
|
|
let resp = client.query(&filter).await?;
|
|
println!("{resp}");
|
|
Ok(())
|
|
}
|
|
|
|
async fn current_repo(client: &BuzzClient, repo_id: &str) -> Result<Event, CliError> {
|
|
validate_repo_id(repo_id)?;
|
|
fetch_own_repo_announcement(client, repo_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
CliError::NotFound(format!(
|
|
"repository {repo_id:?} was not found for the current identity"
|
|
))
|
|
})
|
|
}
|
|
|
|
async fn cmd_protect_list(client: &BuzzClient, repo_id: &str) -> Result<(), CliError> {
|
|
let event = current_repo(client, repo_id).await?;
|
|
println!("{}", protection_rules_json(&event)?);
|
|
Ok(())
|
|
}
|
|
|
|
async fn cmd_protect_set(
|
|
client: &BuzzClient,
|
|
repo_id: &str,
|
|
ref_pattern: &str,
|
|
push_role: Option<crate::RepoPushRole>,
|
|
no_force_push: bool,
|
|
no_delete: bool,
|
|
require_patch: bool,
|
|
) -> Result<(), CliError> {
|
|
let push_role = push_role.map(|role| match role {
|
|
crate::RepoPushRole::Owner => "owner",
|
|
crate::RepoPushRole::Admin => "admin",
|
|
crate::RepoPushRole::Member => "member",
|
|
});
|
|
let tag = build_protection_tag(
|
|
ref_pattern,
|
|
push_role,
|
|
no_force_push,
|
|
no_delete,
|
|
require_patch,
|
|
)?;
|
|
let event = current_repo(client, repo_id).await?;
|
|
let builder =
|
|
build_updated_repo_announcement(&event, RepoChange::SetProtection(Box::new(tag)))?;
|
|
submit_repo_update(client, builder).await
|
|
}
|
|
|
|
async fn cmd_protect_remove(
|
|
client: &BuzzClient,
|
|
repo_id: &str,
|
|
ref_pattern: &str,
|
|
) -> Result<(), CliError> {
|
|
RefPattern::parse(ref_pattern)
|
|
.map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?;
|
|
let event = current_repo(client, repo_id).await?;
|
|
if !event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| protection_pattern(tag) == Some(ref_pattern))
|
|
{
|
|
return Err(CliError::NotFound(format!(
|
|
"repository {repo_id:?} has no protection rule for {ref_pattern:?}"
|
|
)));
|
|
}
|
|
let builder = build_updated_repo_announcement(
|
|
&event,
|
|
RepoChange::RemoveProtection(ref_pattern.to_string()),
|
|
)?;
|
|
submit_repo_update(client, builder).await
|
|
}
|
|
|
|
/// Bind (or rebind) a repository to a channel — the fix path for issue
|
|
/// #3527's permanently-404 repos. Publishes a read-modify-write update of
|
|
/// the caller's own kind:30617 with exactly one `buzz-channel` tag; all
|
|
/// other metadata (protections, name, description, future tags) is
|
|
/// preserved by the same machinery `repos protect` uses.
|
|
///
|
|
/// The UUID is validated for *shape* only — deliberately. Channel existence
|
|
/// and the caller's membership are the relay's authority at git-access
|
|
/// time; a CLI-side network pre-check would just be TOCTOU with extra
|
|
/// latency.
|
|
async fn cmd_bind_repo(client: &BuzzClient, repo_id: &str, channel: &str) -> Result<(), CliError> {
|
|
let event = current_repo(client, repo_id).await?;
|
|
let builder =
|
|
build_updated_repo_announcement(&event, RepoChange::BindChannel(channel.to_string()))?;
|
|
submit_repo_update(client, builder).await
|
|
}
|
|
|
|
pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), CliError> {
|
|
use crate::{ReposCmd, ReposProtectCmd};
|
|
match cmd {
|
|
ReposCmd::Create {
|
|
id,
|
|
name,
|
|
description,
|
|
clone_urls,
|
|
web,
|
|
relays,
|
|
channel,
|
|
} => {
|
|
cmd_create_repo(
|
|
client,
|
|
&id,
|
|
name.as_deref(),
|
|
description.as_deref(),
|
|
&clone_urls,
|
|
web.as_deref(),
|
|
&relays,
|
|
channel.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await,
|
|
ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await,
|
|
ReposCmd::Bind { id, channel } => cmd_bind_repo(client, &id, &channel).await,
|
|
ReposCmd::Protect(command) => match command {
|
|
ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await,
|
|
ReposProtectCmd::Set {
|
|
id,
|
|
ref_pattern,
|
|
push,
|
|
no_force_push,
|
|
no_delete,
|
|
require_patch,
|
|
} => {
|
|
cmd_protect_set(
|
|
client,
|
|
&id,
|
|
&ref_pattern,
|
|
push,
|
|
no_force_push,
|
|
no_delete,
|
|
require_patch,
|
|
)
|
|
.await
|
|
}
|
|
ReposProtectCmd::Remove { id, ref_pattern } => {
|
|
cmd_protect_remove(client, &id, &ref_pattern).await
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
|
|
|
use super::{
|
|
build_create_announcement, build_protection_tag, build_updated_repo_announcement,
|
|
protection_rules_json, validate_write_response, RepoChange,
|
|
};
|
|
|
|
fn signed_repo(tags: Vec<Tag>, content: &str, created_at: u64) -> nostr::Event {
|
|
EventBuilder::new(Kind::Custom(30617), content)
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp::from(created_at))
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign repository event")
|
|
}
|
|
|
|
fn tag(parts: &[&str]) -> Tag {
|
|
Tag::parse(parts.iter().copied()).expect("valid test tag")
|
|
}
|
|
|
|
#[test]
|
|
fn protection_update_preserves_metadata_and_replaces_only_matching_pattern() {
|
|
let existing = signed_repo(
|
|
vec![
|
|
tag(&["d", "demo"]),
|
|
tag(&["name", "Demo"]),
|
|
tag(&["buzz-channel", "channel-id"]),
|
|
tag(&["future-metadata", "preserve-me"]),
|
|
tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
|
|
tag(&["buzz-protect", "refs/heads/main", "push:member"]),
|
|
tag(&["buzz-protect", "refs/tags/*", "no-delete"]),
|
|
],
|
|
"repository content",
|
|
100,
|
|
);
|
|
let replacement = build_protection_tag("refs/heads/main", Some("admin"), true, true, false)
|
|
.expect("valid replacement");
|
|
|
|
let updated = build_updated_repo_announcement(
|
|
&existing,
|
|
RepoChange::SetProtection(Box::new(replacement)),
|
|
)
|
|
.expect("build update")
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign update");
|
|
|
|
assert_eq!(updated.content, "repository content");
|
|
assert_eq!(updated.created_at.as_secs(), 101);
|
|
assert!(!updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["buzz-channel", "channel-id"]));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"]));
|
|
assert!(updated.tags.iter().any(|tag| {
|
|
tag.as_slice()
|
|
== [
|
|
"buzz-protect",
|
|
"refs/heads/main",
|
|
"push:admin",
|
|
"no-force-push",
|
|
"no-delete",
|
|
]
|
|
}));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| { tag.as_slice() == ["buzz-protect", "refs/tags/*", "no-delete"] }));
|
|
assert_eq!(
|
|
updated
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| {
|
|
let values = tag.as_slice();
|
|
values.first().map(String::as_str) == Some("buzz-protect")
|
|
&& values.get(1).map(String::as_str) == Some("refs/heads/main")
|
|
})
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn protection_remove_preserves_other_patterns() {
|
|
let existing = signed_repo(
|
|
vec![
|
|
tag(&["d", "demo"]),
|
|
tag(&["buzz-protect", "refs/heads/main", "no-delete"]),
|
|
tag(&["buzz-protect", "refs/heads/release", "push:owner"]),
|
|
],
|
|
"",
|
|
10,
|
|
);
|
|
|
|
let updated = build_updated_repo_announcement(
|
|
&existing,
|
|
RepoChange::RemoveProtection("refs/heads/main".into()),
|
|
)
|
|
.expect("build removal")
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign removal");
|
|
|
|
assert!(!updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice().get(1).map(String::as_str) == Some("refs/heads/main")));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| { tag.as_slice() == ["buzz-protect", "refs/heads/release", "push:owner"] }));
|
|
}
|
|
|
|
#[test]
|
|
fn protection_set_requires_at_least_one_rule() {
|
|
assert!(build_protection_tag("refs/heads/main", None, false, false, false).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn protection_update_rejects_malformed_existing_rules() {
|
|
let existing = signed_repo(
|
|
vec![
|
|
tag(&["d", "demo"]),
|
|
tag(&["buzz-protect", "refs/heads/main"]),
|
|
],
|
|
"",
|
|
10,
|
|
);
|
|
let replacement =
|
|
build_protection_tag("refs/heads/release", Some("admin"), false, false, false)
|
|
.expect("valid replacement");
|
|
|
|
let error = build_updated_repo_announcement(
|
|
&existing,
|
|
RepoChange::SetProtection(Box::new(replacement)),
|
|
)
|
|
.expect_err("malformed existing rule must fail closed");
|
|
|
|
assert!(error
|
|
.to_string()
|
|
.contains("repository contains invalid protection rules"));
|
|
}
|
|
|
|
#[test]
|
|
fn protection_update_enforces_repository_rule_limit() {
|
|
let mut tags = vec![tag(&["d", "demo"])];
|
|
for index in 0..50 {
|
|
tags.push(tag(&[
|
|
"buzz-protect",
|
|
&format!("refs/heads/branch-{index}"),
|
|
"push:member",
|
|
]));
|
|
}
|
|
let existing = signed_repo(tags, "", 10);
|
|
let replacement =
|
|
build_protection_tag("refs/heads/main", Some("admin"), false, false, false)
|
|
.expect("valid replacement");
|
|
|
|
let error = build_updated_repo_announcement(
|
|
&existing,
|
|
RepoChange::SetProtection(Box::new(replacement)),
|
|
)
|
|
.expect_err("the 51st rule must be rejected");
|
|
|
|
assert!(error.to_string().contains("exceeds max 50"));
|
|
}
|
|
|
|
#[test]
|
|
fn protection_list_keeps_unknown_rules_visible() {
|
|
let existing = signed_repo(
|
|
vec![
|
|
tag(&["d", "demo"]),
|
|
tag(&[
|
|
"buzz-protect",
|
|
"refs/heads/main",
|
|
"push:admin",
|
|
"future-rule",
|
|
]),
|
|
],
|
|
"",
|
|
10,
|
|
);
|
|
|
|
let json = protection_rules_json(&existing).expect("list protections");
|
|
assert_eq!(json["repo_id"], "demo");
|
|
assert_eq!(json["protections"][0]["ref"], "refs/heads/main");
|
|
assert_eq!(
|
|
json["protections"][0]["rules"],
|
|
serde_json::json!(["push:admin", "future-rule"])
|
|
);
|
|
assert_eq!(json["validation_error"], serde_json::Value::Null);
|
|
}
|
|
|
|
#[test]
|
|
fn protection_list_surfaces_malformed_rules_for_recovery() {
|
|
let existing = signed_repo(
|
|
vec![
|
|
tag(&["d", "demo"]),
|
|
tag(&["buzz-protect", "refs/heads/main"]),
|
|
],
|
|
"",
|
|
10,
|
|
);
|
|
|
|
let json = protection_rules_json(&existing).expect("list malformed protections");
|
|
assert_eq!(json["protections"][0]["ref"], "refs/heads/main");
|
|
assert!(json["validation_error"]
|
|
.as_str()
|
|
.is_some_and(|error| error.contains("needs pattern + at least one rule")));
|
|
}
|
|
|
|
#[test]
|
|
fn bind_channel_replaces_duplicates_and_preserves_everything_else() {
|
|
let channel = uuid::Uuid::new_v4().to_string();
|
|
let existing = signed_repo(
|
|
vec![
|
|
tag(&["d", "demo"]),
|
|
tag(&["name", "Demo"]),
|
|
// Two stale bindings — e.g. from a buggy or vanilla client.
|
|
tag(&["buzz-channel", "old-and-broken"]),
|
|
tag(&["buzz-channel", &uuid::Uuid::new_v4().to_string()]),
|
|
tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
|
|
tag(&["buzz-protect", "refs/heads/main", "push:admin"]),
|
|
tag(&["future-metadata", "preserve-me"]),
|
|
],
|
|
"repository content",
|
|
100,
|
|
);
|
|
|
|
let updated =
|
|
build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone()))
|
|
.expect("build bind update")
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign bind update");
|
|
|
|
assert_eq!(updated.content, "repository content");
|
|
assert_eq!(updated.created_at.as_secs(), 101);
|
|
// Exactly one binding remains, and it is the requested one.
|
|
let bindings: Vec<_> = updated
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel"))
|
|
.collect();
|
|
assert_eq!(bindings.len(), 1);
|
|
assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]);
|
|
// Auth stripped (relay re-stamps); everything else preserved.
|
|
assert!(!updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["buzz-protect", "refs/heads/main", "push:admin"]));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"]));
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["name", "Demo"]));
|
|
}
|
|
|
|
#[test]
|
|
fn bind_channel_adds_binding_to_unbound_repo() {
|
|
let channel = uuid::Uuid::new_v4().to_string();
|
|
let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10);
|
|
|
|
let updated =
|
|
build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone()))
|
|
.expect("build bind update")
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign bind update");
|
|
|
|
assert!(updated
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["buzz-channel", channel.as_str()]));
|
|
}
|
|
|
|
#[test]
|
|
fn bind_channel_rejects_malformed_uuid() {
|
|
let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10);
|
|
|
|
let error =
|
|
build_updated_repo_announcement(&existing, RepoChange::BindChannel("nope".into()))
|
|
.expect_err("malformed channel id must not build an update");
|
|
|
|
assert!(matches!(error, crate::error::CliError::Usage(_)));
|
|
}
|
|
|
|
/// Issue #3527: `repos create --channel` must emit exactly one
|
|
/// `buzz-channel` tag so the primary create command stops producing
|
|
/// repos the relay 404s forever.
|
|
#[test]
|
|
fn create_with_channel_emits_exactly_one_binding_tag() {
|
|
let channel = uuid::Uuid::new_v4().to_string();
|
|
let event = build_create_announcement(
|
|
"demo",
|
|
Some("Demo"),
|
|
None,
|
|
&["https://relay.example/git/owner/demo".to_string()],
|
|
None,
|
|
&[],
|
|
Some(&channel),
|
|
)
|
|
.expect("build create announcement")
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign create announcement");
|
|
|
|
assert_eq!(event.kind, Kind::Custom(30617));
|
|
let bindings: Vec<_> = event
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel"))
|
|
.collect();
|
|
assert_eq!(bindings.len(), 1, "exactly one buzz-channel tag");
|
|
assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]);
|
|
// The standard metadata still rides along.
|
|
assert!(event.tags.iter().any(|tag| tag.as_slice() == ["d", "demo"]));
|
|
assert!(event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["name", "Demo"]));
|
|
}
|
|
|
|
#[test]
|
|
fn create_without_channel_emits_no_binding_tag() {
|
|
let event = build_create_announcement("demo", None, None, &[], None, &[], None)
|
|
.expect("build create announcement")
|
|
.sign_with_keys(&Keys::generate())
|
|
.expect("sign create announcement");
|
|
|
|
assert!(
|
|
!event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")),
|
|
"no --channel means no binding tag (vanilla NIP-34 stays possible)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_rejects_malformed_channel_uuid() {
|
|
let error = build_create_announcement("demo", None, None, &[], None, &[], Some("nope"))
|
|
.expect_err("malformed channel id must not build an announcement");
|
|
assert!(matches!(error, crate::error::CliError::Usage(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_write_response_is_a_conflict() {
|
|
let error = validate_write_response(
|
|
r#"{"event_id":"abc","accepted":true,"message":"duplicate: superseded"}"#,
|
|
)
|
|
.expect_err("dominated writes must not report success");
|
|
|
|
assert!(matches!(error, crate::error::CliError::Conflict(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn successful_write_response_is_normalized() {
|
|
let output = validate_write_response(
|
|
r#"{"event_id":"abc","accepted":true,"message":"saved","extra":"ignored"}"#,
|
|
)
|
|
.expect("accepted write");
|
|
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&output).expect("normalized JSON"),
|
|
serde_json::json!({
|
|
"event_id": "abc",
|
|
"accepted": true,
|
|
"message": "saved",
|
|
})
|
|
);
|
|
}
|
|
}
|