Skip to main content

khive_types/
namespace.rs

1//! Namespace — validated string-based scoping for substrate records.
2//!
3//! In khive OSS, namespace is a plain string (e.g., `"local"`, `"research"`,
4//! `"lattice-project"`). It groups records and supports cross-namespace
5//! queries via the entity graph.
6//!
7//! Multi-tenant deployments (hosted khive deployments) add capability-based
8//! access controls on top in a separate crate — those are not part of the
9//! open-source runtime.
10
11extern crate alloc;
12use alloc::string::String;
13use core::fmt;
14
15/// Validation error returned when a namespace string is rejected.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum NamespaceError {
18    Empty,
19    TooLong { max: usize },
20    InvalidCharacter { ch: char },
21    EmptySegment,
22    TrailingSeparator,
23}
24
25impl fmt::Display for NamespaceError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::Empty => f.write_str("namespace must not be empty"),
29            Self::TooLong { max } => write!(f, "namespace exceeds {max} characters"),
30            Self::InvalidCharacter { ch } => {
31                write!(f, "namespace contains invalid character {ch:?}")
32            }
33            Self::EmptySegment => f.write_str("namespace must not contain empty path segments"),
34            Self::TrailingSeparator => f.write_str("namespace must not end with ':'"),
35        }
36    }
37}
38
39#[cfg(feature = "std")]
40impl std::error::Error for NamespaceError {}
41
42fn validate_namespace(value: &str) -> Result<(), NamespaceError> {
43    const MAX_LEN: usize = 256;
44    if value.is_empty() {
45        return Err(NamespaceError::Empty);
46    }
47    if value.len() > MAX_LEN {
48        return Err(NamespaceError::TooLong { max: MAX_LEN });
49    }
50    if value.ends_with(':') {
51        return Err(NamespaceError::TrailingSeparator);
52    }
53    for segment in value.split(':') {
54        if segment.is_empty() {
55            return Err(NamespaceError::EmptySegment);
56        }
57        for ch in segment.chars() {
58            if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' && ch != '.' {
59                return Err(NamespaceError::InvalidCharacter { ch });
60            }
61        }
62    }
63    Ok(())
64}
65
66/// A validated, opaque namespace identifier.
67///
68/// Construct via [`Namespace::parse`] or [`Namespace::local`]. The absence of
69/// `From<String>` / `From<&str>` impls is intentional — callers must validate.
70#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
71pub struct Namespace(String);
72
73impl Namespace {
74    /// The name of the default local namespace.
75    pub const LOCAL: &'static str = "local";
76
77    /// Parse and validate a namespace string.
78    ///
79    /// Returns `Err(NamespaceError)` if the string is empty, too long, contains
80    /// invalid characters, has empty segments, or ends with `:`.
81    pub fn parse(value: &str) -> Result<Self, NamespaceError> {
82        validate_namespace(value)?;
83        Ok(Self(String::from(value)))
84    }
85
86    /// Construct the default `"local"` namespace (always valid; no allocation).
87    pub fn local() -> Self {
88        Self(String::from(Self::LOCAL))
89    }
90
91    #[inline]
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95
96    pub fn into_inner(self) -> String {
97        self.0
98    }
99}
100
101impl core::convert::TryFrom<String> for Namespace {
102    type Error = NamespaceError;
103
104    fn try_from(value: String) -> Result<Self, Self::Error> {
105        Self::parse(&value)
106    }
107}
108
109impl core::convert::TryFrom<&str> for Namespace {
110    type Error = NamespaceError;
111
112    fn try_from(value: &str) -> Result<Self, Self::Error> {
113        Self::parse(value)
114    }
115}
116
117impl fmt::Display for Namespace {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.write_str(&self.0)
120    }
121}
122
123impl AsRef<str> for Namespace {
124    #[inline]
125    fn as_ref(&self) -> &str {
126        &self.0
127    }
128}
129
130/// Returns `true` if `child` is a hierarchical prefix-descendant of `parent`.
131///
132/// Example: `"research:lattice"` is a prefix-child of `"research"`.
133pub fn has_segment_prefix(child: &Namespace, parent: &Namespace) -> bool {
134    let c = child.as_str();
135    let p = parent.as_str();
136    c.len() > p.len() && c.starts_with(p) && c.as_bytes().get(p.len()) == Some(&b':')
137}
138
139#[cfg(feature = "serde")]
140mod serde_impl {
141    use super::*;
142    use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
143
144    impl Serialize for Namespace {
145        fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
146            s.serialize_str(&self.0)
147        }
148    }
149
150    impl<'de> Deserialize<'de> for Namespace {
151        fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
152            let s = String::deserialize(d)?;
153            Namespace::parse(&s).map_err(de::Error::custom)
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn parse_valid_namespace() {
164        let ns = Namespace::parse("research").unwrap();
165        assert_eq!(ns.as_str(), "research");
166    }
167
168    #[test]
169    fn local_is_local() {
170        assert_eq!(Namespace::local().as_str(), "local");
171    }
172
173    #[test]
174    fn parse_hierarchical_namespace() {
175        let ns = Namespace::parse("research:lattice").unwrap();
176        assert_eq!(ns.as_str(), "research:lattice");
177    }
178
179    #[test]
180    fn parse_empty_returns_error() {
181        assert_eq!(Namespace::parse(""), Err(NamespaceError::Empty));
182    }
183
184    #[test]
185    fn parse_trailing_separator_returns_error() {
186        assert_eq!(
187            Namespace::parse("research:"),
188            Err(NamespaceError::TrailingSeparator)
189        );
190    }
191
192    #[test]
193    fn parse_double_colon_returns_empty_segment() {
194        assert_eq!(Namespace::parse("a::b"), Err(NamespaceError::EmptySegment));
195    }
196
197    #[test]
198    fn parse_invalid_char_returns_error() {
199        assert!(matches!(
200            Namespace::parse("bad namespace"),
201            Err(NamespaceError::InvalidCharacter { ch: ' ' })
202        ));
203    }
204
205    #[test]
206    fn try_from_string() {
207        use core::convert::TryFrom;
208        let ns = Namespace::try_from(String::from("my-ns")).unwrap();
209        assert_eq!(ns.as_str(), "my-ns");
210    }
211
212    #[test]
213    fn has_segment_prefix_detects_child() {
214        let parent = Namespace::parse("research").unwrap();
215        let child = Namespace::parse("research:lattice").unwrap();
216        let sibling = Namespace::parse("other").unwrap();
217
218        assert!(has_segment_prefix(&child, &parent));
219        assert!(!has_segment_prefix(&sibling, &parent));
220        assert!(!has_segment_prefix(&parent, &parent));
221    }
222
223    #[cfg(feature = "serde")]
224    #[test]
225    fn serde_roundtrip() {
226        let ns = Namespace::parse("proj-123").unwrap();
227        let json = serde_json::to_string(&ns).unwrap();
228        let back: Namespace = serde_json::from_str(&json).unwrap();
229        assert_eq!(ns, back);
230    }
231
232    #[cfg(feature = "serde")]
233    #[test]
234    fn serde_deserialize_rejects_invalid() {
235        let result: Result<Namespace, _> = serde_json::from_str("\"\"");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn parse_slash_is_rejected() {
241        // Forward slashes are not in the allowed charset (alphanumeric, `-`, `_`, `.`).
242        assert!(matches!(
243            Namespace::parse("tenant/sub"),
244            Err(NamespaceError::InvalidCharacter { ch: '/' })
245        ));
246    }
247
248    #[test]
249    fn parse_unicode_is_rejected() {
250        // Only ASCII characters are allowed; non-ASCII (e.g. accented letters) must fail.
251        assert!(matches!(
252            Namespace::parse("café"),
253            Err(NamespaceError::InvalidCharacter { .. })
254        ));
255    }
256
257    #[test]
258    fn parse_dot_is_valid() {
259        // Dots are explicitly allowed to support version-style namespaces like "v1.5".
260        let ns = Namespace::parse("v1.5").unwrap();
261        assert_eq!(ns.as_str(), "v1.5");
262    }
263
264    #[test]
265    fn parse_too_long_is_rejected() {
266        let long = "a".repeat(257);
267        assert!(matches!(
268            Namespace::parse(&long),
269            Err(NamespaceError::TooLong { .. })
270        ));
271    }
272
273    #[test]
274    fn parse_exactly_256_chars_is_valid() {
275        let max = "a".repeat(256);
276        assert!(Namespace::parse(&max).is_ok());
277    }
278}