diff --git a/rsworkspace/crates/acp-nats/src/acp_prefix.rs b/rsworkspace/crates/acp-nats/src/acp_prefix.rs index 06f71a7f7..73afca50a 100644 --- a/rsworkspace/crates/acp-nats/src/acp_prefix.rs +++ b/rsworkspace/crates/acp-nats/src/acp_prefix.rs @@ -7,11 +7,8 @@ //! malformed dots (consecutive, leading, trailing). Max 128 bytes. Validity is guaranteed at //! construction. -use std::sync::Arc; - -use crate::constants::MAX_PREFIX_LENGTH; -use crate::nats::token; -use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::SubjectTokenViolation; +use trogon_nats::DottedNatsToken; /// Error returned when [`AcpPrefix`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -35,28 +32,16 @@ impl std::error::Error for AcpPrefixError {} /// NATS-safe ACP prefix. Guarantees validity at construction—invalid instances are unrepresentable. #[derive(Clone, Debug)] -pub struct AcpPrefix(Arc); +pub struct AcpPrefix(DottedNatsToken); impl AcpPrefix { pub fn new(s: impl Into) -> Result { let s = s.into(); - if s.is_empty() { - return Err(AcpPrefixError(SubjectTokenViolation::Empty)); - } - if let Some(ch) = token::has_wildcards_or_whitespace(&s) { - return Err(AcpPrefixError(SubjectTokenViolation::InvalidCharacter(ch))); - } - if token::has_consecutive_or_boundary_dots(&s) { - return Err(AcpPrefixError(SubjectTokenViolation::InvalidCharacter('.'))); - } - if s.len() > MAX_PREFIX_LENGTH { - return Err(AcpPrefixError(SubjectTokenViolation::TooLong(s.len()))); - } - Ok(Self(s.into())) + DottedNatsToken::new(s).map(Self).map_err(AcpPrefixError) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() } } diff --git a/rsworkspace/crates/acp-nats/src/constants.rs b/rsworkspace/crates/acp-nats/src/constants.rs index 2f6a62595..969e376e2 100644 --- a/rsworkspace/crates/acp-nats/src/constants.rs +++ b/rsworkspace/crates/acp-nats/src/constants.rs @@ -19,10 +19,6 @@ pub const SESSION_READY_DELAY: Duration = Duration::from_millis(100); pub const PROMPT_TIMEOUT_WARNING_SUPPRESSION_WINDOW: Duration = Duration::from_secs(5); pub const TEST_PROMPT_TIMEOUT: Duration = Duration::from_secs(5); -pub const MAX_PREFIX_LENGTH: usize = 128; -pub const MAX_SESSION_ID_LENGTH: usize = 128; -pub const MAX_METHOD_NAME_LENGTH: usize = 128; - pub const AGENT_UNAVAILABLE: i32 = -32001; pub const SESSION_PREFIX: &str = ".session."; diff --git a/rsworkspace/crates/acp-nats/src/ext_method_name.rs b/rsworkspace/crates/acp-nats/src/ext_method_name.rs index e6326cc68..09293e568 100644 --- a/rsworkspace/crates/acp-nats/src/ext_method_name.rs +++ b/rsworkspace/crates/acp-nats/src/ext_method_name.rs @@ -5,11 +5,8 @@ //! rejects `*`, `>`, whitespace; allows dotted namespaces (e.g. `vendor.operation`) but rejects //! malformed dots (consecutive, leading, trailing). Validity is guaranteed at construction. -use std::sync::Arc; - -use crate::constants::MAX_METHOD_NAME_LENGTH; -use crate::nats::token; -use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::SubjectTokenViolation; +use trogon_nats::DottedNatsToken; /// Error returned when [`ExtMethodName`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -35,32 +32,17 @@ impl std::error::Error for ExtMethodNameError {} /// /// Rejects empty, too-long, wildcard, whitespace, and malformed dotted names. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ExtMethodName(Arc); +pub struct ExtMethodName(DottedNatsToken); impl ExtMethodName { pub fn new(method: impl AsRef) -> Result { - let s = method.as_ref(); - if s.is_empty() { - return Err(ExtMethodNameError(SubjectTokenViolation::Empty)); - } - if s.len() > MAX_METHOD_NAME_LENGTH { - return Err(ExtMethodNameError(SubjectTokenViolation::TooLong(s.len()))); - } - if let Some(ch) = token::has_wildcards_or_whitespace(s) { - return Err(ExtMethodNameError(SubjectTokenViolation::InvalidCharacter( - ch, - ))); - } - if token::has_consecutive_or_boundary_dots(s) { - return Err(ExtMethodNameError(SubjectTokenViolation::InvalidCharacter( - '.', - ))); - } - Ok(Self(s.into())) + DottedNatsToken::new(method) + .map(Self) + .map_err(ExtMethodNameError) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() } } diff --git a/rsworkspace/crates/acp-nats/src/lib.rs b/rsworkspace/crates/acp-nats/src/lib.rs index 30ba66a82..55f755177 100644 --- a/rsworkspace/crates/acp-nats/src/lib.rs +++ b/rsworkspace/crates/acp-nats/src/lib.rs @@ -12,7 +12,6 @@ pub(crate) mod jsonrpc; pub mod nats; pub(crate) mod pending_prompt_waiters; pub mod session_id; -pub mod subject_token_violation; pub(crate) mod telemetry; pub use acp_prefix::{AcpPrefix, AcpPrefixError}; diff --git a/rsworkspace/crates/acp-nats/src/nats/mod.rs b/rsworkspace/crates/acp-nats/src/nats/mod.rs index ce94e11a1..9ca19503b 100644 --- a/rsworkspace/crates/acp-nats/src/nats/mod.rs +++ b/rsworkspace/crates/acp-nats/src/nats/mod.rs @@ -1,7 +1,6 @@ mod extensions; pub mod parsing; mod subjects; -pub(crate) mod token; use serde::Serialize; use serde::de::DeserializeOwned; diff --git a/rsworkspace/crates/acp-nats/src/session_id.rs b/rsworkspace/crates/acp-nats/src/session_id.rs index 63b8a9090..fda3e6b04 100644 --- a/rsworkspace/crates/acp-nats/src/session_id.rs +++ b/rsworkspace/crates/acp-nats/src/session_id.rs @@ -4,12 +4,9 @@ //! Validation follows [NATS subject naming](https://docs.nats.io/nats-concepts/subjects#characters-allowed-and-recommended-for-subject-names): //! ASCII only (recommended), rejecting `.` `*` `>` and whitespace (forbidden). Validity is //! guaranteed at construction. -//! -//! TODO: Consider extracting to `trogon-nats` as a generic `NatsSubject` (or `NatsToken`) type -//! so prefix, session_id, and other subject tokens share the same validation. -use crate::constants::MAX_SESSION_ID_LENGTH; -use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::SubjectTokenViolation; +use trogon_nats::NatsToken; /// Error returned when [`AcpSessionId`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -36,32 +33,15 @@ impl std::error::Error for SessionIdError {} /// Follows [NATS subject naming](https://docs.nats.io/nats-concepts/subjects#characters-allowed-and-recommended-for-subject-names): /// ASCII only; rejects `.`, `*`, `>`, and whitespace. Max 128 characters. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct AcpSessionId(std::sync::Arc); +pub struct AcpSessionId(NatsToken); impl AcpSessionId { pub fn new(s: impl AsRef) -> Result { - let s = s.as_ref(); - if s.is_empty() { - return Err(SessionIdError(SubjectTokenViolation::Empty)); - } - let mut char_count = 0; - for ch in s.chars() { - char_count += 1; - if char_count > MAX_SESSION_ID_LENGTH { - return Err(SessionIdError(SubjectTokenViolation::TooLong(char_count))); - } - if !ch.is_ascii() { - return Err(SessionIdError(SubjectTokenViolation::InvalidCharacter(ch))); - } - if ch == '.' || ch == '*' || ch == '>' || ch.is_whitespace() { - return Err(SessionIdError(SubjectTokenViolation::InvalidCharacter(ch))); - } - } - Ok(Self(s.into())) + NatsToken::new(s).map(Self).map_err(SessionIdError) } pub fn as_str(&self) -> &str { - &self.0 + self.0.as_str() } } diff --git a/rsworkspace/crates/trogon-nats/src/constants.rs b/rsworkspace/crates/trogon-nats/src/constants.rs index 8362ae940..7dbe45206 100644 --- a/rsworkspace/crates/trogon-nats/src/constants.rs +++ b/rsworkspace/crates/trogon-nats/src/constants.rs @@ -12,3 +12,5 @@ pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); pub const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); pub const REQ_ID_HEADER: &str = "X-Req-Id"; + +pub const MAX_NATS_TOKEN_LENGTH: usize = 128; diff --git a/rsworkspace/crates/trogon-nats/src/lib.rs b/rsworkspace/crates/trogon-nats/src/lib.rs index e88901ab4..1e8f6a060 100644 --- a/rsworkspace/crates/trogon-nats/src/lib.rs +++ b/rsworkspace/crates/trogon-nats/src/lib.rs @@ -42,6 +42,9 @@ pub mod connect; pub mod constants; pub mod jetstream; pub mod messaging; +pub mod nats_token; +pub mod subject_token_violation; +pub(crate) mod token; #[cfg(feature = "test-support")] pub mod mocks; @@ -56,6 +59,8 @@ pub use messaging::{ RetryPolicy, build_request_headers, headers_with_trace_context, inject_trace_context, publish, request, request_with_timeout, }; +pub use nats_token::{DottedNatsToken, NatsToken}; +pub use subject_token_violation::SubjectTokenViolation; #[cfg(feature = "test-support")] pub use mocks::{AdvancedMockNatsClient, MockNatsClient}; diff --git a/rsworkspace/crates/trogon-nats/src/nats_token.rs b/rsworkspace/crates/trogon-nats/src/nats_token.rs new file mode 100644 index 000000000..c913484c7 --- /dev/null +++ b/rsworkspace/crates/trogon-nats/src/nats_token.rs @@ -0,0 +1,280 @@ +//! NATS-safe subject token value objects. +//! +//! Two concrete types cover the two validation flavors used in practice: +//! +//! - [`NatsToken`] — single token, no dots, ASCII-only, max 128 chars +//! - [`DottedNatsToken`] — dotted segments allowed, UTF-8, max 128 bytes +//! +//! All validation happens at construction; invalid instances are unrepresentable. + +use std::sync::Arc; + +use crate::constants::MAX_NATS_TOKEN_LENGTH; +use crate::subject_token_violation::SubjectTokenViolation; +use crate::token; + +/// A validated single NATS subject token. +/// +/// Rejects empty, non-ASCII, dots, wildcards (`*`, `>`), and whitespace. +/// Max 128 characters. Wraps an `Arc` so cloning is cheap. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct NatsToken(Arc); + +impl NatsToken { + /// Validate and construct a new single token. + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(SubjectTokenViolation::Empty); + } + let mut char_count: usize = 0; + for ch in s.chars() { + char_count += 1; + if char_count > MAX_NATS_TOKEN_LENGTH { + return Err(SubjectTokenViolation::TooLong(char_count)); + } + if !ch.is_ascii() || ch == '.' || ch == '*' || ch == '>' || ch.is_whitespace() { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); + } + } + Ok(Self(s.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for NatsToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::ops::Deref for NatsToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for NatsToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// A validated dotted NATS subject segment. +/// +/// Allows `.` as a token separator but rejects malformed dots (consecutive, +/// leading, trailing). Rejects wildcards (`*`, `>`) and whitespace. +/// Max 128 bytes. Wraps an `Arc` so cloning is cheap. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DottedNatsToken(Arc); + +impl DottedNatsToken { + /// Validate and construct a new dotted token. + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(SubjectTokenViolation::Empty); + } + if let Some(ch) = token::has_wildcards_or_whitespace(s) { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); + } + if token::has_consecutive_or_boundary_dots(s) { + return Err(SubjectTokenViolation::InvalidCharacter('.')); + } + if s.len() > MAX_NATS_TOKEN_LENGTH { + return Err(SubjectTokenViolation::TooLong(s.len())); + } + Ok(Self(s.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for DottedNatsToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::ops::Deref for DottedNatsToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for DottedNatsToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_valid() { + assert!(NatsToken::new("valid-session-123").is_ok()); + assert!(NatsToken::new("a").is_ok()); + assert_eq!(NatsToken::new("hello").unwrap().as_str(), "hello"); + } + + #[test] + fn single_empty() { + assert_eq!(NatsToken::new(""), Err(SubjectTokenViolation::Empty)); + } + + #[test] + fn single_too_long() { + let long = "a".repeat(129); + assert_eq!( + NatsToken::new(&long), + Err(SubjectTokenViolation::TooLong(129)) + ); + assert!(NatsToken::new("a".repeat(128)).is_ok()); + } + + #[test] + fn single_rejects_dots() { + assert_eq!( + NatsToken::new("a.b"), + Err(SubjectTokenViolation::InvalidCharacter('.')) + ); + } + + #[test] + fn single_rejects_wildcards() { + assert!(NatsToken::new("a*").is_err()); + assert!(NatsToken::new("a>").is_err()); + assert!(NatsToken::new(">").is_err()); + } + + #[test] + fn single_rejects_whitespace() { + assert!(NatsToken::new("a b").is_err()); + assert!(NatsToken::new("a\t").is_err()); + assert!(NatsToken::new("a\n").is_err()); + } + + #[test] + fn single_rejects_non_ascii() { + assert_eq!( + NatsToken::new("séssion"), + Err(SubjectTokenViolation::InvalidCharacter('é')) + ); + } + + #[test] + fn dotted_valid_simple() { + assert!(DottedNatsToken::new("acp").is_ok()); + assert!(DottedNatsToken::new("a").is_ok()); + } + + #[test] + fn dotted_valid_dotted() { + assert_eq!( + DottedNatsToken::new("my.multi.part").unwrap().as_str(), + "my.multi.part" + ); + assert!(DottedNatsToken::new("a.b").is_ok()); + assert!(DottedNatsToken::new("vendor.operation").is_ok()); + } + + #[test] + fn dotted_empty() { + assert_eq!(DottedNatsToken::new(""), Err(SubjectTokenViolation::Empty)); + } + + #[test] + fn dotted_too_long() { + let long = "a".repeat(129); + assert_eq!( + DottedNatsToken::new(&long), + Err(SubjectTokenViolation::TooLong(129)) + ); + assert!(DottedNatsToken::new("a".repeat(128)).is_ok()); + } + + #[test] + fn dotted_rejects_wildcards() { + assert!(DottedNatsToken::new("acp.*").is_err()); + assert!(DottedNatsToken::new("acp.>").is_err()); + } + + #[test] + fn dotted_rejects_whitespace() { + assert!(DottedNatsToken::new("acp prefix").is_err()); + assert!(DottedNatsToken::new("acp\t").is_err()); + assert!(DottedNatsToken::new("acp\n").is_err()); + } + + #[test] + fn dotted_rejects_malformed_dots() { + assert!(DottedNatsToken::new("..method").is_err()); + assert!(DottedNatsToken::new("method..name").is_err()); + assert!(DottedNatsToken::new(".method").is_err()); + assert!(DottedNatsToken::new("method.").is_err()); + assert!(DottedNatsToken::new(".").is_err()); + assert!(DottedNatsToken::new("acp..foo").is_err()); + assert!(DottedNatsToken::new(".acp").is_err()); + assert!(DottedNatsToken::new("acp.").is_err()); + } + + #[test] + fn dotted_accepts_non_ascii() { + assert!(DottedNatsToken::new("préfixe").is_ok()); + } + + #[test] + fn single_display_and_deref() { + let t = NatsToken::new("my-session").unwrap(); + assert_eq!(format!("{}", t), "my-session"); + assert_eq!(t.len(), 10); + assert!(t.starts_with("my")); + } + + #[test] + fn dotted_display_and_deref() { + let t = DottedNatsToken::new("my.prefix").unwrap(); + assert_eq!(format!("{}", t), "my.prefix"); + assert_eq!(t.len(), 9); + assert!(t.starts_with("my")); + } + + #[test] + fn single_as_ref_str() { + let t = NatsToken::new("hello").unwrap(); + let s: &str = t.as_ref(); + assert_eq!(s, "hello"); + } + + #[test] + fn dotted_as_ref_str() { + let t = DottedNatsToken::new("hello").unwrap(); + let s: &str = t.as_ref(); + assert_eq!(s, "hello"); + } + + #[test] + fn single_clone_and_eq() { + let a = NatsToken::new("abc").unwrap(); + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn dotted_clone_and_eq() { + let a = DottedNatsToken::new("a.b.c").unwrap(); + let b = a.clone(); + assert_eq!(a, b); + } +} diff --git a/rsworkspace/crates/acp-nats/src/subject_token_violation.rs b/rsworkspace/crates/trogon-nats/src/subject_token_violation.rs similarity index 100% rename from rsworkspace/crates/acp-nats/src/subject_token_violation.rs rename to rsworkspace/crates/trogon-nats/src/subject_token_violation.rs diff --git a/rsworkspace/crates/acp-nats/src/nats/token.rs b/rsworkspace/crates/trogon-nats/src/token.rs similarity index 77% rename from rsworkspace/crates/acp-nats/src/nats/token.rs rename to rsworkspace/crates/trogon-nats/src/token.rs index 11527178f..277c200c8 100644 --- a/rsworkspace/crates/acp-nats/src/nats/token.rs +++ b/rsworkspace/crates/trogon-nats/src/token.rs @@ -3,13 +3,13 @@ //! See [NATS subject naming](https://docs.nats.io/nats-concepts/subjects#characters-allowed-and-recommended-for-subject-names). /// Returns the first character that is a NATS wildcard (`*`, `>`) or whitespace. -pub(crate) fn has_wildcards_or_whitespace(value: &str) -> Option { +pub fn has_wildcards_or_whitespace(value: &str) -> Option { value .chars() .find(|ch| *ch == '*' || *ch == '>' || ch.is_whitespace()) } /// True if value has consecutive dots, or starts/ends with a dot. -pub(crate) fn has_consecutive_or_boundary_dots(value: &str) -> bool { +pub fn has_consecutive_or_boundary_dots(value: &str) -> bool { value.contains("..") || value.starts_with('.') || value.ends_with('.') }