feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "buzz-sdk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Typed Nostr event builders for Buzz operations"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Compute a NIP-OA auth tag for an agent keypair.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example compute_auth_tag -- <owner_secret_hex> <agent_pubkey_hex> [conditions]
|
||||
//!
|
||||
//! Prints the JSON auth tag to stdout.
|
||||
|
||||
use buzz_sdk::nip_oa;
|
||||
use nostr::{Keys, PublicKey};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!(
|
||||
"Usage: {} <owner_secret_hex> <agent_pubkey_hex> [conditions]",
|
||||
args[0]
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let owner_keys = Keys::parse(&args[1]).expect("invalid owner secret key");
|
||||
let agent_pubkey = PublicKey::from_hex(&args[2]).expect("invalid agent pubkey hex");
|
||||
let conditions = args.get(3).map(|s| s.as_str()).unwrap_or("");
|
||||
|
||||
let tag_json = nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions)
|
||||
.expect("failed to compute auth tag");
|
||||
|
||||
println!("{tag_json}");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! `buzz-sdk` — typed Nostr event builders for Buzz operations.
|
||||
//!
|
||||
//! # Mental Model
|
||||
//!
|
||||
//! ```text
|
||||
//! caller params → builder fn → validates → EventBuilder → caller signs → Event
|
||||
//! ```
|
||||
//!
|
||||
//! Each builder function validates its inputs and returns an [`nostr::EventBuilder`].
|
||||
//! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`.
|
||||
//! No keys are held here. No network calls are made.
|
||||
|
||||
pub mod builders;
|
||||
pub mod mentions;
|
||||
pub mod nip_oa;
|
||||
|
||||
pub use builders::*;
|
||||
|
||||
/// Re-export kind constants so consumers don't need buzz-core directly.
|
||||
pub use buzz_core::kind;
|
||||
|
||||
/// Thread reference for reply builders (NIP-10 markers).
|
||||
///
|
||||
/// - Direct reply (root == parent): emits `["e", root, "", "reply"]`
|
||||
/// - Nested reply (root ≠ parent): emits `["e", root, "", "root"]` + `["e", parent, "", "reply"]`
|
||||
pub struct ThreadRef {
|
||||
/// The root event of the thread.
|
||||
pub root_event_id: nostr::EventId,
|
||||
/// The immediate parent being replied to.
|
||||
pub parent_event_id: nostr::EventId,
|
||||
}
|
||||
|
||||
/// Metadata for diff/patch messages (kind 40008).
|
||||
pub struct DiffMeta {
|
||||
/// Repository URL — required, must start with `http://` or `https://`.
|
||||
pub repo_url: String,
|
||||
/// Commit SHA — required, minimum 7 hex characters.
|
||||
pub commit_sha: String,
|
||||
/// Optional file path within the repository.
|
||||
pub file_path: Option<String>,
|
||||
/// Optional parent commit SHA — minimum 7 hex chars if present.
|
||||
pub parent_commit: Option<String>,
|
||||
/// Optional branch pair `(source, target)` — both or neither.
|
||||
pub branch: Option<(String, String)>,
|
||||
/// Optional pull request number — must be positive.
|
||||
pub pr_number: Option<u32>,
|
||||
/// Optional programming language identifier.
|
||||
pub language: Option<String>,
|
||||
/// Optional human-readable description.
|
||||
pub description: Option<String>,
|
||||
/// Whether the diff was truncated due to size.
|
||||
pub truncated: bool,
|
||||
/// Optional alt text for accessibility.
|
||||
pub alt_text: Option<String>,
|
||||
}
|
||||
|
||||
/// Vote direction for `build_vote`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VoteDirection {
|
||||
/// Upvote — content `"+"`.
|
||||
Up,
|
||||
/// Downvote — content `"-"`.
|
||||
Down,
|
||||
}
|
||||
|
||||
/// A NIP-30 custom emoji tag payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomEmoji {
|
||||
/// The shortcode without surrounding colons.
|
||||
pub shortcode: String,
|
||||
/// Image URL for this custom emoji.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Return a channel name without client-rendered leading hash prefixes.
|
||||
pub use buzz_core::channel::canonical_channel_name;
|
||||
/// Channel type.
|
||||
pub use buzz_core::channel::ChannelType as ChannelKind;
|
||||
/// Channel visibility.
|
||||
pub use buzz_core::channel::ChannelVisibility as Visibility;
|
||||
/// Member role.
|
||||
pub use buzz_core::channel::MemberRole;
|
||||
|
||||
/// Errors returned by SDK builder functions.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SdkError {
|
||||
/// Content exceeds the maximum allowed size.
|
||||
#[error("content exceeds maximum size of {max} bytes (got {got})")]
|
||||
ContentTooLarge {
|
||||
/// Maximum allowed bytes.
|
||||
max: usize,
|
||||
/// Actual byte count.
|
||||
got: usize,
|
||||
},
|
||||
/// A tag could not be constructed.
|
||||
#[error("invalid tag: {0}")]
|
||||
InvalidTag(String),
|
||||
/// Emoji string exceeds 64 characters.
|
||||
#[error("emoji exceeds maximum length of 64 characters")]
|
||||
EmojiTooLong,
|
||||
/// More than 50 mentions were supplied.
|
||||
#[error("too many mentions (max 50)")]
|
||||
TooManyMentions,
|
||||
/// Diff metadata failed validation.
|
||||
#[error("invalid diff metadata: {0}")]
|
||||
InvalidDiffMeta(String),
|
||||
/// Input failed validation (e.g. malformed pubkey).
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
//! `@name` and NIP-27 `nostr:npub1…` mention resolution helpers for Buzz chat messages.
|
||||
//!
|
||||
//! These helpers are **pure** — no network calls, no async. Callers query
|
||||
//! channel membership (kind 39002) and profile (kind 0) events themselves,
|
||||
//! then hand the profile JSON to [`match_names_to_profiles`].
|
||||
//!
|
||||
//! ## Pipeline
|
||||
//!
|
||||
//! ```text
|
||||
//! body text ──► extract_at_names ──► names: Vec<String>
|
||||
//! │
|
||||
//! members + profiles (queried by caller) │
|
||||
//! ▼
|
||||
//! match_names_to_profiles ──► pubkeys
|
||||
//! │
|
||||
//! body text ──► strip_code_regions ──► extract_nostr_uris ─┤
|
||||
//! ▼
|
||||
//! explicit mentions ──► normalize ──► merge_mentions ──► p-tags
|
||||
//! ```
|
||||
//!
|
||||
//! When the set of known member names is available upfront,
|
||||
//! [`extract_at_mentions_with_known`] replaces the first step to correctly
|
||||
//! handle multi-word display names.
|
||||
//!
|
||||
//! [`extract_nostr_uris`] handles NIP-27 inline `nostr:npub1…` references,
|
||||
//! skipping those inside code blocks/spans via [`strip_code_regions`].
|
||||
//!
|
||||
//! See [`crate::mentions::MENTION_CAP`] for the hard upper bound on tags.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::{FromBech32, PublicKey};
|
||||
|
||||
/// Maximum number of mention p-tags allowed on a single message.
|
||||
///
|
||||
/// Matches the cap enforced by Buzz message builders and the legacy MCP
|
||||
/// inline implementation.
|
||||
pub const MENTION_CAP: usize = 50;
|
||||
|
||||
/// A channel-member profile, as needed for name matching.
|
||||
///
|
||||
/// `pubkey` is the lowercase hex public key. `content_json` is the raw
|
||||
/// kind 0 event content (a JSON object). Borrowing the content avoids
|
||||
/// cloning what can be a sizable string.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MentionProfile<'a> {
|
||||
/// Lowercase hex public key.
|
||||
pub pubkey: &'a str,
|
||||
/// Raw kind 0 event `content` field (a JSON object).
|
||||
pub content_json: &'a str,
|
||||
}
|
||||
|
||||
/// Extract single-word `@mention` names from message content.
|
||||
///
|
||||
/// Prefer [`extract_at_mentions_with_known`] when known member names are
|
||||
/// available — it correctly handles multi-word display names.
|
||||
///
|
||||
/// Returns lowercased names found after `@` tokens. An `@name` only matches
|
||||
/// when the `@` is at start-of-string or preceded by an ASCII whitespace
|
||||
/// character — this excludes things like email addresses (`user@host`).
|
||||
///
|
||||
/// Allowed name characters: ASCII alphanumerics, `.`, `-`, `_`.
|
||||
/// Duplicates are removed; first-seen order is preserved.
|
||||
pub fn extract_at_names(content: &str) -> Vec<String> {
|
||||
if content.is_empty() || !content.contains('@') {
|
||||
return vec![];
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let chars: Vec<char> = content.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if chars[i] == '@' {
|
||||
let preceded_by_ws = i == 0 || chars[i - 1].is_ascii_whitespace();
|
||||
if preceded_by_ws && i + 1 < len {
|
||||
let start = i + 1;
|
||||
let mut end = start;
|
||||
while end < len {
|
||||
let c = chars[end];
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > start {
|
||||
let name: String = chars[start..end].iter().collect();
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if seen.insert(lower.clone()) {
|
||||
names.push(lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Extract `@mention` names from message content using known member names.
|
||||
///
|
||||
/// At each `@` preceded by whitespace or start-of-string, tries known names
|
||||
/// longest-first (case-insensitive, word-boundary-checked), then falls back
|
||||
/// to single-word tokenization. Returns lowercased names in first-seen order,
|
||||
/// deduplicated. Empty/whitespace-only entries in `known_names` are ignored.
|
||||
pub fn extract_at_mentions_with_known(content: &str, known_names: &[&str]) -> Vec<String> {
|
||||
if content.is_empty() || !content.contains('@') {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&str> = known_names
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.collect();
|
||||
sorted.sort_by_key(|k| std::cmp::Reverse(k.len()));
|
||||
|
||||
let mut names = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for (i, _) in content.match_indices('@') {
|
||||
let preceded = i == 0 || content.as_bytes()[i - 1].is_ascii_whitespace();
|
||||
if !preceded {
|
||||
continue;
|
||||
}
|
||||
let rest = &content[i + 1..];
|
||||
if rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lower = if let Some(&known) = sorted.iter().find(|&&k| {
|
||||
rest.get(..k.len())
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(k) && is_word_boundary(&rest[k.len()..]))
|
||||
}) {
|
||||
known.to_ascii_lowercase()
|
||||
} else {
|
||||
let end = rest
|
||||
.find(|c: char| !c.is_ascii_alphanumeric() && !matches!(c, '.' | '-' | '_'))
|
||||
.unwrap_or(rest.len());
|
||||
if end == 0 {
|
||||
continue;
|
||||
}
|
||||
rest[..end].to_ascii_lowercase()
|
||||
};
|
||||
|
||||
if seen.insert(lower.clone()) {
|
||||
names.push(lower);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn is_word_boundary(s: &str) -> bool {
|
||||
s.chars().next().is_none_or(|c| {
|
||||
c.is_ascii_whitespace() || matches!(c, ',' | ';' | '.' | '!' | '?' | ':' | ')' | ']' | '}')
|
||||
})
|
||||
}
|
||||
|
||||
/// Match extracted `@names` against channel-member profiles.
|
||||
///
|
||||
/// For each profile, parses its `content_json` and reads the
|
||||
/// `display_name` field (falling back to `name` **only if `display_name`
|
||||
/// is absent**, preserving the legacy MCP behavior). If the resulting
|
||||
/// name matches any extracted `@name` case-insensitively, the profile's
|
||||
/// pubkey is included.
|
||||
///
|
||||
/// Output order is **profile-input order**, not name-input order. When
|
||||
/// the [`MENTION_CAP`] is later applied during merging, this means the
|
||||
/// matched-pubkey set is stable with respect to query result ordering
|
||||
/// rather than text-position ordering.
|
||||
///
|
||||
/// Profiles whose `content_json` does not parse, or whose `display_name`
|
||||
/// (and `name`) are absent or non-string, are silently skipped.
|
||||
///
|
||||
/// Duplicate display names within a channel will produce multiple matches
|
||||
/// for a single `@name` — this is by design; resolution is bounded to
|
||||
/// channel members, so ambiguity is local to that channel.
|
||||
pub fn match_names_to_profiles(names: &[String], profiles: &[MentionProfile<'_>]) -> Vec<String> {
|
||||
if names.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in profiles {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(p.content_json) else {
|
||||
continue;
|
||||
};
|
||||
let name = value
|
||||
.get("display_name")
|
||||
.or_else(|| value.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if names.iter().any(|n| n.eq_ignore_ascii_case(name)) {
|
||||
out.push(p.pubkey.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Merge auto-resolved pubkeys into an explicit mention list, up to `cap`.
|
||||
///
|
||||
/// Explicit mentions have priority; auto-resolved entries are appended
|
||||
/// only if not already present (case-sensitive contains check — callers
|
||||
/// should normalize beforehand). Stops adding once `cap` is reached.
|
||||
pub fn merge_mentions(explicit: &mut Vec<String>, auto_resolved: &[String], cap: usize) {
|
||||
let budget = cap.saturating_sub(explicit.len());
|
||||
let mut added = 0usize;
|
||||
for pk in auto_resolved {
|
||||
if added >= budget {
|
||||
break;
|
||||
}
|
||||
if !explicit.contains(pk) {
|
||||
explicit.push(pk.clone());
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a list of mention pubkeys.
|
||||
///
|
||||
/// - Lowercases every entry.
|
||||
/// - Removes duplicates, preserving first-seen order.
|
||||
/// - When `sender_pubkey` is `Some(pk)`, removes any case-insensitive match
|
||||
/// against the sender's own pubkey (you don't @mention yourself).
|
||||
pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: Option<&str>) -> Vec<String> {
|
||||
let sender = sender_pubkey.map(|s| s.to_ascii_lowercase());
|
||||
let mut seen = HashSet::new();
|
||||
pubkeys
|
||||
.iter()
|
||||
.map(|pk| pk.to_ascii_lowercase())
|
||||
.filter(|pk| sender.as_deref() != Some(pk.as_str()))
|
||||
.filter(|pk| seen.insert(pk.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove fenced code blocks and inline code spans from content.
|
||||
///
|
||||
/// Returns a copy of `content` with ` ```…``` ` blocks and `` `…` `` spans
|
||||
/// replaced by spaces. Used only for mention scanning — the original
|
||||
/// content is stored verbatim. Preserves valid UTF-8 throughout.
|
||||
pub fn strip_code_regions(content: &str) -> String {
|
||||
let mut out = String::with_capacity(content.len());
|
||||
let mut chars = content.char_indices().peekable();
|
||||
|
||||
while let Some(&(i, ch)) = chars.peek() {
|
||||
// Fenced code block: ``` at line start (possibly after whitespace)
|
||||
if ch == '`' && content[i..].starts_with("```") {
|
||||
let is_fence_start = if i == 0 {
|
||||
true
|
||||
} else {
|
||||
let before = &content[..i];
|
||||
before.ends_with('\n')
|
||||
|| before.chars().all(|c| c.is_ascii_whitespace())
|
||||
|| before.rsplit_once('\n').is_some_and(|(_, after_nl)| {
|
||||
after_nl.chars().all(|c| c.is_ascii_whitespace())
|
||||
})
|
||||
};
|
||||
|
||||
if is_fence_start {
|
||||
// Find end of opening fence line
|
||||
let after_fence = i + 3;
|
||||
let rest = &content[after_fence..];
|
||||
let line_end = rest
|
||||
.find('\n')
|
||||
.map_or(content.len(), |p| after_fence + p + 1);
|
||||
|
||||
// Find closing fence
|
||||
let mut search_from = line_end;
|
||||
let close_end = loop {
|
||||
if search_from >= content.len() {
|
||||
break content.len();
|
||||
}
|
||||
if let Some(pos) = content[search_from..].find("```") {
|
||||
let abs_pos = search_from + pos;
|
||||
let at_line_start = abs_pos == 0
|
||||
|| content.as_bytes()[abs_pos - 1] == b'\n'
|
||||
|| content[..abs_pos]
|
||||
.rsplit_once('\n')
|
||||
.is_some_and(|(_, after_nl)| {
|
||||
after_nl.chars().all(|c| c.is_ascii_whitespace())
|
||||
});
|
||||
if at_line_start {
|
||||
// Skip to end of closing fence line
|
||||
let after_close = abs_pos + 3;
|
||||
let end = content[after_close..]
|
||||
.find('\n')
|
||||
.map_or(content.len(), |p| after_close + p + 1);
|
||||
break end;
|
||||
}
|
||||
search_from = abs_pos + 3;
|
||||
} else {
|
||||
break content.len();
|
||||
}
|
||||
};
|
||||
|
||||
out.push(' ');
|
||||
// Advance chars iterator past the fenced block
|
||||
while let Some(&(ci, _)) = chars.peek() {
|
||||
if ci >= close_end {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code span: `…`
|
||||
if ch == '`' {
|
||||
let after_tick = i + 1;
|
||||
if after_tick < content.len() {
|
||||
// Find closing backtick on same line
|
||||
if let Some(rel_end) = content[after_tick..].find('`') {
|
||||
let close_pos = after_tick + rel_end;
|
||||
// Only treat as code span if no newline between the backticks
|
||||
if !content[after_tick..close_pos].contains('\n') {
|
||||
out.push(' ');
|
||||
// Advance past closing backtick
|
||||
while let Some(&(ci, _)) = chars.peek() {
|
||||
if ci > close_pos {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Bech32 alphabet used by NIP-19.
|
||||
// NIP-19 allows uppercase; normalize before decode
|
||||
fn is_bech32_char(c: char) -> bool {
|
||||
matches!(c, '0'..='9' | 'a'..='z' | 'A'..='Z')
|
||||
}
|
||||
|
||||
/// Extract pubkeys from NIP-27 `nostr:npub1…` URIs in content.
|
||||
///
|
||||
/// Scans `content` (which should already have code regions stripped via
|
||||
/// [`strip_code_regions`]) for `nostr:npub1` followed by 58 bech32 characters.
|
||||
/// Decodes each to a 32-byte pubkey hex string. Invalid bech32 is silently
|
||||
/// skipped. Returns deduplicated lowercase hex pubkeys.
|
||||
pub fn extract_nostr_uris(content: &str) -> Vec<String> {
|
||||
const PREFIX: &str = "nostr:npub1";
|
||||
const BECH32_SUFFIX_LEN: usize = 58; // chars after "npub1"
|
||||
|
||||
let mut pubkeys = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for (start, _) in content.match_indices(PREFIX) {
|
||||
let bech32_start = start + "nostr:".len();
|
||||
let bech32_end = bech32_start + 5 + BECH32_SUFFIX_LEN; // "npub1" + 58
|
||||
|
||||
// The fixed-width window can land mid-character when multi-byte UTF-8
|
||||
// follows the prefix; slicing a non-boundary would panic. A real bech32
|
||||
// suffix is 58 ASCII bytes, so any non-boundary here is a non-match.
|
||||
if bech32_end > content.len() || !content.is_char_boundary(bech32_end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = &content[bech32_start..bech32_end];
|
||||
if !candidate.chars().all(is_bech32_char) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// NIP-19 allows uppercase; normalize before decode
|
||||
let normalized = candidate.to_ascii_lowercase();
|
||||
if let Ok(pk) = PublicKey::from_bech32(&normalized) {
|
||||
let hex = pk.to_hex();
|
||||
if seen.insert(hex.clone()) {
|
||||
pubkeys.push(hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pubkeys
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_matches_basic() {
|
||||
assert_eq!(extract_at_names("hello @alice"), vec!["alice"]);
|
||||
assert_eq!(extract_at_names("@bob hello"), vec!["bob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_lowercases_and_dedups() {
|
||||
assert_eq!(
|
||||
extract_at_names("@Alice and @alice, meet @Bob"),
|
||||
vec!["alice", "bob"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_allows_newline_prefix() {
|
||||
assert_eq!(extract_at_names("line1\n@tyler line2"), vec!["tyler"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_allows_punctuation_in_names() {
|
||||
assert_eq!(
|
||||
extract_at_names("@john.doe @mary_jane @bob-smith"),
|
||||
vec!["john.doe", "mary_jane", "bob-smith"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_at_names_rejects_email_and_empty() {
|
||||
assert!(extract_at_names("").is_empty());
|
||||
assert!(extract_at_names("no mentions").is_empty());
|
||||
assert!(extract_at_names("user@example.com").is_empty());
|
||||
assert!(extract_at_names("hello @ world").is_empty());
|
||||
assert!(extract_at_names("hello @").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_multiword_name_matches_fully() {
|
||||
// "Will Pfleger" should match @Will Pfleger, not just @Will.
|
||||
let result = extract_at_mentions_with_known("hello @Will Pfleger!", &["Will Pfleger"]);
|
||||
assert_eq!(result, vec!["will pfleger"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_first_word_does_not_match_multiword_name() {
|
||||
// @Will alone must NOT match "Will Pfleger" — partial matches are rejected.
|
||||
let result = extract_at_mentions_with_known("hey @Will how are you", &["Will Pfleger"]);
|
||||
// No known name matches @Will (boundary check: 'Will' is followed by ' h'
|
||||
// which would match "Will Pfleger" only if the full name follows).
|
||||
// Falls back to single-word tokenizer → emits "will".
|
||||
assert_eq!(result, vec!["will"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_first_wins_over_prefix() {
|
||||
// With both "Will" and "Will Pfleger" known, "@Will Pfleger" should
|
||||
// match the longer name, not just "Will".
|
||||
let result = extract_at_mentions_with_known(
|
||||
"@Will Pfleger sent a message",
|
||||
&["Will", "Will Pfleger"],
|
||||
);
|
||||
assert_eq!(result, vec!["will pfleger"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_known_name_matches() {
|
||||
let result = extract_at_mentions_with_known("ping @alice please", &["Alice"]);
|
||||
assert_eq!(result, vec!["alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_name_falls_back_to_single_word() {
|
||||
// @alice is not in known_names but single-word fallback still emits it.
|
||||
let result = extract_at_mentions_with_known("hey @alice", &["Bob"]);
|
||||
assert_eq!(result, vec!["alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_mentions_mixed_known_and_unknown() {
|
||||
let result = extract_at_mentions_with_known(
|
||||
"@Will Pfleger and @alice should review",
|
||||
&["Will Pfleger"],
|
||||
);
|
||||
assert_eq!(result, vec!["will pfleger", "alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_case_insensitively() {
|
||||
let result = extract_at_mentions_with_known(
|
||||
"@Will Pfleger and @will pfleger again",
|
||||
&["Will Pfleger"],
|
||||
);
|
||||
assert_eq!(result, vec!["will pfleger"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiword_name_at_end_of_string() {
|
||||
let result = extract_at_mentions_with_known("cc @Will Pfleger", &["Will Pfleger"]);
|
||||
assert_eq!(result, vec!["will pfleger"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiword_name_followed_by_punctuation() {
|
||||
let result =
|
||||
extract_at_mentions_with_known("thanks @Will Pfleger, great work", &["Will Pfleger"]);
|
||||
assert_eq!(result, vec!["will pfleger"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_address_not_matched() {
|
||||
let result = extract_at_mentions_with_known("user@example.com", &["example.com"]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_returns_empty() {
|
||||
let result = extract_at_mentions_with_known("", &["Alice"]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_known_names_uses_single_word_fallback() {
|
||||
let result = extract_at_mentions_with_known("hey @alice", &[]);
|
||||
assert_eq!(result, vec!["alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_content_does_not_panic() {
|
||||
// Known name byte-length may land mid-character in multi-byte content.
|
||||
// e.g. known "ab" (2 bytes) vs content starting with 日 (3 bytes) —
|
||||
// byte offset 2 is not a char boundary. Must not panic; gracefully
|
||||
// skips the candidate via get() returning None.
|
||||
let result = extract_at_mentions_with_known("@日本語 hello", &["ab"]);
|
||||
// "ab" doesn't match — falls through to single-word fallback which
|
||||
// stops at non-ASCII, so no match. The key assertion: no panic.
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_known_name_matches_with_boundary() {
|
||||
// Multi-byte known name followed by a space (valid boundary).
|
||||
let result = extract_at_mentions_with_known("@日本 hello", &["日本"]);
|
||||
assert_eq!(result, vec!["日本"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_known_name_with_ascii_content_no_panic() {
|
||||
// Reverse case: multi-byte known name against ASCII content.
|
||||
let result = extract_at_mentions_with_known("@alice hello", &["日本語"]);
|
||||
assert_eq!(result, vec!["alice"]);
|
||||
}
|
||||
|
||||
fn profile<'a>(pk: &'a str, json: &'a str) -> MentionProfile<'a> {
|
||||
MentionProfile {
|
||||
pubkey: pk,
|
||||
content_json: json,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_uses_display_name_case_insensitive() {
|
||||
let names = vec!["alice".to_string()];
|
||||
let profiles = vec![profile("pk1", r#"{"display_name":"Alice"}"#)];
|
||||
assert_eq!(match_names_to_profiles(&names, &profiles), vec!["pk1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_falls_back_to_name_only_if_display_name_absent() {
|
||||
let names = vec!["bob".to_string()];
|
||||
// display_name present but empty → skipped (no fallback to `name`).
|
||||
let p1 = profile("pk1", r#"{"display_name":"","name":"Bob"}"#);
|
||||
// display_name absent → falls back to `name`.
|
||||
let p2 = profile("pk2", r#"{"name":"Bob"}"#);
|
||||
let out = match_names_to_profiles(&names, &[p1, p2]);
|
||||
assert_eq!(out, vec!["pk2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_preserves_profile_input_order() {
|
||||
let names = vec!["alice".to_string(), "bob".to_string()];
|
||||
let profiles = vec![
|
||||
profile("pkB", r#"{"display_name":"Bob"}"#),
|
||||
profile("pkA", r#"{"display_name":"Alice"}"#),
|
||||
];
|
||||
// Output order tracks the profile slice, not the name slice.
|
||||
assert_eq!(
|
||||
match_names_to_profiles(&names, &profiles),
|
||||
vec!["pkB", "pkA"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_all_pubkeys_for_duplicate_display_names() {
|
||||
// Ambiguity is intentional and bounded to channel members.
|
||||
let names = vec!["alice".to_string()];
|
||||
let profiles = vec![
|
||||
profile("pk1", r#"{"display_name":"Alice"}"#),
|
||||
profile("pk2", r#"{"display_name":"alice"}"#),
|
||||
];
|
||||
assert_eq!(
|
||||
match_names_to_profiles(&names, &profiles),
|
||||
vec!["pk1", "pk2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_skips_unparseable_and_missing_fields() {
|
||||
let names = vec!["alice".to_string()];
|
||||
let profiles = vec![
|
||||
profile("pk1", "not json"),
|
||||
profile("pk2", "{}"),
|
||||
profile("pk3", r#"{"display_name":42}"#),
|
||||
profile("pk4", r#"{"display_name":"Alice"}"#),
|
||||
];
|
||||
assert_eq!(match_names_to_profiles(&names, &profiles), vec!["pk4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_empty_names_returns_empty() {
|
||||
let profiles = vec![profile("pk1", r#"{"display_name":"Alice"}"#)];
|
||||
assert!(match_names_to_profiles(&[], &profiles).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_new_and_skips_dupes() {
|
||||
let mut m = vec!["a".to_string()];
|
||||
merge_mentions(&mut m, &["a".into(), "b".into()], MENTION_CAP);
|
||||
assert_eq!(m, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_respects_cap() {
|
||||
let mut m: Vec<String> = (0..49).map(|i| format!("pk{i}")).collect();
|
||||
merge_mentions(&mut m, &["x".into(), "y".into()], MENTION_CAP);
|
||||
assert_eq!(m.len(), MENTION_CAP);
|
||||
assert_eq!(m.last().unwrap(), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_noop_when_explicit_at_cap() {
|
||||
let mut m: Vec<String> = (0..MENTION_CAP).map(|i| format!("pk{i}")).collect();
|
||||
merge_mentions(&mut m, &["extra".into()], MENTION_CAP);
|
||||
assert_eq!(m.len(), MENTION_CAP);
|
||||
assert!(!m.contains(&"extra".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_lowercases_and_dedups() {
|
||||
let pks = vec!["ABC".to_string(), "abc".to_string(), "DEF".to_string()];
|
||||
assert_eq!(normalize_mention_pubkeys(&pks, None), vec!["abc", "def"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_removes_sender_case_insensitive() {
|
||||
let pks = vec!["ABC".to_string(), "DEF".to_string()];
|
||||
assert_eq!(normalize_mention_pubkeys(&pks, Some("abc")), vec!["def"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_with_none_sender_keeps_everything() {
|
||||
let pks = vec!["abc".to_string()];
|
||||
assert_eq!(normalize_mention_pubkeys(&pks, None), vec!["abc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_empty_input() {
|
||||
assert!(normalize_mention_pubkeys(&[], Some("anything")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_removes_fenced_block() {
|
||||
let input = "before\n```rust\nlet x = 1;\n```\nafter";
|
||||
let stripped = strip_code_regions(input);
|
||||
assert!(!stripped.contains("let x = 1"));
|
||||
assert!(stripped.contains("before"));
|
||||
assert!(stripped.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_removes_inline_code() {
|
||||
let input =
|
||||
"see `nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg` here";
|
||||
let stripped = strip_code_regions(input);
|
||||
assert!(!stripped.contains("npub1"));
|
||||
assert!(stripped.contains("see"));
|
||||
assert!(stripped.contains("here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_preserves_prose() {
|
||||
let input =
|
||||
"hello nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg world";
|
||||
let stripped = strip_code_regions(input);
|
||||
assert!(stripped.contains("nostr:npub1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_handles_empty() {
|
||||
assert_eq!(strip_code_regions(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_unclosed_backtick_preserved() {
|
||||
// A lone backtick without a closing one is not a code span
|
||||
let input = "hello `world";
|
||||
let stripped = strip_code_regions(input);
|
||||
assert!(stripped.contains("world"));
|
||||
}
|
||||
|
||||
const TEST_NPUB1: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
|
||||
const TEST_HEX1: &str = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e";
|
||||
const TEST_NPUB2: &str = "npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw";
|
||||
const TEST_HEX2: &str = "4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca";
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_valid_in_prose() {
|
||||
let content = format!("hello nostr:{} world", TEST_NPUB1);
|
||||
let result = extract_nostr_uris(&content);
|
||||
assert_eq!(result, vec![TEST_HEX1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_not_extracted_in_backticks() {
|
||||
let content = format!("see `nostr:{}` here", TEST_NPUB1);
|
||||
let stripped = strip_code_regions(&content);
|
||||
let result = extract_nostr_uris(&stripped);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_not_extracted_in_fenced_code() {
|
||||
let content = format!("before\n```\nnostr:{}\n```\nafter", TEST_NPUB1);
|
||||
let stripped = strip_code_regions(&content);
|
||||
let result = extract_nostr_uris(&stripped);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_invalid_bech32_skipped() {
|
||||
// Corrupt the last few chars to make invalid bech32
|
||||
let invalid = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjaaaa";
|
||||
let content = format!("nostr:{}", invalid);
|
||||
let result = extract_nostr_uris(&content);
|
||||
// Should not panic, just skip
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_deduplicates() {
|
||||
let content = format!("nostr:{} and again nostr:{}", TEST_NPUB1, TEST_NPUB1);
|
||||
let result = extract_nostr_uris(&content);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], TEST_HEX1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_multiple_different() {
|
||||
let content = format!("nostr:{} and nostr:{}", TEST_NPUB1, TEST_NPUB2);
|
||||
let result = extract_nostr_uris(&content);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.contains(&TEST_HEX1.to_string()));
|
||||
assert!(result.contains(&TEST_HEX2.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_at_name_and_npub_dedup() {
|
||||
// Simulates the integration: @name resolves to same pubkey as nostr:npub
|
||||
// The dedup happens at the merge_mentions level, but extract_nostr_uris
|
||||
// itself deduplicates within its own output.
|
||||
let content = format!("nostr:{}", TEST_NPUB1);
|
||||
let uri_pubkeys = extract_nostr_uris(&content);
|
||||
let name_pubkeys = vec![TEST_HEX1.to_string()];
|
||||
|
||||
// merge_mentions deduplicates
|
||||
let mut merged = name_pubkeys;
|
||||
merge_mentions(&mut merged, &uri_pubkeys, MENTION_CAP);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0], TEST_HEX1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_empty_content() {
|
||||
assert!(extract_nostr_uris("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_no_prefix() {
|
||||
// npub without "nostr:" prefix should not match
|
||||
let content = format!("just {} in text", TEST_NPUB1);
|
||||
let result = extract_nostr_uris(&content);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_after_unicode_does_not_panic() {
|
||||
// Multi-byte UTF-8 before a nostr: URI must not cause panics
|
||||
let content = format!("こんにちは nostr:{}", TEST_NPUB1);
|
||||
let result = extract_nostr_uris(&content);
|
||||
assert_eq!(result, vec![TEST_HEX1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_multibyte_inside_window_does_not_panic() {
|
||||
// Multi-byte UTF-8 within the fixed 58-char suffix window would make
|
||||
// bech32_end land mid-character; the boundary guard must skip it.
|
||||
let content = format!("nostr:npub1{}", "あ".repeat(20));
|
||||
assert!(extract_nostr_uris(&content).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_code_regions_preserves_unicode() {
|
||||
let input = "こんにちは `code` 世界";
|
||||
let stripped = strip_code_regions(input);
|
||||
assert!(stripped.contains("こんにちは"));
|
||||
assert!(stripped.contains("世界"));
|
||||
assert!(!stripped.contains("code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_nostr_uris_uppercase_bech32_chars() {
|
||||
// NIP-19 allows uppercase bech32 characters in the suffix
|
||||
let upper_suffix = &TEST_NPUB1[5..].to_uppercase(); // uppercase the 58 chars after "npub1"
|
||||
let npub_mixed = format!("npub1{}", upper_suffix);
|
||||
let content = format!("nostr:{}", npub_mixed);
|
||||
let result = extract_nostr_uris(&content);
|
||||
assert_eq!(result, vec![TEST_HEX1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
//! NIP-OA — Owner Attestation
|
||||
//!
|
||||
//! Computes and verifies `auth` tags that prove an owner key authorized
|
||||
//! an agent key to publish events under the agent's own authorship.
|
||||
//!
|
||||
//! # Tag format
|
||||
//!
|
||||
//! ```json
|
||||
//! ["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
|
||||
//! ```
|
||||
//!
|
||||
//! # Signing preimage
|
||||
//!
|
||||
//! ```text
|
||||
//! preimage = "nostr:agent-auth:" || agent_pubkey_hex || ":" || conditions
|
||||
//! message = SHA256(preimage)
|
||||
//! sig = BIP-340 Schnorr(message, owner_secret_key)
|
||||
//! ```
|
||||
|
||||
use core::str::FromStr;
|
||||
|
||||
use nostr::hashes::sha256::Hash as Sha256Hash;
|
||||
use nostr::hashes::Hash;
|
||||
use nostr::secp256k1::schnorr::Signature;
|
||||
use nostr::secp256k1::Message;
|
||||
use nostr::{Keys, PublicKey, Tag, SECP256K1};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::SdkError;
|
||||
|
||||
/// Validate the `conditions` string per the NIP-OA spec.
|
||||
///
|
||||
/// Empty string is valid. Non-empty must be `clause` or `clause&clause&...`
|
||||
/// where each clause is `kind=<0-65535>`, `created_at<<0-4294967295>`, or
|
||||
/// `created_at><0-4294967295>`. Canonical decimals only (no leading zeros).
|
||||
fn validate_conditions(conditions: &str) -> Result<(), SdkError> {
|
||||
if conditions.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No whitespace anywhere
|
||||
if conditions.bytes().any(|b| b.is_ascii_whitespace()) {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"conditions must not contain whitespace".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Split on '&' — each part must be non-empty and a valid clause
|
||||
for clause in conditions.split('&') {
|
||||
if clause.is_empty() {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"empty clause in conditions (leading/trailing/double '&')".into(),
|
||||
));
|
||||
}
|
||||
validate_clause(clause)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_clause(clause: &str) -> Result<(), SdkError> {
|
||||
if let Some(value) = clause.strip_prefix("kind=") {
|
||||
validate_canonical_decimal(value, 0, 65535, "kind")
|
||||
} else if let Some(value) = clause.strip_prefix("created_at<") {
|
||||
validate_canonical_decimal(value, 0, 4294967295, "created_at<")
|
||||
} else if let Some(value) = clause.strip_prefix("created_at>") {
|
||||
validate_canonical_decimal(value, 0, 4294967295, "created_at>")
|
||||
} else {
|
||||
Err(SdkError::InvalidInput(format!(
|
||||
"unsupported clause: {clause:?}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_canonical_decimal(s: &str, min: u64, max: u64, label: &str) -> Result<(), SdkError> {
|
||||
if s.is_empty() {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value must not be empty"
|
||||
)));
|
||||
}
|
||||
|
||||
// No leading zeros except "0" itself
|
||||
if s.len() > 1 && s.starts_with('0') {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value has leading zero: {s:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Must be all digits
|
||||
if !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value is not a valid decimal: {s:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
let value: u64 = s
|
||||
.parse()
|
||||
.map_err(|e| SdkError::InvalidInput(format!("{label} value out of range: {e}")))?;
|
||||
|
||||
if value < min || value > max {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value {value} out of range [{min}, {max}]"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_preimage(agent_pubkey: &PublicKey, conditions: &str) -> String {
|
||||
format!("nostr:agent-auth:{}:{}", agent_pubkey.to_hex(), conditions)
|
||||
}
|
||||
|
||||
fn hash_preimage(preimage: &str) -> Message {
|
||||
let digest = Sha256Hash::hash(preimage.as_bytes());
|
||||
Message::from_digest(digest.to_byte_array())
|
||||
}
|
||||
|
||||
/// Check that a character is a lowercase hex digit (`0-9`, `a-f`).
|
||||
/// Nostr convention requires lowercase hex for pubkeys and signatures.
|
||||
fn is_lowercase_hex(c: char) -> bool {
|
||||
c.is_ascii_digit() || matches!(c, 'a'..='f')
|
||||
}
|
||||
|
||||
fn parse_json_array(s: &str) -> Result<Vec<Value>, SdkError> {
|
||||
let v: Value = serde_json::from_str(s)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("invalid JSON: {e}")))?;
|
||||
match v {
|
||||
Value::Array(arr) => Ok(arr),
|
||||
_ => Err(SdkError::InvalidInput(
|
||||
"auth tag must be a JSON array".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`.
|
||||
///
|
||||
/// Signs the preimage with `owner_keys` using BIP-340 Schnorr.
|
||||
///
|
||||
/// Returns a JSON string of the form:
|
||||
/// `["auth","<owner_pubkey_hex>","<conditions>","<sig_hex>"]`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SdkError::InvalidInput`] if `owner_pubkey == agent_pubkey`
|
||||
/// (self-attestation is meaningless and rejected).
|
||||
pub fn compute_auth_tag(
|
||||
owner_keys: &Keys,
|
||||
agent_pubkey: &PublicKey,
|
||||
conditions: &str,
|
||||
) -> Result<String, SdkError> {
|
||||
let owner_pubkey = owner_keys.public_key();
|
||||
if owner_pubkey == *agent_pubkey {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"owner and agent pubkeys must differ (self-attestation rejected)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_conditions(conditions)?;
|
||||
|
||||
let preimage = build_preimage(agent_pubkey, conditions);
|
||||
let message = hash_preimage(&preimage);
|
||||
let sig = owner_keys.sign_schnorr(&message);
|
||||
|
||||
let tag_json = serde_json::json!(["auth", owner_pubkey.to_hex(), conditions, sig.to_string(),]);
|
||||
Ok(tag_json.to_string())
|
||||
}
|
||||
|
||||
/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`.
|
||||
///
|
||||
/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature
|
||||
/// against the owner pubkey embedded in the tag.
|
||||
///
|
||||
/// Returns the owner's [`PublicKey`] on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count,
|
||||
/// bad hex, self-attestation, or signature verification failure.
|
||||
pub fn verify_auth_tag(
|
||||
auth_tag_json: &str,
|
||||
agent_pubkey: &PublicKey,
|
||||
) -> Result<PublicKey, SdkError> {
|
||||
let arr = parse_json_array(auth_tag_json)?;
|
||||
|
||||
if arr.len() != 4 {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"auth tag must have 4 elements, got {}",
|
||||
arr.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let label = arr[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?;
|
||||
if label != "auth" {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"first element must be \"auth\", got \"{label}\""
|
||||
)));
|
||||
}
|
||||
|
||||
let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| {
|
||||
SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into())
|
||||
})?;
|
||||
let conditions = arr[2]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?;
|
||||
let sig_hex = arr[3]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?;
|
||||
|
||||
let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?;
|
||||
|
||||
validate_conditions(conditions)?;
|
||||
|
||||
if owner_pubkey == *agent_pubkey {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"owner and agent pubkeys must differ (self-attestation rejected)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let sig = Signature::from_str(sig_hex)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?;
|
||||
|
||||
let preimage = build_preimage(agent_pubkey, conditions);
|
||||
let message = hash_preimage(&preimage);
|
||||
|
||||
let xonly = owner_pubkey.xonly().map_err(|e| {
|
||||
SdkError::InvalidInput(format!("owner pubkey xonly conversion failed: {e}"))
|
||||
})?;
|
||||
SECP256K1
|
||||
.verify_schnorr(&sig, &message, &xonly)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("signature verification failed: {e}")))?;
|
||||
|
||||
Ok(owner_pubkey)
|
||||
}
|
||||
|
||||
/// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the
|
||||
/// signature.
|
||||
///
|
||||
/// Validates structure only:
|
||||
/// - Exactly 4 elements
|
||||
/// - First element is `"auth"`
|
||||
/// - Second element is a 64-character hex string (owner pubkey)
|
||||
/// - Fourth element is a 128-character hex string (signature)
|
||||
///
|
||||
/// This is the fast path used at MCP startup — no crypto is performed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SdkError::InvalidInput`] for any structural violation.
|
||||
pub fn parse_auth_tag(json_str: &str) -> Result<Tag, SdkError> {
|
||||
let arr = parse_json_array(json_str)?;
|
||||
|
||||
if arr.len() != 4 {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"auth tag must have 4 elements, got {}",
|
||||
arr.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let label = arr[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?;
|
||||
if label != "auth" {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"first element must be \"auth\", got \"{label}\""
|
||||
)));
|
||||
}
|
||||
|
||||
let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| {
|
||||
SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into())
|
||||
})?;
|
||||
if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"owner pubkey must be 64 hex chars, got {:?}",
|
||||
owner_pubkey_hex
|
||||
)));
|
||||
}
|
||||
|
||||
let conditions = arr[2]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?;
|
||||
|
||||
validate_conditions(conditions)?;
|
||||
|
||||
let sig_hex = arr[3]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?;
|
||||
if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"signature must be 128 hex chars, got length {}",
|
||||
sig_hex.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Tag::parse(["auth", owner_pubkey_hex, conditions, sig_hex])
|
||||
.map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const OWNER_PUBKEY_HEX: &str =
|
||||
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||||
const AGENT_PUBKEY_HEX: &str =
|
||||
"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||||
const CONDITIONS: &str = "kind=1&created_at<1713957000";
|
||||
const SPEC_SIG_HEX: &str =
|
||||
"8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369";
|
||||
|
||||
/// Verify the spec's provided signature against the spec's known preimage.
|
||||
#[test]
|
||||
fn test_verify_spec_test_vector() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
let owner_pubkey = PublicKey::from_hex(OWNER_PUBKEY_HEX).unwrap();
|
||||
|
||||
let preimage = build_preimage(&agent_pubkey, CONDITIONS);
|
||||
assert_eq!(
|
||||
preimage,
|
||||
format!("nostr:agent-auth:{}:{}", AGENT_PUBKEY_HEX, CONDITIONS)
|
||||
);
|
||||
|
||||
let message = hash_preimage(&preimage);
|
||||
|
||||
let sig = Signature::from_str(SPEC_SIG_HEX).expect("spec sig must parse");
|
||||
let xonly = owner_pubkey.xonly().expect("valid test pubkey");
|
||||
SECP256K1
|
||||
.verify_schnorr(&sig, &message, &xonly)
|
||||
.expect("spec test vector signature must verify");
|
||||
}
|
||||
|
||||
/// Sign with generated keys, then verify — round-trip without byte comparison.
|
||||
#[test]
|
||||
fn test_sign_then_verify_round_trip() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
|
||||
let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "kind=9")
|
||||
.expect("compute_auth_tag must succeed");
|
||||
|
||||
let recovered =
|
||||
verify_auth_tag(&tag_json, &agent_pubkey).expect("verify_auth_tag must succeed");
|
||||
|
||||
assert_eq!(recovered, owner_keys.public_key());
|
||||
}
|
||||
|
||||
/// Empty conditions string is valid.
|
||||
#[test]
|
||||
fn test_empty_conditions() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
|
||||
let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "")
|
||||
.expect("empty conditions must succeed");
|
||||
|
||||
let recovered = verify_auth_tag(&tag_json, &agent_pubkey)
|
||||
.expect("verify with empty conditions must succeed");
|
||||
|
||||
assert_eq!(recovered, owner_keys.public_key());
|
||||
}
|
||||
|
||||
/// Self-attestation (owner == agent) must be rejected at both sign and verify.
|
||||
#[test]
|
||||
fn test_reject_self_attestation() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key();
|
||||
|
||||
let err = compute_auth_tag(&keys, &pubkey, "kind=9")
|
||||
.expect_err("self-attestation must be rejected");
|
||||
assert!(
|
||||
matches!(err, SdkError::InvalidInput(_)),
|
||||
"expected InvalidInput, got {err:?}"
|
||||
);
|
||||
|
||||
// Craft a self-attesting tag and verify it's rejected.
|
||||
let fake_json =
|
||||
serde_json::json!(["auth", pubkey.to_hex(), "kind=9", "a".repeat(128),]).to_string();
|
||||
let err = verify_auth_tag(&fake_json, &pubkey)
|
||||
.expect_err("self-attestation must be rejected at verify");
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
/// Various malformed inputs to verify_auth_tag must return errors.
|
||||
#[test]
|
||||
fn test_reject_malformed_tag() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
|
||||
assert!(verify_auth_tag("not json", &agent_pubkey).is_err());
|
||||
assert!(verify_auth_tag(r#"{"auth":"x"}"#, &agent_pubkey).is_err());
|
||||
assert!(verify_auth_tag(r#"["auth","a","b"]"#, &agent_pubkey).is_err());
|
||||
|
||||
let bad_label =
|
||||
serde_json::json!(["notauth", OWNER_PUBKEY_HEX, CONDITIONS, "a".repeat(128)])
|
||||
.to_string();
|
||||
assert!(verify_auth_tag(&bad_label, &agent_pubkey).is_err());
|
||||
|
||||
let bad_pk =
|
||||
serde_json::json!(["auth", "notahex", CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(verify_auth_tag(&bad_pk, &agent_pubkey).is_err());
|
||||
|
||||
let bad_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "notasig"]).to_string();
|
||||
assert!(verify_auth_tag(&bad_sig, &agent_pubkey).is_err());
|
||||
|
||||
// Valid structure but wrong signature (won't verify).
|
||||
let wrong_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "0".repeat(128)]).to_string();
|
||||
assert!(verify_auth_tag(&wrong_sig, &agent_pubkey).is_err());
|
||||
}
|
||||
|
||||
/// parse_auth_tag with a well-formed JSON array returns a Tag.
|
||||
#[test]
|
||||
fn test_parse_auth_tag_valid() {
|
||||
let sig_hex = "a".repeat(128);
|
||||
let json = serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, sig_hex,]).to_string();
|
||||
|
||||
let tag = parse_auth_tag(&json).expect("valid tag must parse");
|
||||
let slice = tag.as_slice();
|
||||
assert_eq!(slice[0], "auth");
|
||||
assert_eq!(slice[1], OWNER_PUBKEY_HEX);
|
||||
assert_eq!(slice[2], CONDITIONS);
|
||||
assert_eq!(slice[3], "a".repeat(128));
|
||||
}
|
||||
|
||||
/// Various malformed inputs to parse_auth_tag must return errors.
|
||||
#[test]
|
||||
fn test_parse_auth_tag_malformed() {
|
||||
assert!(parse_auth_tag("not json").is_err());
|
||||
assert!(parse_auth_tag(r#"{"auth":"x"}"#).is_err());
|
||||
assert!(parse_auth_tag(r#"["auth","a","b"]"#).is_err());
|
||||
|
||||
let bad_label =
|
||||
serde_json::json!(["notauth", OWNER_PUBKEY_HEX, CONDITIONS, "a".repeat(128)])
|
||||
.to_string();
|
||||
assert!(parse_auth_tag(&bad_label).is_err());
|
||||
|
||||
let short_pk = serde_json::json!(["auth", "abcd", CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&short_pk).is_err());
|
||||
|
||||
let non_hex_pk =
|
||||
serde_json::json!(["auth", "z".repeat(64), CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&non_hex_pk).is_err());
|
||||
|
||||
let short_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "abcd"]).to_string();
|
||||
assert!(parse_auth_tag(&short_sig).is_err());
|
||||
|
||||
let non_hex_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "z".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&non_hex_sig).is_err());
|
||||
}
|
||||
|
||||
/// Uppercase hex must be rejected — Nostr convention is lowercase only.
|
||||
#[test]
|
||||
fn test_parse_auth_tag_rejects_uppercase_hex() {
|
||||
// Uppercase in owner pubkey
|
||||
let upper_pk = OWNER_PUBKEY_HEX.to_uppercase();
|
||||
let json = serde_json::json!(["auth", upper_pk, CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(
|
||||
parse_auth_tag(&json).is_err(),
|
||||
"uppercase owner pubkey must be rejected"
|
||||
);
|
||||
|
||||
// Mixed case in owner pubkey
|
||||
let mut mixed_pk = OWNER_PUBKEY_HEX.to_string();
|
||||
mixed_pk.replace_range(0..1, "A");
|
||||
let json = serde_json::json!(["auth", mixed_pk, CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(
|
||||
parse_auth_tag(&json).is_err(),
|
||||
"mixed-case owner pubkey must be rejected"
|
||||
);
|
||||
|
||||
// Uppercase in signature
|
||||
let json =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "A".repeat(128)]).to_string();
|
||||
assert!(
|
||||
parse_auth_tag(&json).is_err(),
|
||||
"uppercase signature must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify the spec's SHA-256 hash of the preimage matches the expected value.
|
||||
#[test]
|
||||
fn test_spec_sha256_hash() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
let preimage = build_preimage(&agent_pubkey, CONDITIONS);
|
||||
let digest = Sha256Hash::hash(preimage.as_bytes());
|
||||
let expected = "08cdecd55af4c28d3801fd69615dcf5cc04fab3bc134b38a840bf157197069a6";
|
||||
assert_eq!(format!("{digest:x}"), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_conditions() {
|
||||
// These should all pass through validate_conditions
|
||||
assert!(validate_conditions("").is_ok());
|
||||
assert!(validate_conditions("kind=1").is_ok());
|
||||
assert!(validate_conditions("kind=0").is_ok());
|
||||
assert!(validate_conditions("kind=65535").is_ok());
|
||||
assert!(validate_conditions("created_at<1713957000").is_ok());
|
||||
assert!(validate_conditions("created_at>0").is_ok());
|
||||
assert!(validate_conditions("created_at>4294967295").is_ok());
|
||||
assert!(validate_conditions("kind=1&created_at<1713957000").is_ok());
|
||||
assert!(validate_conditions("kind=9&created_at>100&created_at<200").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_trailing_ampersand() {
|
||||
let err = validate_conditions("kind=1&").unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_leading_ampersand() {
|
||||
assert!(validate_conditions("&kind=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_double_ampersand() {
|
||||
assert!(validate_conditions("kind=1&&created_at<100").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_leading_zero() {
|
||||
assert!(validate_conditions("kind=01").is_err());
|
||||
assert!(validate_conditions("created_at<01713957000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_whitespace() {
|
||||
assert!(validate_conditions("kind=1 ").is_err());
|
||||
assert!(validate_conditions(" kind=1").is_err());
|
||||
assert!(validate_conditions("kind= 1").is_err());
|
||||
assert!(validate_conditions("kind=1\t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_unknown_clause() {
|
||||
assert!(validate_conditions("foo=1").is_err());
|
||||
assert!(validate_conditions("Kind=1").is_err()); // case-sensitive
|
||||
assert!(validate_conditions("CREATED_AT<100").is_err());
|
||||
assert!(validate_conditions("created_at=100").is_err()); // wrong operator
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_out_of_range() {
|
||||
assert!(validate_conditions("kind=65536").is_err());
|
||||
assert!(validate_conditions("created_at<4294967296").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_non_decimal() {
|
||||
assert!(validate_conditions("kind=abc").is_err());
|
||||
assert!(validate_conditions("kind=-1").is_err());
|
||||
assert!(validate_conditions("kind=1.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_empty_value() {
|
||||
assert!(validate_conditions("kind=").is_err());
|
||||
assert!(validate_conditions("created_at<").is_err());
|
||||
assert!(validate_conditions("created_at>").is_err());
|
||||
}
|
||||
|
||||
// Verify conditions validation is wired into compute and verify
|
||||
#[test]
|
||||
fn test_compute_rejects_invalid_conditions() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let err = compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=01")
|
||||
.expect_err("leading zero must be rejected");
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_invalid_conditions() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
// Craft a tag with invalid conditions but valid structure
|
||||
let bad_conditions =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=01", "a".repeat(128)]).to_string();
|
||||
let err = verify_auth_tag(&bad_conditions, &agent_pubkey)
|
||||
.expect_err("leading zero must be rejected at verify");
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rejects_invalid_conditions() {
|
||||
let bad =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=1&", "a".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&bad).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user