From 2fc8d83caef1c7b0040e3dc194399d4956f8a985 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 1 Sep 2026 13:11:50 -0400 Subject: [PATCH] Add collaborative Project state CLI Signed-off-by: John Tennant --- crates/buzz-cli/src/commands/projects.rs | 305 ++++++++++++++++++++- crates/buzz-cli/src/lib.rs | 58 +++- crates/buzz-core/src/project_state.rs | 323 ++++++++++++++++++++++- 3 files changed, 675 insertions(+), 11 deletions(-) diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 3edad04b41..b51c3e41d0 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -16,15 +16,18 @@ //! - Deletion durability against later arrival (watermark follow-up) is //! not in scope. -use buzz_core::kind::KIND_PROJECT; +use buzz_core::kind::{KIND_PROJECT, KIND_PROJECT_STATE}; +use buzz_core::project_state::validate_project_state_projection; use buzz_sdk::{ - build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, - PROJECT_D_MAX_LEN, + build_delete_addressable, build_project, build_project_related_channel_change, + build_project_with_tags, ProjectMemberCoord, PROJECT_D_MAX_LEN, }; use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; use crate::agent_management::{build_project_channel, CreateProjectChannelDraft}; -use crate::client::BuzzClient; +use crate::client::{normalize_events, BuzzClient}; use crate::commands::parse_write_response; use crate::commands::project_channel::{ repo_id_from_project_slug, require_repo_channel_binding, truncate_repo_name, @@ -481,6 +484,159 @@ pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Re Ok(()) } +fn project_owner(client: &BuzzClient, owner: Option<&str>) -> Result { + let owner = owner + .map(str::to_owned) + .or_else(|| client.auth_tag_owner_hex()) + .unwrap_or_else(|| client.keys().public_key().to_hex()); + crate::validate::validate_hex64(&owner)?; + PublicKey::parse(&owner) + .map(|key| key.to_hex()) + .map_err(|error| CliError::Usage(format!("invalid Project owner: {error}"))) +} + +fn project_coordinate(owner: &str, slug: &str) -> Result { + validate_project_slug(slug)?; + Ok(format!("{KIND_PROJECT}:{owner}:{slug}")) +} + +fn parse_project_state( + event: Event, + relay_pubkey: &str, + coordinate: &str, +) -> Result<(Event, u64), CliError> { + let relay_pubkey = PublicKey::parse(relay_pubkey) + .map_err(|error| CliError::Other(format!("relay self pubkey is invalid: {error}")))?; + let revision = validate_project_state_projection(&event, &relay_pubkey, coordinate) + .map_err(|error| CliError::Other(format!("Project State is invalid: {error}")))?; + Ok((event, revision)) +} + +async fn fetch_project_state( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, +) -> Result<(Event, u64), CliError> { + let owner = project_owner(client, owner)?; + let coordinate = project_coordinate(&owner, slug)?; + let relay_info: serde_json::Value = serde_json::from_str(&client.get_public("/").await?) + .map_err(|error| CliError::Other(format!("relay info is invalid: {error}")))?; + let relay_pubkey = relay_info + .get("self") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CliError::Other("relay info is missing its self pubkey".into()))?; + crate::validate::validate_hex64(relay_pubkey) + .map_err(|_| CliError::Other("relay self pubkey is invalid".into()))?; + let relay_pubkey = PublicKey::parse(relay_pubkey) + .map_err(|error| CliError::Other(format!("relay self pubkey is invalid: {error}")))? + .to_hex(); + let projection_d = hex::encode(Sha256::digest(coordinate.as_bytes())); + let events = client + .query_paginated( + serde_json::json!({ + "kinds": [KIND_PROJECT_STATE], + "authors": [relay_pubkey], + "#d": [projection_d], + "#a": [coordinate], + }), + 2, + ) + .await?; + if events.len() > 1 { + return Err(CliError::Other( + "relay returned multiple current Project State events".into(), + )); + } + let event = events + .into_iter() + .next() + .ok_or_else(|| CliError::NotFound(format!("Project State for {slug:?} not found")))?; + let event: Event = serde_json::from_value(event) + .map_err(|error| CliError::Other(format!("Project State event is invalid: {error}")))?; + parse_project_state(event, &relay_pubkey, &coordinate) +} + +fn format_project_state(event: &Event, format: &crate::OutputFormat) -> Result { + let normalized = normalize_events(&[serde_json::to_value(event) + .map_err(|error| CliError::Other(format!("Project State encoding failed: {error}")))?]); + match format { + crate::OutputFormat::Json => Ok(normalized), + crate::OutputFormat::Compact => { + let events: Vec = + serde_json::from_str(&normalized).map_err(|error| { + CliError::Other(format!("Project State encoding failed: {error}")) + })?; + let compact: Vec = events + .iter() + .map(|event| { + serde_json::json!({ + "id": event.get("id").cloned().unwrap_or_default(), + "content": event.get("content").cloned().unwrap_or_default(), + "created_at": event.get("created_at").cloned().unwrap_or_default(), + }) + }) + .collect(); + serde_json::to_string(&compact) + .map_err(|error| CliError::Other(format!("Project State encoding failed: {error}"))) + } + } +} + +async fn cmd_state( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, + format: &crate::OutputFormat, +) -> Result<(), CliError> { + let (event, _) = fetch_project_state(client, slug, owner).await?; + println!("{}", format_project_state(&event, format)?); + Ok(()) +} + +async fn cmd_change_related_channels( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, + add: &[Uuid], + remove: &[Uuid], +) -> Result<(), CliError> { + let owner = project_owner(client, owner)?; + let coordinate = project_coordinate(&owner, slug)?; + let (_, revision) = fetch_project_state(client, slug, Some(&owner)).await?; + let builder = build_project_related_channel_change(&coordinate, revision, add, remove) + .map_err(|error| CliError::Usage(error.to_string()))?; + let event = client.sign_event(builder)?; + let raw = map_project_change_submit(client.submit_event(event).await)?; + let response: serde_json::Value = serde_json::from_str(&raw) + .map_err(|error| CliError::Other(format!("relay response is invalid: {error}")))?; + if !response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("Project change was rejected"); + return if message.starts_with("conflict:") { + Err(CliError::Conflict(message.into())) + } else { + Err(CliError::Other(format!("relay rejected event: {message}"))) + }; + } + println!("{}", crate::client::normalize_write_response(&raw)); + Ok(()) +} + +fn map_project_change_submit(result: Result) -> Result { + match result { + Err(CliError::Relay { status: 400, body }) if body.starts_with("conflict:") => { + Err(CliError::Conflict(body)) + } + result => result, + } +} + /// `buzz projects list` pub async fn cmd_list( client: &BuzzClient, @@ -798,7 +954,11 @@ fn validate_visibility(vis: &str) -> Result<(), CliError> { // ── Dispatch ────────────────────────────────────────────────────────────────── -pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> { +pub async fn dispatch( + cmd: crate::ProjectsCmd, + client: &BuzzClient, + format: &crate::OutputFormat, +) -> Result<(), CliError> { use crate::ProjectsCmd; match cmd { ProjectsCmd::Create { @@ -821,6 +981,9 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<() .await } ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, + ProjectsCmd::State { slug, owner } => { + cmd_state(client, &slug, owner.as_deref(), format).await + } ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, ProjectsCmd::AddChannel { @@ -842,6 +1005,12 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<() ) .await } + ProjectsCmd::ChangeRelatedChannels { + slug, + owner, + add, + remove, + } => cmd_change_related_channels(client, &slug, owner.as_deref(), &add, &remove).await, ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, ProjectsCmd::Update { slug, @@ -877,10 +1046,134 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<() #[cfg(test)] mod tests { use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP}; - use nostr::Tag; + use nostr::{Keys, Kind, Tag}; use super::*; + fn project_state_event(keys: &Keys, coordinate: &str, revision: &str, content: &str) -> Event { + let projection_d = hex::encode(Sha256::digest(coordinate.as_bytes())); + EventBuilder::new(Kind::Custom(KIND_PROJECT_STATE as u16), content) + .tags([ + Tag::parse(["d", &projection_d]).expect("d tag"), + Tag::parse(["a", coordinate]).expect("a tag"), + Tag::parse(["rev", revision]).expect("rev tag"), + Tag::parse(["e", &"1".repeat(64), "", "identity"]).expect("identity tag"), + Tag::parse(["e", &"2".repeat(64), "", "change"]).expect("change tag"), + ]) + .sign_with_keys(keys) + .expect("state event") + } + + #[test] + fn project_state_requires_relay_signature_coordinate_and_canonical_body() { + let relay = Keys::generate(); + let owner = "a".repeat(64); + let coordinate = format!("30621:{owner}:platform"); + let content = r#"{"v":1,"deleted":false,"project_tags":[["d","platform"]]}"#; + let event = project_state_event(&relay, &coordinate, "7", content); + let (_, revision) = + parse_project_state(event.clone(), &relay.public_key().to_hex(), &coordinate) + .expect("valid state"); + assert_eq!(revision, 7); + + let impostor = Keys::generate(); + assert!(parse_project_state( + project_state_event(&impostor, &coordinate, "7", content), + &relay.public_key().to_hex(), + &coordinate, + ) + .is_err()); + assert!(parse_project_state( + event, + &relay.public_key().to_hex(), + &format!("30621:{owner}:other"), + ) + .is_err()); + assert!(parse_project_state( + project_state_event(&relay, &coordinate, "07", content), + &relay.public_key().to_hex(), + &coordinate, + ) + .is_err()); + assert!(parse_project_state( + project_state_event( + &relay, + &coordinate, + "7", + r#"{"v":1,"deleted":false,"project_tags":[],"future":true}"#, + ), + &relay.public_key().to_hex(), + &coordinate, + ) + .is_err()); + assert!(parse_project_state( + project_state_event(&relay, &coordinate, "7", &format!(" {content}")), + &relay.public_key().to_hex(), + &coordinate, + ) + .is_err()); + } + + #[test] + fn project_state_output_honors_global_format() { + let relay = Keys::generate(); + let coordinate = format!("30621:{}:platform", "a".repeat(64)); + let content = r#"{"v":1,"deleted":false,"project_tags":[["d","platform"]]}"#; + let event = project_state_event(&relay, &coordinate, "7", content); + + let json: Vec = serde_json::from_str( + &format_project_state(&event, &crate::OutputFormat::Json).expect("json output"), + ) + .expect("JSON array"); + assert_eq!(json.len(), 1); + let object = json[0].as_object().expect("event object"); + assert_eq!(object.len(), 7); + for field in [ + "id", + "pubkey", + "kind", + "content", + "created_at", + "tags", + "sig", + ] { + assert!(object.contains_key(field), "missing {field}"); + } + + let compact: Vec = serde_json::from_str( + &format_project_state(&event, &crate::OutputFormat::Compact).expect("compact output"), + ) + .expect("compact array"); + assert_eq!(compact.len(), 1); + let compact = compact[0].as_object().expect("compact event object"); + assert_eq!(compact.len(), 3); + assert_eq!(compact.get("id"), object.get("id")); + assert_eq!(compact.get("content"), object.get("content")); + assert_eq!(compact.get("created_at"), object.get("created_at")); + } + + #[test] + fn project_owner_defaults_to_delegating_owner() { + let actor = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let auth = Tag::parse(["auth", &owner, "kind=47010", &"3".repeat(128)]).expect("auth tag"); + let client = + crate::client::BuzzClient::new("http://127.0.0.1:9".into(), actor, Some(auth), None) + .expect("client"); + assert_eq!(project_owner(&client, None).expect("owner"), owner); + } + + #[test] + fn project_change_cas_conflict_maps_to_exit_five() { + let error = map_project_change_submit(Err(CliError::Relay { + status: 400, + body: "conflict: Project revision is 8".into(), + })) + .expect_err("conflict"); + assert!(matches!(error, CliError::Conflict(_))); + assert_eq!(crate::error::exit_code(&error), 5); + } + async fn run_default_repo_create_race( winning_channel: &str, ) -> (Result<(), CliError>, Vec) { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa..ecd18bba05 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1316,6 +1316,15 @@ pub enum ProjectsCmd { #[arg(long)] owner: Option, }, + /// Read the relay-authoritative collaborative Project state + State { + /// Project slug + slug: String, + /// Project owner pubkey (64-char hex). Defaults to the current identity + /// or its NIP-OA owner when configured. + #[arg(long)] + owner: Option, + }, /// List projects List { /// Owner pubkey (64-char hex). Defaults to the current identity. @@ -1356,6 +1365,25 @@ pub enum ProjectsCmd { #[arg(long)] template: Option, }, + /// Atomically add or remove related channels at the current revision + #[command( + name = "change-related-channels", + group = clap::ArgGroup::new("related-channel-mutation").required(true).multiple(true) + )] + ChangeRelatedChannels { + /// Project slug + slug: String, + /// Project owner pubkey (64-char hex). Defaults to the current identity + /// or its NIP-OA owner when configured. + #[arg(long)] + owner: Option, + /// Related channel UUID to add (repeatable) + #[arg(long = "add", group = "related-channel-mutation")] + add: Vec, + /// Related channel UUID to remove (repeatable) + #[arg(long = "remove", group = "related-channel-mutation")] + remove: Vec, + }, /// Remove one or more member repositories from a project #[command(name = "remove-repo")] RemoveRepo { @@ -2087,7 +2115,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, - Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await, + Cmd::Projects(sub) => commands::projects::dispatch(sub, &client, &cli.format).await, Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, @@ -2399,11 +2427,13 @@ mod tests { vec![ "add-channel", "add-repo", + "change-related-channels", "create", "delete", "get", "list", "remove-repo", + "state", "update" ] ); @@ -2444,7 +2474,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), - ("projects", 8), + ("projects", 10), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2534,6 +2564,30 @@ mod tests { .is_ok()); } + #[test] + fn projects_collaborative_state_commands_parse() { + let owner = "a".repeat(64); + assert!( + Cli::try_parse_from(["buzz", "projects", "state", "platform", "--owner", &owner,]) + .is_ok() + ); + assert!(Cli::try_parse_from([ + "buzz", + "projects", + "change-related-channels", + "platform", + "--add", + "11111111-1111-4111-8111-111111111111", + "--remove", + "22222222-2222-4222-8222-222222222222", + ]) + .is_ok()); + assert!( + Cli::try_parse_from(["buzz", "projects", "change-related-channels", "platform",]) + .is_err() + ); + } + /// Multiple independent fields must be accepted in the same invocation. #[test] fn projects_update_multi_field_is_accepted() { diff --git a/crates/buzz-core/src/project_state.rs b/crates/buzz-core/src/project_state.rs index 8c94e2600f..1eae9bb780 100644 --- a/crates/buzz-core/src/project_state.rs +++ b/crates/buzz-core/src/project_state.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; -use nostr::{Event, EventId, Kind, Tag}; -use serde::Serialize; +use nostr::{Event, EventId, Kind, PublicKey, Tag}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; use uuid::Uuid; @@ -125,13 +125,232 @@ pub fn project_state_template( }) } -#[derive(Serialize)] +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct ProjectionBody { v: u8, deleted: bool, project_tags: Vec>, } +/// Validate a relay-authored Project State event and return its CAS revision. +/// +/// This checks the event signature and relay author, the exact canonical +/// envelope and compact JSON encoding, and the canonical ordering and shape of +/// all known effective Project tags. Unknown Project extension tags remain +/// valid, but may appear only after the known tag groups. +pub fn validate_project_state_projection( + event: &Event, + relay_pubkey: &PublicKey, + coordinate: &str, +) -> Result { + let (_, project_d) = parse_project_coordinate(coordinate)?; + event + .verify() + .map_err(|error| ProjectStateError::State(format!("invalid event signature: {error}")))?; + if event.kind.as_u16() as u32 != KIND_PROJECT_STATE || &event.pubkey != relay_pubkey { + return Err(ProjectStateError::State( + "event is not a Project State signed by the relay".into(), + )); + } + + let raw_tags: Vec<&[String]> = event.tags.iter().map(Tag::as_slice).collect(); + if raw_tags.len() != 5 { + return Err(ProjectStateError::State( + "projection must have exactly five tags".into(), + )); + } + let projection_d = hex::encode(Sha256::digest(coordinate.as_bytes())); + if raw_tags[0] != ["d", projection_d.as_str()] + || raw_tags[1] != ["a", coordinate] + || raw_tags[2].len() != 2 + || raw_tags[2][0] != "rev" + || !canonical_positive_i64(&raw_tags[2][1]) + || !canonical_event_reference(raw_tags[3], "identity") + || !canonical_event_reference(raw_tags[4], "change") + { + return Err(ProjectStateError::State( + "projection envelope is not canonical".into(), + )); + } + let revision = raw_tags[2][1] + .parse::() + .map_err(|error| ProjectStateError::State(format!("invalid revision: {error}")))?; + + let body: ProjectionBody = serde_json::from_str(&event.content) + .map_err(|error| ProjectStateError::State(format!("invalid projection JSON: {error}")))?; + let canonical = serde_json::to_string(&body) + .map_err(|error| ProjectStateError::Encoding(error.to_string()))?; + if body.v != 1 || canonical != event.content { + return Err(ProjectStateError::State( + "projection JSON is not supported canonical version 1".into(), + )); + } + if body.deleted { + if !body.project_tags.is_empty() { + return Err(ProjectStateError::State( + "deleted projection must have no Project tags".into(), + )); + } + } else { + validate_canonical_live_projection_tags(&body.project_tags, project_d)?; + } + Ok(revision) +} + +fn canonical_positive_i64(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('0') + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok_and(|value| value > 0) +} + +fn canonical_event_reference(tag: &[String], marker: &str) -> bool { + matches!(tag, [name, id, relay, tag_marker] + if name == "e" + && relay.is_empty() + && tag_marker == marker + && is_lower_hex64(id) + && EventId::parse(id).is_ok()) +} + +fn is_lower_hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_canonical_live_projection_tags( + tags: &[Vec], + project_d: &str, +) -> Result<(), ProjectStateError> { + if !matches!(tags.first().map(Vec::as_slice), Some([name, value]) if name == "d" && value == project_d) + { + return Err(ProjectStateError::State( + "live projection must begin with its matching Project d tag".into(), + )); + } + + let mut name = None; + let mut description = None; + let mut members = Vec::new(); + let mut home_channel = None; + let mut related = Vec::new(); + let mut visibility = None; + let mut extensions = Vec::new(); + let mut seen_member_coordinates = HashSet::new(); + + for tag in &tags[1..] { + let tag_name = tag.first().map(String::as_str); + match tag_name { + Some("d" | "auth") => { + return Err(ProjectStateError::State(format!( + "{tag_name:?} is not valid in this projection position" + ))) + } + Some("name") => { + validate_projection_singleton(tag, &name, 256)?; + name = Some(tag.clone()); + } + Some("description") => { + validate_projection_singleton(tag, &description, 2048)?; + description = Some(tag.clone()); + } + Some("a") => { + if !(2..=3).contains(&tag.len()) { + return Err(ProjectStateError::State( + "member a tag has invalid shape".into(), + )); + } + validate_member_coordinate(&tag[1])?; + if !seen_member_coordinates.insert(tag[1].as_str()) { + return Err(ProjectStateError::State( + "member repository coordinate is duplicated".into(), + )); + } + members.push(tag.clone()); + } + Some("buzz-channel") => { + validate_projection_singleton(tag, &home_channel, 256)?; + home_channel = Some(tag.clone()); + } + Some("buzz-related-channel") => { + let canonical_uuid = tag + .get(1) + .and_then(|value| Uuid::parse_str(value).ok()) + .is_some_and(|uuid| uuid.to_string() == tag[1]); + if tag.len() != 2 || !canonical_uuid { + return Err(ProjectStateError::State( + "related-channel tag is not a canonical UUID".into(), + )); + } + related.push(tag.clone()); + } + Some("buzz-visibility") => { + validate_projection_singleton(tag, &visibility, 256)?; + visibility = Some(tag.clone()); + } + _ => extensions.push(tag.clone()), + } + } + if members.len() > MEMBER_CAP || related.len() > MEMBER_CAP { + return Err(ProjectStateError::State( + "projection collection exceeds the 64-member cap".into(), + )); + } + let mut sorted_members = members.clone(); + sorted_members.sort_unstable(); + let mut sorted_related = related.clone(); + sorted_related.sort_unstable(); + sorted_related.dedup(); + if members != sorted_members || related != sorted_related { + return Err(ProjectStateError::State( + "projection collections are not in canonical order".into(), + )); + } + if let Some(home) = home_channel.as_ref().and_then(|tag| tag.get(1)) { + let canonical_home = Uuid::parse_str(home).ok().map(|uuid| uuid.to_string()); + if canonical_home + .as_ref() + .is_some_and(|home| related.iter().any(|tag| tag.get(1) == Some(home))) + { + return Err(ProjectStateError::State( + "home channel cannot also be a related channel".into(), + )); + } + } + + let mut canonical = vec![vec!["d".into(), project_d.into()]]; + canonical.extend(name); + canonical.extend(description); + canonical.extend(members); + canonical.extend(home_channel); + canonical.extend(related); + canonical.extend(visibility); + canonical.extend(extensions); + if canonical != tags { + return Err(ProjectStateError::State( + "Project tags are not in canonical group order".into(), + )); + } + Ok(()) +} + +fn validate_projection_singleton( + tag: &[String], + prior: &Option>, + max_len: usize, +) -> Result<(), ProjectStateError> { + if prior.is_some() || tag.len() != 2 || tag[1].len() > max_len { + return Err(ProjectStateError::State(format!( + "{} tag has invalid shape, length, or cardinality", + tag.first().map_or("metadata", String::as_str) + ))); + } + Ok(()) +} + fn parse_project_coordinate(coordinate: &str) -> Result<(&str, &str), ProjectStateError> { let mut parts = coordinate.splitn(3, ':'); let kind = parts.next(); @@ -330,6 +549,13 @@ mod tests { (keys, event) } + fn signed_projection(relay: &Keys, template: &ProjectStateTemplate) -> Event { + EventBuilder::new(template.kind, &template.content) + .tags(template.tags.clone()) + .sign_with_keys(relay) + .expect("sign test projection") + } + #[test] fn emits_exact_canonical_live_projection() { let repo_b = format!("30617:{}:b", "b".repeat(64)); @@ -493,4 +719,95 @@ mod tests { Err(ProjectStateError::State(_)) )); } + + #[test] + fn validates_canonical_projection_and_rejects_noncanonical_envelope_or_json() { + let (owner, identity) = fixture(vec![tag(&["d", "project"])]); + let relay = Keys::generate(); + let coordinate = format!("30621:{}:project", owner.public_key().to_hex()); + let template = project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 7, + identity_event: &identity, + change_event_id: &identity.id, + deleted: false, + related_channels: &[], + }) + .unwrap(); + let event = signed_projection(&relay, &template); + assert_eq!( + validate_project_state_projection(&event, &relay.public_key(), &coordinate), + Ok(7) + ); + + let mut reordered = template.clone(); + reordered.tags.swap(0, 1); + assert!(validate_project_state_projection( + &signed_projection(&relay, &reordered), + &relay.public_key(), + &coordinate, + ) + .is_err()); + + let mut spaced_json = template; + spaced_json.content.insert(0, ' '); + assert!(validate_project_state_projection( + &signed_projection(&relay, &spaced_json), + &relay.public_key(), + &coordinate, + ) + .is_err()); + } + + #[test] + fn rejects_noncanonical_live_project_tags() { + let (owner, identity) = fixture(vec![tag(&["d", "project"])]); + let relay = Keys::generate(); + let coordinate = format!("30621:{}:project", owner.public_key().to_hex()); + let template = project_state_template(ProjectStateProjectionInput { + coordinate: &coordinate, + revision: 1, + identity_event: &identity, + change_event_id: &identity.id, + deleted: false, + related_channels: &[], + }) + .unwrap(); + let invalid_tag_sets = [ + vec![vec!["d".into(), "other".into()]], + vec![ + vec!["d".into(), "project".into()], + vec!["d".into(), "project".into()], + ], + vec![ + vec!["d".into(), "project".into()], + vec!["buzz-related-channel".into(), "not-a-uuid".into()], + ], + vec![ + vec!["d".into(), "project".into()], + vec!["name".into(), "Name".into(), "extra".into()], + ], + vec![ + vec!["d".into(), "project".into()], + vec!["description".into(), "Desc".into()], + vec!["name".into(), "Name".into()], + ], + ]; + + for project_tags in invalid_tag_sets { + let mut invalid = template.clone(); + invalid.content = serde_json::to_string(&ProjectionBody { + v: 1, + deleted: false, + project_tags, + }) + .unwrap(); + assert!(validate_project_state_projection( + &signed_projection(&relay, &invalid), + &relay.public_key(), + &coordinate, + ) + .is_err()); + } + } }