From 689aba558236b301f33f51bb25419e69ed85ffde Mon Sep 17 00:00:00 2001 From: itsitsiridakis Date: Mon, 23 Mar 2026 21:02:59 +0200 Subject: [PATCH 1/4] refactor: unify token types under NatsToken in trogon-nats Signed-off-by: itsitsiridakis --- rsworkspace/crates/acp-nats/src/acp_prefix.rs | 24 +- .../crates/acp-nats/src/ext_method_name.rs | 29 +- rsworkspace/crates/acp-nats/src/lib.rs | 1 + rsworkspace/crates/acp-nats/src/nats/mod.rs | 1 - .../acp-nats/src/nats_token_policies.rs | 23 ++ rsworkspace/crates/acp-nats/src/session_id.rs | 29 +- .../acp-nats/src/subject_token_violation.rs | 8 +- rsworkspace/crates/trogon-nats/src/lib.rs | 5 + .../crates/trogon-nats/src/nats_token.rs | 353 ++++++++++++++++++ .../src/subject_token_violation.rs | 7 + .../src/nats => trogon-nats/src}/token.rs | 4 +- 11 files changed, 407 insertions(+), 77 deletions(-) create mode 100644 rsworkspace/crates/acp-nats/src/nats_token_policies.rs create mode 100644 rsworkspace/crates/trogon-nats/src/nats_token.rs create mode 100644 rsworkspace/crates/trogon-nats/src/subject_token_violation.rs rename rsworkspace/crates/{acp-nats/src/nats => trogon-nats/src}/token.rs (77%) diff --git a/rsworkspace/crates/acp-nats/src/acp_prefix.rs b/rsworkspace/crates/acp-nats/src/acp_prefix.rs index 06f71a7f7..a41215ef2 100644 --- a/rsworkspace/crates/acp-nats/src/acp_prefix.rs +++ b/rsworkspace/crates/acp-nats/src/acp_prefix.rs @@ -7,11 +7,9 @@ //! 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::nats_token_policies::MultiTokenPolicy; use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::NatsToken; /// Error returned when [`AcpPrefix`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -35,28 +33,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(NatsToken); 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())) + NatsToken::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/ext_method_name.rs b/rsworkspace/crates/acp-nats/src/ext_method_name.rs index e6326cc68..68f23fa68 100644 --- a/rsworkspace/crates/acp-nats/src/ext_method_name.rs +++ b/rsworkspace/crates/acp-nats/src/ext_method_name.rs @@ -5,11 +5,9 @@ //! 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::nats_token_policies::MultiTokenPolicy; use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::NatsToken; /// Error returned when [`ExtMethodName`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -35,32 +33,15 @@ 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(NatsToken); 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())) + NatsToken::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..9ec409af1 100644 --- a/rsworkspace/crates/acp-nats/src/lib.rs +++ b/rsworkspace/crates/acp-nats/src/lib.rs @@ -10,6 +10,7 @@ pub(crate) mod in_flight_slot_guard; pub mod jetstream; pub(crate) mod jsonrpc; pub mod nats; +pub(crate) mod nats_token_policies; pub(crate) mod pending_prompt_waiters; pub mod session_id; pub mod subject_token_violation; 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/nats_token_policies.rs b/rsworkspace/crates/acp-nats/src/nats_token_policies.rs new file mode 100644 index 000000000..8a6aab8cf --- /dev/null +++ b/rsworkspace/crates/acp-nats/src/nats_token_policies.rs @@ -0,0 +1,23 @@ +use trogon_nats::NatsTokenPolicy; + +/// Single NATS subject token: no dots, ASCII-only, max 128 chars. +/// +/// Used for values embedded as a single token in a subject, e.g. session IDs. +pub struct SingleTokenPolicy; + +impl NatsTokenPolicy for SingleTokenPolicy { + const ALLOW_DOTS: bool = false; + const REQUIRE_ASCII: bool = true; + const MAX_LENGTH: usize = 128; +} + +/// Multi-token NATS subject segment: dots allowed as separators, max 128 bytes. +/// +/// Used for values that may contain dotted namespaces, e.g. prefixes and method names. +pub struct MultiTokenPolicy; + +impl NatsTokenPolicy for MultiTokenPolicy { + const ALLOW_DOTS: bool = true; + const REQUIRE_ASCII: bool = false; + const MAX_LENGTH: usize = 128; +} diff --git a/rsworkspace/crates/acp-nats/src/session_id.rs b/rsworkspace/crates/acp-nats/src/session_id.rs index 63b8a9090..0905d5c1f 100644 --- a/rsworkspace/crates/acp-nats/src/session_id.rs +++ b/rsworkspace/crates/acp-nats/src/session_id.rs @@ -4,12 +4,10 @@ //! 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::nats_token_policies::SingleTokenPolicy; use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::NatsToken; /// Error returned when [`AcpSessionId`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -36,32 +34,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/acp-nats/src/subject_token_violation.rs b/rsworkspace/crates/acp-nats/src/subject_token_violation.rs index 9928bd7fa..9fccaa78a 100644 --- a/rsworkspace/crates/acp-nats/src/subject_token_violation.rs +++ b/rsworkspace/crates/acp-nats/src/subject_token_violation.rs @@ -1,7 +1 @@ -/// Describes what went wrong when validating a NATS subject token: empty, invalid character, or too long. -#[derive(Debug, Clone, PartialEq)] -pub enum SubjectTokenViolation { - Empty, - InvalidCharacter(char), - TooLong(usize), -} +pub use trogon_nats::SubjectTokenViolation; diff --git a/rsworkspace/crates/trogon-nats/src/lib.rs b/rsworkspace/crates/trogon-nats/src/lib.rs index e88901ab4..b166b4199 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 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::{NatsToken, NatsTokenPolicy}; +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..38ca40254 --- /dev/null +++ b/rsworkspace/crates/trogon-nats/src/nats_token.rs @@ -0,0 +1,353 @@ +//! Generic NATS-safe subject token value object. +//! +//! [`NatsToken

