mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
Implements the `buzz projects` command group — the NIP-MP Phase 2 write path for kind:30621 multi-repo projects. The relay accepted kind:30621 in #3171; this adds the two-layer Rust builder in `buzz-sdk` and the seven CLI commands. ## What this adds ### `crates/buzz-sdk/src/builders.rs` — two-layer builder **Layer A (protocol):** - `validate_project_envelope(tags, content)` — 8 NIP-MP rules in relay order: `d`-cardinality, `d`-empty/length, member-cap (≤64 `a` tags, checked before per-tag parse), member-tag-arity (2–3 elements), member-coordinate grammar (first-two-colons split, literal `30617`, lowercase 64-hex owner, non-empty remainder), member-duplicate (coordinate only, hint ignored), singleton metadata cardinality, byte bounds (`name` ≤256 / `description` ≤2048 / `buzz-channel` ≤256 / `buzz-visibility` ≤256). - `build_project_with_tags(content, tags)` — raw Layer A builder; RMW mutations path. - `ProjectMemberCoord` — `30617:<owner-hex>:<repo-d>` + optional opaque relay hint; equality/Hash by coordinate only. **Layer B (writer policy):** - `build_project(slug, name, description, members, channel, visibility)` — constructs `d` tag, enforces UUID channel and `listed|unlisted` visibility, forces empty content; composes onto Layer A. This is the `create` path. **Shared:** - `build_delete_addressable(kind, pubkey, d)` — generic NIP-09 kind:5 coordinate delete; `build_workflow_delete` now delegates to this. - All 31 `NIP-MP.fixtures.json` cases exercised through `build_project_with_tags`; count assertion guards against omissions. ### `crates/buzz-cli/` — seven commands ``` buzz projects create <slug> --repo <coord> [--name] [--description] [--channel <uuid>] [--visibility listed|unlisted] buzz projects get <slug> [--owner <pubkey>] buzz projects list [--owner <pubkey>] [--limit <n>] buzz projects add-repo <slug> --repo <coord> [--repo <coord>]... buzz projects remove-repo <slug> --repo <coord> [--repo <coord>]... buzz projects update <slug> [--name|--clear-name] [--description|--clear-description] [--channel <uuid>|--clear-channel] [--visibility listed|unlisted|--clear-visibility] buzz projects delete <slug> ``` Command semantics: - **`create`**: all local validation (slug, repos, channel, visibility, name length) fires before the collision preflight — invalid input returns `Usage` without a network call. Routes through Layer B (`build_project`). - **`update`**: at least one setter/clearer required — enforced by a clap `ArgGroup` with `required(true).multiple(true)`, with a runtime backstop for programmatic callers; setter + own clearer are mutually exclusive per clap conflicts. - **`add-repo`/`remove-repo`**: coordinate expansion and dedup fire before head fetch — malformed or duplicate `--repo` values return `Usage` without touching the relay. - **`delete`**: head-based tombstone at `created_at = head + 1`; post-submit re-query verifies tombstone landed. - All mutations: strip `auth`, re-validate full envelope through Layer A; `created_at` advances from observed head, never wall-clock. - Relay hints on existing member tags preserved verbatim through RMW. ## Limitations (recorded, not in scope) - **No relay-hint authoring**: `--repo` carries a coordinate only; existing hinted `a` tags survive RMW unchanged. - **Signer-self delete only**: NIP-OA owner-delete extension not exposed; `delete` targets the signer's own coordinate. - **Deletion durability**: watermark carry-over applies; `delete` is best-effort against a later-arriving replacement. ## Live round-trip 21-step transcript executed against a relay built from `origin/main` `b1b283cd4`, covering create, get, multi-field update (name + description + channel in one call), channel set/clear, add-repo, remove-repo, delete (tombstone verified at `head+1`, repeated delete → `NotFound`). Delta transcript confirmed multi-field update, channel set/clear, no-op add-repo → `Conflict` exit 5, empty update and setter+own-clearer both rejected at parse time. Duplicate create → `Conflict`. Cross-owner `add-repo` with full coordinate exercised. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
844 lines
28 KiB
Rust
844 lines
28 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}")))
|
|
}
|
|
|
|
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)]
|
|
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 resp = client.submit_event(event).await?;
|
|
println!("{resp}");
|
|
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",
|
|
})
|
|
);
|
|
}
|
|
}
|