mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Add collaborative Project state CLI
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -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<String, CliError> {
|
||||
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<String, CliError> {
|
||||
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<String, CliError> {
|
||||
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::Value> =
|
||||
serde_json::from_str(&normalized).map_err(|error| {
|
||||
CliError::Other(format!("Project State encoding failed: {error}"))
|
||||
})?;
|
||||
let compact: Vec<serde_json::Value> = 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<String, CliError>) -> Result<String, CliError> {
|
||||
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::Value> = 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::Value> = 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<u16>) {
|
||||
|
||||
@@ -1316,6 +1316,15 @@ pub enum ProjectsCmd {
|
||||
#[arg(long)]
|
||||
owner: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// List projects
|
||||
List {
|
||||
/// Owner pubkey (64-char hex). Defaults to the current identity.
|
||||
@@ -1356,6 +1365,25 @@ pub enum ProjectsCmd {
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
/// Related channel UUID to add (repeatable)
|
||||
#[arg(long = "add", group = "related-channel-mutation")]
|
||||
add: Vec<Uuid>,
|
||||
/// Related channel UUID to remove (repeatable)
|
||||
#[arg(long = "remove", group = "related-channel-mutation")]
|
||||
remove: Vec<Uuid>,
|
||||
},
|
||||
/// 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() {
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<u64, ProjectStateError> {
|
||||
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::<u64>()
|
||||
.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::<i64>().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<String>],
|
||||
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<Vec<String>>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user