`] is parameterized by a [`NatsTokenPolicy`] that encodes the +//! validation flavor (single-token vs. multi-token, ASCII-only vs. UTF-8, etc.). +//! All validation happens at construction; invalid instances are unrepresentable. + +use std::marker::PhantomData; +use std::sync::Arc; + +use crate::subject_token_violation::SubjectTokenViolation; +use crate::token; + +/// Policy trait that controls how a [`NatsToken`] is validated. +/// +/// Implemented as associated constants so the compiler can monomorphize away +/// all branching at compile time. +pub trait NatsTokenPolicy { + /// Whether `.` is accepted as a token separator (multi-token mode). + const ALLOW_DOTS: bool; + + /// Whether non-ASCII characters are rejected. + /// When `true`, length is measured in **chars**; otherwise in **bytes**. + const REQUIRE_ASCII: bool; + + /// Maximum permitted length (chars when `REQUIRE_ASCII`, bytes otherwise). + const MAX_LENGTH: usize; +} + +/// A validated NATS subject token (or dotted token sequence). +/// +/// Wraps an `Arc` so cloning is cheap. The policy `P` determines which +/// characters and lengths are accepted. +/// +/// Trait impls are hand-written so that `P` does not need to implement +/// `Clone`, `Debug`, `PartialEq`, `Eq`, or `Hash`. +pub struct NatsToken(Arc, PhantomData

); + +impl Clone for NatsToken

{ + fn clone(&self) -> Self { + Self(self.0.clone(), PhantomData) + } +} + +impl std::fmt::Debug for NatsToken

{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("NatsToken").field(&self.0).finish() + } +} + +impl PartialEq for NatsToken

{ + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for NatsToken

{} + +impl std::hash::Hash for NatsToken

{ + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +impl NatsToken

{ + /// Validate and construct a new token. + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + + if s.is_empty() { + return Err(SubjectTokenViolation::Empty); + } + + if P::REQUIRE_ASCII { + let mut char_count: usize = 0; + for ch in s.chars() { + char_count += 1; + if char_count > P::MAX_LENGTH { + return Err(SubjectTokenViolation::TooLong(char_count)); + } + if !ch.is_ascii() { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); + } + if ch == '*' || ch == '>' || ch.is_whitespace() { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); + } + if ch == '.' && !P::ALLOW_DOTS { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); + } + } + if P::ALLOW_DOTS && token::has_consecutive_or_boundary_dots(s) { + return Err(SubjectTokenViolation::InvalidCharacter('.')); + } + } else { + if let Some(ch) = token::has_wildcards_or_whitespace(s) { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); + } + if !P::ALLOW_DOTS { + if s.contains('.') { + return Err(SubjectTokenViolation::InvalidCharacter('.')); + } + } else if token::has_consecutive_or_boundary_dots(s) { + return Err(SubjectTokenViolation::InvalidCharacter('.')); + } + if s.len() > P::MAX_LENGTH { + return Err(SubjectTokenViolation::TooLong(s.len())); + } + } + + Ok(Self(s.into(), PhantomData)) + } + + 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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct SingleTokenPolicy; + impl NatsTokenPolicy for SingleTokenPolicy { + const ALLOW_DOTS: bool = false; + const REQUIRE_ASCII: bool = true; + const MAX_LENGTH: usize = 128; + } + + struct MultiTokenPolicy; + impl NatsTokenPolicy for MultiTokenPolicy { + const ALLOW_DOTS: bool = true; + const REQUIRE_ASCII: bool = false; + const MAX_LENGTH: usize = 128; + } + + struct AsciiMultiTokenPolicy; + impl NatsTokenPolicy for AsciiMultiTokenPolicy { + const ALLOW_DOTS: bool = true; + const REQUIRE_ASCII: bool = true; + const MAX_LENGTH: usize = 128; + } + + type SingleToken = NatsToken; + type MultiToken = NatsToken; + type AsciiMultiToken = NatsToken; + + // ── SingleTokenPolicy (no dots, ASCII-only) ──────────────────────── + + #[test] + fn single_valid() { + assert!(SingleToken::new("valid-session-123").is_ok()); + assert!(SingleToken::new("a").is_ok()); + assert_eq!(SingleToken::new("hello").unwrap().as_str(), "hello"); + } + + #[test] + fn single_empty() { + assert_eq!(SingleToken::new(""), Err(SubjectTokenViolation::Empty)); + } + + #[test] + fn single_too_long() { + let long = "a".repeat(129); + assert_eq!( + SingleToken::new(&long), + Err(SubjectTokenViolation::TooLong(129)) + ); + assert!(SingleToken::new("a".repeat(128)).is_ok()); + } + + #[test] + fn single_rejects_dots() { + assert_eq!( + SingleToken::new("a.b"), + Err(SubjectTokenViolation::InvalidCharacter('.')) + ); + } + + #[test] + fn single_rejects_wildcards() { + assert!(SingleToken::new("a*").is_err()); + assert!(SingleToken::new("a>").is_err()); + assert!(SingleToken::new(">").is_err()); + } + + #[test] + fn single_rejects_whitespace() { + assert!(SingleToken::new("a b").is_err()); + assert!(SingleToken::new("a\t").is_err()); + assert!(SingleToken::new("a\n").is_err()); + } + + #[test] + fn single_rejects_non_ascii() { + assert_eq!( + SingleToken::new("séssion"), + Err(SubjectTokenViolation::InvalidCharacter('é')) + ); + } + + // ── MultiTokenPolicy (dots ok, byte-length) ─────────────────────── + + #[test] + fn multi_valid_simple() { + assert!(MultiToken::new("acp").is_ok()); + assert!(MultiToken::new("a").is_ok()); + } + + #[test] + fn multi_valid_dotted() { + assert_eq!( + MultiToken::new("my.multi.part").unwrap().as_str(), + "my.multi.part" + ); + assert!(MultiToken::new("a.b").is_ok()); + assert!(MultiToken::new("vendor.operation").is_ok()); + } + + #[test] + fn multi_empty() { + assert_eq!(MultiToken::new(""), Err(SubjectTokenViolation::Empty)); + } + + #[test] + fn multi_too_long() { + let long = "a".repeat(129); + assert_eq!( + MultiToken::new(&long), + Err(SubjectTokenViolation::TooLong(129)) + ); + assert!(MultiToken::new("a".repeat(128)).is_ok()); + } + + #[test] + fn multi_rejects_wildcards() { + assert!(MultiToken::new("acp.*").is_err()); + assert!(MultiToken::new("acp.>").is_err()); + } + + #[test] + fn multi_rejects_whitespace() { + assert!(MultiToken::new("acp prefix").is_err()); + assert!(MultiToken::new("acp\t").is_err()); + assert!(MultiToken::new("acp\n").is_err()); + } + + #[test] + fn multi_rejects_malformed_dots() { + assert!(MultiToken::new("..method").is_err()); + assert!(MultiToken::new("method..name").is_err()); + assert!(MultiToken::new(".method").is_err()); + assert!(MultiToken::new("method.").is_err()); + assert!(MultiToken::new(".").is_err()); + assert!(MultiToken::new("acp..foo").is_err()); + assert!(MultiToken::new(".acp").is_err()); + assert!(MultiToken::new("acp.").is_err()); + } + + #[test] + fn multi_accepts_non_ascii() { + assert!(MultiToken::new("préfixe").is_ok()); + } + + // ── Trait impls ──────────────────────────────────────────────────── + + #[test] + fn display_and_deref() { + let t = SingleToken::new("my-session").unwrap(); + assert_eq!(format!("{}", t), "my-session"); + assert_eq!(t.len(), 10); + assert!(t.starts_with("my")); + } + + #[test] + fn as_ref_str() { + let t = MultiToken::new("hello").unwrap(); + let s: &str = t.as_ref(); + assert_eq!(s, "hello"); + } + + #[test] + fn clone_and_eq() { + let a = SingleToken::new("abc").unwrap(); + let b = a.clone(); + assert_eq!(a, b); + } + + // ── AsciiMultiTokenPolicy (dots ok, ASCII-only) ───────────────────── + + #[test] + fn ascii_multi_valid_dotted() { + assert_eq!( + AsciiMultiToken::new("my.multi.part").unwrap().as_str(), + "my.multi.part" + ); + assert!(AsciiMultiToken::new("a.b").is_ok()); + assert!(AsciiMultiToken::new("vendor.operation").is_ok()); + } + + #[test] + fn ascii_multi_valid_simple() { + assert!(AsciiMultiToken::new("simple").is_ok()); + assert!(AsciiMultiToken::new("a").is_ok()); + } + + #[test] + fn ascii_multi_rejects_non_ascii() { + assert_eq!( + AsciiMultiToken::new("préfixe"), + Err(SubjectTokenViolation::InvalidCharacter('é')) + ); + } + + #[test] + fn ascii_multi_rejects_malformed_dots() { + assert!(AsciiMultiToken::new("..method").is_err()); + assert!(AsciiMultiToken::new("method..name").is_err()); + assert!(AsciiMultiToken::new(".method").is_err()); + assert!(AsciiMultiToken::new("method.").is_err()); + assert!(AsciiMultiToken::new(".").is_err()); + } + + #[test] + fn ascii_multi_rejects_wildcards() { + assert!(AsciiMultiToken::new("a.*").is_err()); + assert!(AsciiMultiToken::new("a.>").is_err()); + } + + #[test] + fn ascii_multi_rejects_whitespace() { + assert!(AsciiMultiToken::new("a b").is_err()); + assert!(AsciiMultiToken::new("a\t").is_err()); + } +} diff --git a/rsworkspace/crates/trogon-nats/src/subject_token_violation.rs b/rsworkspace/crates/trogon-nats/src/subject_token_violation.rs new file mode 100644 index 000000000..9928bd7fa --- /dev/null +++ b/rsworkspace/crates/trogon-nats/src/subject_token_violation.rs @@ -0,0 +1,7 @@ +/// Describes what went wrong when validating a NATS subject token: empty, invalid character, or too long. +#[derive(Debug, Clone, PartialEq)] +pub enum SubjectTokenViolation { + Empty, + InvalidCharacter(char), + TooLong(usize), +} 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('.') } From 243ba85b29a65b778d3cba0815d5e890817e9c5f Mon Sep 17 00:00:00 2001 From: itsitsiridakis Date: Wed, 1 Apr 2026 11:13:17 +0300 Subject: [PATCH 2/4] yordis request Signed-off-by: itsitsiridakis --- rsworkspace/crates/acp-nats/src/acp_prefix.rs | 7 +- rsworkspace/crates/acp-nats/src/constants.rs | 4 - .../crates/acp-nats/src/ext_method_name.rs | 9 +- rsworkspace/crates/acp-nats/src/lib.rs | 1 - .../acp-nats/src/nats_token_policies.rs | 23 -- rsworkspace/crates/acp-nats/src/session_id.rs | 3 +- rsworkspace/crates/trogon-nats/src/lib.rs | 4 +- .../crates/trogon-nats/src/nats_token.rs | 342 +++++++----------- 8 files changed, 151 insertions(+), 242 deletions(-) delete mode 100644 rsworkspace/crates/acp-nats/src/nats_token_policies.rs diff --git a/rsworkspace/crates/acp-nats/src/acp_prefix.rs b/rsworkspace/crates/acp-nats/src/acp_prefix.rs index a41215ef2..7311e3e3b 100644 --- a/rsworkspace/crates/acp-nats/src/acp_prefix.rs +++ b/rsworkspace/crates/acp-nats/src/acp_prefix.rs @@ -7,9 +7,8 @@ //! malformed dots (consecutive, leading, trailing). Max 128 bytes. Validity is guaranteed at //! construction. -use crate::nats_token_policies::MultiTokenPolicy; use crate::subject_token_violation::SubjectTokenViolation; -use trogon_nats::NatsToken; +use trogon_nats::DottedNatsToken; /// Error returned when [`AcpPrefix`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -33,12 +32,12 @@ impl std::error::Error for AcpPrefixError {} /// NATS-safe ACP prefix. Guarantees validity at construction—invalid instances are unrepresentable. #[derive(Clone, Debug)] -pub struct AcpPrefix(NatsToken); +pub struct AcpPrefix(DottedNatsToken); impl AcpPrefix { pub fn new(s: impl Into) -> Result { let s = s.into(); - NatsToken::new(s).map(Self).map_err(AcpPrefixError) + DottedNatsToken::new(s).map(Self).map_err(AcpPrefixError) } pub fn as_str(&self) -> &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 68f23fa68..8e8ea66e7 100644 --- a/rsworkspace/crates/acp-nats/src/ext_method_name.rs +++ b/rsworkspace/crates/acp-nats/src/ext_method_name.rs @@ -5,9 +5,8 @@ //! rejects `*`, `>`, whitespace; allows dotted namespaces (e.g. `vendor.operation`) but rejects //! malformed dots (consecutive, leading, trailing). Validity is guaranteed at construction. -use crate::nats_token_policies::MultiTokenPolicy; use crate::subject_token_violation::SubjectTokenViolation; -use trogon_nats::NatsToken; +use trogon_nats::DottedNatsToken; /// Error returned when [`ExtMethodName`] validation fails. #[derive(Debug, Clone, PartialEq)] @@ -33,11 +32,13 @@ 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(NatsToken); +pub struct ExtMethodName(DottedNatsToken); impl ExtMethodName { pub fn new(method: impl AsRef) -> Result { - NatsToken::new(method).map(Self).map_err(ExtMethodNameError) + DottedNatsToken::new(method) + .map(Self) + .map_err(ExtMethodNameError) } pub fn as_str(&self) -> &str { diff --git a/rsworkspace/crates/acp-nats/src/lib.rs b/rsworkspace/crates/acp-nats/src/lib.rs index 9ec409af1..30ba66a82 100644 --- a/rsworkspace/crates/acp-nats/src/lib.rs +++ b/rsworkspace/crates/acp-nats/src/lib.rs @@ -10,7 +10,6 @@ pub(crate) mod in_flight_slot_guard; pub mod jetstream; pub(crate) mod jsonrpc; pub mod nats; -pub(crate) mod nats_token_policies; pub(crate) mod pending_prompt_waiters; pub mod session_id; pub mod subject_token_violation; diff --git a/rsworkspace/crates/acp-nats/src/nats_token_policies.rs b/rsworkspace/crates/acp-nats/src/nats_token_policies.rs deleted file mode 100644 index 8a6aab8cf..000000000 --- a/rsworkspace/crates/acp-nats/src/nats_token_policies.rs +++ /dev/null @@ -1,23 +0,0 @@ -use trogon_nats::NatsTokenPolicy; - -/// Single NATS subject token: no dots, ASCII-only, max 128 chars. -/// -/// Used for values embedded as a single token in a subject, e.g. session IDs. -pub struct SingleTokenPolicy; - -impl NatsTokenPolicy for SingleTokenPolicy { - const ALLOW_DOTS: bool = false; - const REQUIRE_ASCII: bool = true; - const MAX_LENGTH: usize = 128; -} - -/// Multi-token NATS subject segment: dots allowed as separators, max 128 bytes. -/// -/// Used for values that may contain dotted namespaces, e.g. prefixes and method names. -pub struct MultiTokenPolicy; - -impl NatsTokenPolicy for MultiTokenPolicy { - const ALLOW_DOTS: bool = true; - const REQUIRE_ASCII: bool = false; - const MAX_LENGTH: usize = 128; -} diff --git a/rsworkspace/crates/acp-nats/src/session_id.rs b/rsworkspace/crates/acp-nats/src/session_id.rs index 0905d5c1f..194c7067b 100644 --- a/rsworkspace/crates/acp-nats/src/session_id.rs +++ b/rsworkspace/crates/acp-nats/src/session_id.rs @@ -5,7 +5,6 @@ //! ASCII only (recommended), rejecting `.` `*` `>` and whitespace (forbidden). Validity is //! guaranteed at construction. -use crate::nats_token_policies::SingleTokenPolicy; use crate::subject_token_violation::SubjectTokenViolation; use trogon_nats::NatsToken; @@ -34,7 +33,7 @@ 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(NatsToken); +pub struct AcpSessionId(NatsToken); impl AcpSessionId { pub fn new(s: impl AsRef) -> Result { diff --git a/rsworkspace/crates/trogon-nats/src/lib.rs b/rsworkspace/crates/trogon-nats/src/lib.rs index b166b4199..1e8f6a060 100644 --- a/rsworkspace/crates/trogon-nats/src/lib.rs +++ b/rsworkspace/crates/trogon-nats/src/lib.rs @@ -44,7 +44,7 @@ pub mod jetstream; pub mod messaging; pub mod nats_token; pub mod subject_token_violation; -pub mod token; +pub(crate) mod token; #[cfg(feature = "test-support")] pub mod mocks; @@ -59,7 +59,7 @@ pub use messaging::{ RetryPolicy, build_request_headers, headers_with_trace_context, inject_trace_context, publish, request, request_with_timeout, }; -pub use nats_token::{NatsToken, NatsTokenPolicy}; +pub use nats_token::{DottedNatsToken, NatsToken}; pub use subject_token_violation::SubjectTokenViolation; #[cfg(feature = "test-support")] diff --git a/rsworkspace/crates/trogon-nats/src/nats_token.rs b/rsworkspace/crates/trogon-nats/src/nats_token.rs index 38ca40254..03b0cf104 100644 --- a/rsworkspace/crates/trogon-nats/src/nats_token.rs +++ b/rsworkspace/crates/trogon-nats/src/nats_token.rs @@ -1,112 +1,100 @@ -//! Generic NATS-safe subject token value object. +//! 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 //! -//! [`NatsToken

`] is parameterized by a [`NatsTokenPolicy`] that encodes the -//! validation flavor (single-token vs. multi-token, ASCII-only vs. UTF-8, etc.). //! All validation happens at construction; invalid instances are unrepresentable. -use std::marker::PhantomData; use std::sync::Arc; use crate::subject_token_violation::SubjectTokenViolation; use crate::token; -/// Policy trait that controls how a [`NatsToken`] is validated. -/// -/// Implemented as associated constants so the compiler can monomorphize away -/// all branching at compile time. -pub trait NatsTokenPolicy { - /// Whether `.` is accepted as a token separator (multi-token mode). - const ALLOW_DOTS: bool; - - /// Whether non-ASCII characters are rejected. - /// When `true`, length is measured in **chars**; otherwise in **bytes**. - const REQUIRE_ASCII: bool; - - /// Maximum permitted length (chars when `REQUIRE_ASCII`, bytes otherwise). - const MAX_LENGTH: usize; -} +const MAX_LENGTH: usize = 128; -/// A validated NATS subject token (or dotted token sequence). -/// -/// Wraps an `Arc` so cloning is cheap. The policy `P` determines which -/// characters and lengths are accepted. +// ── NatsToken (single, ASCII-only) ───────────────────────────────────── + +/// A validated single NATS subject token. /// -/// Trait impls are hand-written so that `P` does not need to implement -/// `Clone`, `Debug`, `PartialEq`, `Eq`, or `Hash`. -pub struct NatsToken(Arc, PhantomData

); +/// 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 Clone for NatsToken

{ - fn clone(&self) -> Self { - Self(self.0.clone(), PhantomData) +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_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::Debug for NatsToken

{ +impl std::fmt::Display for NatsToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("NatsToken").field(&self.0).finish() + write!(f, "{}", self.0) } } -impl PartialEq for NatsToken

{ - fn eq(&self, other: &Self) -> bool { - self.0 == other.0 +impl std::ops::Deref for NatsToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 } } -impl Eq for NatsToken

{} - -impl std::hash::Hash for NatsToken

{ - fn hash(&self, state: &mut H) { - self.0.hash(state); +impl AsRef for NatsToken { + fn as_ref(&self) -> &str { + &self.0 } } -impl NatsToken

{ - /// Validate and construct a new token. +// ── DottedNatsToken (multi-segment, UTF-8) ───────────────────────────── + +/// 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 P::REQUIRE_ASCII { - let mut char_count: usize = 0; - for ch in s.chars() { - char_count += 1; - if char_count > P::MAX_LENGTH { - return Err(SubjectTokenViolation::TooLong(char_count)); - } - if !ch.is_ascii() { - return Err(SubjectTokenViolation::InvalidCharacter(ch)); - } - if ch == '*' || ch == '>' || ch.is_whitespace() { - return Err(SubjectTokenViolation::InvalidCharacter(ch)); - } - if ch == '.' && !P::ALLOW_DOTS { - return Err(SubjectTokenViolation::InvalidCharacter(ch)); - } - } - if P::ALLOW_DOTS && token::has_consecutive_or_boundary_dots(s) { - return Err(SubjectTokenViolation::InvalidCharacter('.')); - } - } else { - if let Some(ch) = token::has_wildcards_or_whitespace(s) { - return Err(SubjectTokenViolation::InvalidCharacter(ch)); - } - if !P::ALLOW_DOTS { - if s.contains('.') { - return Err(SubjectTokenViolation::InvalidCharacter('.')); - } - } else if token::has_consecutive_or_boundary_dots(s) { - return Err(SubjectTokenViolation::InvalidCharacter('.')); - } - if s.len() > P::MAX_LENGTH { - return Err(SubjectTokenViolation::TooLong(s.len())); - } + if let Some(ch) = token::has_wildcards_or_whitespace(s) { + return Err(SubjectTokenViolation::InvalidCharacter(ch)); } - - Ok(Self(s.into(), PhantomData)) + if token::has_consecutive_or_boundary_dots(s) { + return Err(SubjectTokenViolation::InvalidCharacter('.')); + } + if s.len() > MAX_LENGTH { + return Err(SubjectTokenViolation::TooLong(s.len())); + } + Ok(Self(s.into())) } pub fn as_str(&self) -> &str { @@ -114,13 +102,13 @@ impl NatsToken

{ } } -impl std::fmt::Display for NatsToken

{ +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 NatsToken

{ +impl std::ops::Deref for DottedNatsToken { type Target = str; fn deref(&self) -> &Self::Target { @@ -128,7 +116,7 @@ impl std::ops::Deref for NatsToken

{ } } -impl AsRef for NatsToken

{ +impl AsRef for DottedNatsToken { fn as_ref(&self) -> &str { &self.0 } @@ -138,216 +126,166 @@ impl AsRef for NatsToken

{ mod tests { use super::*; - struct SingleTokenPolicy; - impl NatsTokenPolicy for SingleTokenPolicy { - const ALLOW_DOTS: bool = false; - const REQUIRE_ASCII: bool = true; - const MAX_LENGTH: usize = 128; - } - - struct MultiTokenPolicy; - impl NatsTokenPolicy for MultiTokenPolicy { - const ALLOW_DOTS: bool = true; - const REQUIRE_ASCII: bool = false; - const MAX_LENGTH: usize = 128; - } - - struct AsciiMultiTokenPolicy; - impl NatsTokenPolicy for AsciiMultiTokenPolicy { - const ALLOW_DOTS: bool = true; - const REQUIRE_ASCII: bool = true; - const MAX_LENGTH: usize = 128; - } - - type SingleToken = NatsToken; - type MultiToken = NatsToken; - type AsciiMultiToken = NatsToken; - - // ── SingleTokenPolicy (no dots, ASCII-only) ──────────────────────── + // ── NatsToken (single, ASCII-only) ───────────────────────────────── #[test] fn single_valid() { - assert!(SingleToken::new("valid-session-123").is_ok()); - assert!(SingleToken::new("a").is_ok()); - assert_eq!(SingleToken::new("hello").unwrap().as_str(), "hello"); + 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!(SingleToken::new(""), Err(SubjectTokenViolation::Empty)); + assert_eq!(NatsToken::new(""), Err(SubjectTokenViolation::Empty)); } #[test] fn single_too_long() { let long = "a".repeat(129); assert_eq!( - SingleToken::new(&long), + NatsToken::new(&long), Err(SubjectTokenViolation::TooLong(129)) ); - assert!(SingleToken::new("a".repeat(128)).is_ok()); + assert!(NatsToken::new("a".repeat(128)).is_ok()); } #[test] fn single_rejects_dots() { assert_eq!( - SingleToken::new("a.b"), + NatsToken::new("a.b"), Err(SubjectTokenViolation::InvalidCharacter('.')) ); } #[test] fn single_rejects_wildcards() { - assert!(SingleToken::new("a*").is_err()); - assert!(SingleToken::new("a>").is_err()); - assert!(SingleToken::new(">").is_err()); + assert!(NatsToken::new("a*").is_err()); + assert!(NatsToken::new("a>").is_err()); + assert!(NatsToken::new(">").is_err()); } #[test] fn single_rejects_whitespace() { - assert!(SingleToken::new("a b").is_err()); - assert!(SingleToken::new("a\t").is_err()); - assert!(SingleToken::new("a\n").is_err()); + 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!( - SingleToken::new("séssion"), + NatsToken::new("séssion"), Err(SubjectTokenViolation::InvalidCharacter('é')) ); } - // ── MultiTokenPolicy (dots ok, byte-length) ─────────────────────── + // ── DottedNatsToken (multi-segment, UTF-8) ───────────────────────── #[test] - fn multi_valid_simple() { - assert!(MultiToken::new("acp").is_ok()); - assert!(MultiToken::new("a").is_ok()); + fn dotted_valid_simple() { + assert!(DottedNatsToken::new("acp").is_ok()); + assert!(DottedNatsToken::new("a").is_ok()); } #[test] - fn multi_valid_dotted() { + fn dotted_valid_dotted() { assert_eq!( - MultiToken::new("my.multi.part").unwrap().as_str(), + DottedNatsToken::new("my.multi.part").unwrap().as_str(), "my.multi.part" ); - assert!(MultiToken::new("a.b").is_ok()); - assert!(MultiToken::new("vendor.operation").is_ok()); + assert!(DottedNatsToken::new("a.b").is_ok()); + assert!(DottedNatsToken::new("vendor.operation").is_ok()); } #[test] - fn multi_empty() { - assert_eq!(MultiToken::new(""), Err(SubjectTokenViolation::Empty)); + fn dotted_empty() { + assert_eq!(DottedNatsToken::new(""), Err(SubjectTokenViolation::Empty)); } #[test] - fn multi_too_long() { + fn dotted_too_long() { let long = "a".repeat(129); assert_eq!( - MultiToken::new(&long), + DottedNatsToken::new(&long), Err(SubjectTokenViolation::TooLong(129)) ); - assert!(MultiToken::new("a".repeat(128)).is_ok()); + assert!(DottedNatsToken::new("a".repeat(128)).is_ok()); } #[test] - fn multi_rejects_wildcards() { - assert!(MultiToken::new("acp.*").is_err()); - assert!(MultiToken::new("acp.>").is_err()); + fn dotted_rejects_wildcards() { + assert!(DottedNatsToken::new("acp.*").is_err()); + assert!(DottedNatsToken::new("acp.>").is_err()); } #[test] - fn multi_rejects_whitespace() { - assert!(MultiToken::new("acp prefix").is_err()); - assert!(MultiToken::new("acp\t").is_err()); - assert!(MultiToken::new("acp\n").is_err()); + 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 multi_rejects_malformed_dots() { - assert!(MultiToken::new("..method").is_err()); - assert!(MultiToken::new("method..name").is_err()); - assert!(MultiToken::new(".method").is_err()); - assert!(MultiToken::new("method.").is_err()); - assert!(MultiToken::new(".").is_err()); - assert!(MultiToken::new("acp..foo").is_err()); - assert!(MultiToken::new(".acp").is_err()); - assert!(MultiToken::new("acp.").is_err()); + 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 multi_accepts_non_ascii() { - assert!(MultiToken::new("préfixe").is_ok()); + fn dotted_accepts_non_ascii() { + assert!(DottedNatsToken::new("préfixe").is_ok()); } - // ── Trait impls ──────────────────────────────────────────────────── + // ── Shared trait impls ───────────────────────────────────────────── #[test] - fn display_and_deref() { - let t = SingleToken::new("my-session").unwrap(); + 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 as_ref_str() { - let t = MultiToken::new("hello").unwrap(); - let s: &str = t.as_ref(); - assert_eq!(s, "hello"); - } - - #[test] - fn clone_and_eq() { - let a = SingleToken::new("abc").unwrap(); - let b = a.clone(); - assert_eq!(a, b); - } - - // ── AsciiMultiTokenPolicy (dots ok, ASCII-only) ───────────────────── - - #[test] - fn ascii_multi_valid_dotted() { - assert_eq!( - AsciiMultiToken::new("my.multi.part").unwrap().as_str(), - "my.multi.part" - ); - assert!(AsciiMultiToken::new("a.b").is_ok()); - assert!(AsciiMultiToken::new("vendor.operation").is_ok()); - } - - #[test] - fn ascii_multi_valid_simple() { - assert!(AsciiMultiToken::new("simple").is_ok()); - assert!(AsciiMultiToken::new("a").is_ok()); + 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 ascii_multi_rejects_non_ascii() { - assert_eq!( - AsciiMultiToken::new("préfixe"), - Err(SubjectTokenViolation::InvalidCharacter('é')) - ); + fn single_as_ref_str() { + let t = NatsToken::new("hello").unwrap(); + let s: &str = t.as_ref(); + assert_eq!(s, "hello"); } #[test] - fn ascii_multi_rejects_malformed_dots() { - assert!(AsciiMultiToken::new("..method").is_err()); - assert!(AsciiMultiToken::new("method..name").is_err()); - assert!(AsciiMultiToken::new(".method").is_err()); - assert!(AsciiMultiToken::new("method.").is_err()); - assert!(AsciiMultiToken::new(".").is_err()); + fn dotted_as_ref_str() { + let t = DottedNatsToken::new("hello").unwrap(); + let s: &str = t.as_ref(); + assert_eq!(s, "hello"); } #[test] - fn ascii_multi_rejects_wildcards() { - assert!(AsciiMultiToken::new("a.*").is_err()); - assert!(AsciiMultiToken::new("a.>").is_err()); + fn single_clone_and_eq() { + let a = NatsToken::new("abc").unwrap(); + let b = a.clone(); + assert_eq!(a, b); } #[test] - fn ascii_multi_rejects_whitespace() { - assert!(AsciiMultiToken::new("a b").is_err()); - assert!(AsciiMultiToken::new("a\t").is_err()); + fn dotted_clone_and_eq() { + let a = DottedNatsToken::new("a.b.c").unwrap(); + let b = a.clone(); + assert_eq!(a, b); } } From 28a86f4ee0da4249825589daf576cb1ffb857ae2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 1 Apr 2026 15:23:38 -0400 Subject: [PATCH 3/4] refactor: remove subject_token_violation re-export, move MAX_LENGTH to constants Signed-off-by: Yordis Prieto --- rsworkspace/crates/acp-nats/src/acp_prefix.rs | 2 +- rsworkspace/crates/acp-nats/src/ext_method_name.rs | 2 +- rsworkspace/crates/acp-nats/src/lib.rs | 1 - rsworkspace/crates/acp-nats/src/session_id.rs | 2 +- rsworkspace/crates/acp-nats/src/subject_token_violation.rs | 1 - rsworkspace/crates/trogon-nats/src/constants.rs | 2 ++ rsworkspace/crates/trogon-nats/src/nats_token.rs | 7 +++---- 7 files changed, 8 insertions(+), 9 deletions(-) delete mode 100644 rsworkspace/crates/acp-nats/src/subject_token_violation.rs diff --git a/rsworkspace/crates/acp-nats/src/acp_prefix.rs b/rsworkspace/crates/acp-nats/src/acp_prefix.rs index 7311e3e3b..73afca50a 100644 --- a/rsworkspace/crates/acp-nats/src/acp_prefix.rs +++ b/rsworkspace/crates/acp-nats/src/acp_prefix.rs @@ -7,7 +7,7 @@ //! malformed dots (consecutive, leading, trailing). Max 128 bytes. Validity is guaranteed at //! construction. -use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::SubjectTokenViolation; use trogon_nats::DottedNatsToken; /// Error returned when [`AcpPrefix`] validation fails. diff --git a/rsworkspace/crates/acp-nats/src/ext_method_name.rs b/rsworkspace/crates/acp-nats/src/ext_method_name.rs index 8e8ea66e7..09293e568 100644 --- a/rsworkspace/crates/acp-nats/src/ext_method_name.rs +++ b/rsworkspace/crates/acp-nats/src/ext_method_name.rs @@ -5,7 +5,7 @@ //! rejects `*`, `>`, whitespace; allows dotted namespaces (e.g. `vendor.operation`) but rejects //! malformed dots (consecutive, leading, trailing). Validity is guaranteed at construction. -use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::SubjectTokenViolation; use trogon_nats::DottedNatsToken; /// Error returned when [`ExtMethodName`] validation fails. 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/session_id.rs b/rsworkspace/crates/acp-nats/src/session_id.rs index 194c7067b..fda3e6b04 100644 --- a/rsworkspace/crates/acp-nats/src/session_id.rs +++ b/rsworkspace/crates/acp-nats/src/session_id.rs @@ -5,7 +5,7 @@ //! ASCII only (recommended), rejecting `.` `*` `>` and whitespace (forbidden). Validity is //! guaranteed at construction. -use crate::subject_token_violation::SubjectTokenViolation; +use trogon_nats::SubjectTokenViolation; use trogon_nats::NatsToken; /// Error returned when [`AcpSessionId`] validation fails. diff --git a/rsworkspace/crates/acp-nats/src/subject_token_violation.rs b/rsworkspace/crates/acp-nats/src/subject_token_violation.rs deleted file mode 100644 index 9fccaa78a..000000000 --- a/rsworkspace/crates/acp-nats/src/subject_token_violation.rs +++ /dev/null @@ -1 +0,0 @@ -pub use trogon_nats::SubjectTokenViolation; 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/nats_token.rs b/rsworkspace/crates/trogon-nats/src/nats_token.rs index 03b0cf104..748f1ba07 100644 --- a/rsworkspace/crates/trogon-nats/src/nats_token.rs +++ b/rsworkspace/crates/trogon-nats/src/nats_token.rs @@ -9,11 +9,10 @@ use std::sync::Arc; +use crate::constants::MAX_NATS_TOKEN_LENGTH; use crate::subject_token_violation::SubjectTokenViolation; use crate::token; -const MAX_LENGTH: usize = 128; - // ── NatsToken (single, ASCII-only) ───────────────────────────────────── /// A validated single NATS subject token. @@ -33,7 +32,7 @@ impl NatsToken { let mut char_count: usize = 0; for ch in s.chars() { char_count += 1; - if char_count > MAX_LENGTH { + if char_count > MAX_NATS_TOKEN_LENGTH { return Err(SubjectTokenViolation::TooLong(char_count)); } if !ch.is_ascii() || ch == '.' || ch == '*' || ch == '>' || ch.is_whitespace() { @@ -91,7 +90,7 @@ impl DottedNatsToken { if token::has_consecutive_or_boundary_dots(s) { return Err(SubjectTokenViolation::InvalidCharacter('.')); } - if s.len() > MAX_LENGTH { + if s.len() > MAX_NATS_TOKEN_LENGTH { return Err(SubjectTokenViolation::TooLong(s.len())); } Ok(Self(s.into())) From ceef35eb2e1f61ea9a3d0418eab2d607541a3495 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 1 Apr 2026 15:27:49 -0400 Subject: [PATCH 4/4] style: remove decorative separator comments Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-nats/src/nats_token.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/rsworkspace/crates/trogon-nats/src/nats_token.rs b/rsworkspace/crates/trogon-nats/src/nats_token.rs index 748f1ba07..c913484c7 100644 --- a/rsworkspace/crates/trogon-nats/src/nats_token.rs +++ b/rsworkspace/crates/trogon-nats/src/nats_token.rs @@ -13,8 +13,6 @@ use crate::constants::MAX_NATS_TOKEN_LENGTH; use crate::subject_token_violation::SubjectTokenViolation; use crate::token; -// ── NatsToken (single, ASCII-only) ───────────────────────────────────── - /// A validated single NATS subject token. /// /// Rejects empty, non-ASCII, dots, wildcards (`*`, `>`), and whitespace. @@ -67,8 +65,6 @@ impl AsRef for NatsToken { } } -// ── DottedNatsToken (multi-segment, UTF-8) ───────────────────────────── - /// A validated dotted NATS subject segment. /// /// Allows `.` as a token separator but rejects malformed dots (consecutive, @@ -125,8 +121,6 @@ impl AsRef for DottedNatsToken { mod tests { use super::*; - // ── NatsToken (single, ASCII-only) ───────────────────────────────── - #[test] fn single_valid() { assert!(NatsToken::new("valid-session-123").is_ok()); @@ -179,8 +173,6 @@ mod tests { ); } - // ── DottedNatsToken (multi-segment, UTF-8) ───────────────────────── - #[test] fn dotted_valid_simple() { assert!(DottedNatsToken::new("acp").is_ok()); @@ -242,8 +234,6 @@ mod tests { assert!(DottedNatsToken::new("préfixe").is_ok()); } - // ── Shared trait impls ───────────────────────────────────────────── - #[test] fn single_display_and_deref() { let t = NatsToken::new("my-session").unwrap();