Skip to main content

dora_message/
id.rs

1use std::{borrow::Borrow, convert::Infallible, str::FromStr};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// Validate that a node identifier contains only safe characters: `[a-zA-Z0-9_.-]`,
7/// does not start with `.`, is not empty, and is not the reserved id `dora`.
8///
9/// NodeIds must NOT contain `/` because `/` is the separator between
10/// `<node_id>/<output_id>` in input mapping syntax.
11///
12/// The exact id `dora` is reserved for the built-in input namespaces
13/// (`dora/timer/...`, `dora/logs`), which `InputMapping::from_str` matches
14/// before any user node. Only the exact string is reserved -- ids that merely
15/// contain or extend it (`dora-node`, `my-dora`, `Dora`) stay valid.
16///
17/// Leading dots are rejected because the node id is joined into filesystem paths
18/// (e.g. `managed_python_env_dir` appends `node.id` under `.dora/python-envs/`).
19/// A node id of `..` or `.` would resolve to a parent directory, and
20/// `uv venv --clear` would then wipe that directory — destroying sibling envs.
21/// This mirrors the `is_valid_key_part` guard in the hub-client index.
22fn validate_node_id(id: &str) -> Result<(), InvalidId> {
23    if id.is_empty() {
24        return Err(InvalidId("identifier must not be empty".into()));
25    }
26    // `dora` is reserved for built-in input namespaces (`dora/timer/...`,
27    // `dora/logs`). A user node literally named `dora` would be silently
28    // unusable as an input source: `InputMapping::from_str` treats any
29    // `dora/<output>` mapping as a built-in and fails to parse the
30    // subscription. Reject the id up front with a clear message instead of
31    // surfacing an opaque input-parse error later.
32    if id == "dora" {
33        return Err(InvalidId(
34            "identifier 'dora' is reserved for built-in inputs \
35             (dora/timer/..., dora/logs) and cannot be used as a node id"
36                .into(),
37        ));
38    }
39    if id.starts_with('.') {
40        return Err(InvalidId(format!(
41            "identifier '{id}' must not start with '.' \
42             (dot-segments such as '.' and '..' can traverse parent directories)"
43        )));
44    }
45    if let Some(ch) = id
46        .chars()
47        .find(|c| !c.is_ascii_alphanumeric() && *c != '_' && *c != '-' && *c != '.')
48    {
49        return Err(InvalidId(format!(
50            "identifier contains invalid character '{ch}' -- only [a-zA-Z0-9_.-] are allowed"
51        )));
52    }
53    Ok(())
54}
55
56/// Validate that a data identifier contains only safe characters: `[a-zA-Z0-9_./-]`.
57///
58/// DataIds allow `/` because runtime-node operator outputs are
59/// namespaced as `<operator-id>/<output-name>` (e.g. `rust-operator/status`).
60/// The input mapping syntax `<node-id>/<output-id>` splits on the
61/// FIRST `/`, so subsequent slashes are part of the DataId.
62fn validate_data_id(id: &str) -> Result<(), InvalidId> {
63    if id.is_empty() {
64        return Err(InvalidId("identifier must not be empty".into()));
65    }
66    if let Some(ch) = id
67        .chars()
68        .find(|c| !c.is_ascii_alphanumeric() && *c != '_' && *c != '-' && *c != '.' && *c != '/')
69    {
70        return Err(InvalidId(format!(
71            "identifier contains invalid character '{ch}' -- only [a-zA-Z0-9_./-] are allowed"
72        )));
73    }
74    // Reject malformed slash patterns that would produce empty segments
75    // when split (e.g. "op/", "/out", "a//b"). These would cause panics
76    // in downstream code that does DataId::from(segment) on the split
77    // result (e.g. descriptor/validate.rs:379).
78    if id.starts_with('/') || id.ends_with('/') || id.contains("//") {
79        return Err(InvalidId(format!(
80            "identifier '{id}' has empty path segment -- \
81             leading, trailing, or consecutive '/' are not allowed"
82        )));
83    }
84    Ok(())
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct InvalidId(pub String);
89
90impl std::fmt::Display for InvalidId {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{}", self.0)
93    }
94}
95
96impl std::error::Error for InvalidId {}
97
98/// A validated node identifier.
99///
100/// A `NodeId` may contain only `[a-zA-Z0-9_.-]`, must be non-empty, and must
101/// not start with `.` (dot-segments like `.` or `..` could traverse into a
102/// parent directory when the id is joined into a filesystem path). Unlike
103/// [`DataId`], a `NodeId` may **not** contain `/`, which separates
104/// `<node_id>/<output_id>` in input-mapping syntax.
105///
106/// The exact id `dora` is additionally reserved: it names the built-in input
107/// namespaces (`dora/timer/...`, `dora/logs`), which input-mapping parsing
108/// matches before any user node, so a node called `dora` could never be
109/// subscribed to. Only the exact string is reserved — `dora-node`, `my-dora`
110/// and `Dora` all remain valid.
111///
112/// # Parsing vs. conversion (panic footgun)
113///
114/// Use [`str::parse`] / [`FromStr`](std::str::FromStr) for untrusted input: it
115/// returns `Result<NodeId, InvalidId>`. The `From<String>` conversion — and
116/// therefore `.into()` and the auto-derived `TryFrom<String>` — **panics** on
117/// an invalid id.
118///
119/// ```
120/// use dora_message::id::NodeId;
121///
122/// // Fallible path — always safe for untrusted input:
123/// assert!("camera_node".parse::<NodeId>().is_ok());
124/// assert!("node/out".parse::<NodeId>().is_err()); // '/' is not allowed in a NodeId
125/// assert!("".parse::<NodeId>().is_err());         // empty is rejected
126/// assert!(".hidden".parse::<NodeId>().is_err());  // leading '.' is rejected
127/// assert!("dora".parse::<NodeId>().is_err());     // reserved for built-in inputs
128/// assert!("dora-node".parse::<NodeId>().is_ok()); // only the exact id is reserved
129/// ```
130///
131/// The infallible-looking conversion panics on the same invalid input:
132///
133/// ```should_panic
134/// use dora_message::id::NodeId;
135/// let _ = NodeId::from("node/out".to_string()); // panics — prefer .parse()
136/// ```
137#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, JsonSchema)]
138pub struct NodeId(pub(crate) String);
139
140impl<'de> Deserialize<'de> for NodeId {
141    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
142    where
143        D: serde::Deserializer<'de>,
144    {
145        let s = String::deserialize(deserializer)?;
146        validate_node_id(&s).map_err(serde::de::Error::custom)?;
147        Ok(NodeId(s))
148    }
149}
150
151impl FromStr for NodeId {
152    type Err = InvalidId;
153
154    fn from_str(s: &str) -> Result<Self, Self::Err> {
155        validate_node_id(s)?;
156        Ok(Self(s.to_owned()))
157    }
158}
159
160/// # Panics
161///
162/// Panics if `id` is not a valid node id: if it is empty, contains
163/// characters outside `[a-zA-Z0-9_.-]`, starts with `.`, or is the
164/// reserved id `dora`. Pre-validating against the character set alone is
165/// *not* sufficient to make this conversion infallible.
166///
167/// **For untrusted input, use `id.parse::<NodeId>()`** which calls
168/// `FromStr::from_str` and returns `Result<Self, InvalidId>`.
169///
170/// Do NOT use `NodeId::try_from(s)`: `TryFrom<String>` is the
171/// auto-derived blanket impl that delegates to this `From` impl, so it
172/// panics exactly like `.into()`. Only `parse::<NodeId>()` / `from_str`
173/// is fallible.
174impl From<String> for NodeId {
175    fn from(id: String) -> Self {
176        if let Err(e) = validate_node_id(&id) {
177            panic!("invalid NodeId '{id}': {e}");
178        }
179        Self(id)
180    }
181}
182
183impl std::fmt::Display for NodeId {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        std::fmt::Display::fmt(&self.0, f)
186    }
187}
188
189impl AsRef<str> for NodeId {
190    fn as_ref(&self) -> &str {
191        &self.0
192    }
193}
194
195/// The identifier of an operator running inside a `dora runtime` node.
196///
197/// An operator is addressed as `<node_id>/<operator_id>/<output_id>`, so an
198/// `OperatorId` is the middle segment of a runtime output's fully-qualified
199/// name.
200///
201/// Unlike [`NodeId`] and [`DataId`], an `OperatorId` is **not** validated:
202/// [`FromStr`] is [`Infallible`] and [`From<String>`] accepts any string
203/// verbatim (this is why it derives `Deserialize` directly rather than through
204/// a validating deserializer). Callers are responsible for not embedding a `/`
205/// in an operator id — a `/` collides with the `<node>/<operator>/<output>`
206/// addressing separator and makes the operator unaddressable (it would resolve
207/// to a different operator/output split).
208///
209/// ```
210/// use dora_message::id::OperatorId;
211///
212/// // Construction is infallible from both `&str` and `String`:
213/// let from_str: OperatorId = "detector".parse().unwrap(); // FromStr is Infallible
214/// let from_string = OperatorId::from("detector".to_string());
215/// assert_eq!(from_str, from_string);
216///
217/// // Display and AsRef expose the underlying id:
218/// assert_eq!(from_str.to_string(), "detector");
219/// assert_eq!(from_str.as_ref(), "detector");
220/// ```
221#[derive(
222    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
223)]
224pub struct OperatorId(String);
225
226impl FromStr for OperatorId {
227    type Err = Infallible;
228
229    fn from_str(s: &str) -> Result<Self, Self::Err> {
230        Ok(Self(s.to_owned()))
231    }
232}
233
234impl From<String> for OperatorId {
235    fn from(id: String) -> Self {
236        Self(id)
237    }
238}
239
240impl std::fmt::Display for OperatorId {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        std::fmt::Display::fmt(&self.0, f)
243    }
244}
245
246impl AsRef<str> for OperatorId {
247    fn as_ref(&self) -> &str {
248        &self.0
249    }
250}
251
252/// A validated data (output) identifier.
253///
254/// A `DataId` may contain only `[a-zA-Z0-9_./-]` and must be non-empty. Unlike
255/// [`NodeId`], a `DataId` **may** contain `/` (runtime-operator outputs are
256/// namespaced as `<operator-id>/<output-name>`), but leading, trailing, or
257/// consecutive slashes — which would produce empty path segments — are
258/// rejected.
259///
260/// # Parsing vs. conversion (panic footgun)
261///
262/// Use [`str::parse`] / [`FromStr`](std::str::FromStr) for untrusted input: it
263/// returns `Result<DataId, InvalidId>`. The `From<String>` / `From<&str>`
264/// conversions — and therefore `.into()` and the auto-derived `TryFrom` —
265/// **panic** on an invalid id.
266///
267/// ```
268/// use dora_message::id::DataId;
269///
270/// assert!("image".parse::<DataId>().is_ok());
271/// assert!("op/status".parse::<DataId>().is_ok()); // '/' is allowed in a DataId
272/// assert!("a//b".parse::<DataId>().is_err());     // empty path segment rejected
273/// assert!("/out".parse::<DataId>().is_err());     // leading '/' rejected
274/// assert!("bad id".parse::<DataId>().is_err());   // space rejected
275/// ```
276///
277/// The infallible-looking conversion panics on the same invalid input:
278///
279/// ```should_panic
280/// use dora_message::id::DataId;
281/// let _ = DataId::from("a//b".to_string()); // panics — prefer .parse()
282/// ```
283#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, JsonSchema)]
284pub struct DataId(String);
285
286impl<'de> Deserialize<'de> for DataId {
287    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288    where
289        D: serde::Deserializer<'de>,
290    {
291        let s = String::deserialize(deserializer)?;
292        validate_data_id(&s).map_err(serde::de::Error::custom)?;
293        Ok(DataId(s))
294    }
295}
296
297impl FromStr for DataId {
298    type Err = InvalidId;
299
300    fn from_str(s: &str) -> Result<Self, Self::Err> {
301        validate_data_id(s)?;
302        Ok(Self(s.to_owned()))
303    }
304}
305
306impl From<DataId> for String {
307    fn from(id: DataId) -> Self {
308        id.0
309    }
310}
311
312/// # Panics
313///
314/// Panics if `id` is not a valid data id: if it is empty, contains
315/// characters outside `[a-zA-Z0-9_./-]`, or has an empty path segment (a
316/// leading, trailing, or consecutive `/`). Pre-validating against the
317/// character set alone is *not* sufficient to make this conversion
318/// infallible.
319///
320/// **For untrusted input, use `id.parse::<DataId>()`** which calls
321/// `FromStr::from_str` and returns `Result<Self, InvalidId>`.
322///
323/// Do NOT use `DataId::try_from(s)`: `TryFrom<String>` is the
324/// auto-derived blanket impl that delegates to this `From` impl, so it
325/// panics exactly like `.into()`. Only `parse::<DataId>()` / `from_str`
326/// is fallible.
327///
328/// ```should_panic
329/// use dora_message::id::DataId;
330/// // A trailing '/' contains only valid characters but is still rejected
331/// // (empty path segment), so this conversion panics.
332/// let _ = DataId::from("op/".to_string());
333/// ```
334impl From<String> for DataId {
335    fn from(id: String) -> Self {
336        if let Err(e) = validate_data_id(&id) {
337            panic!("invalid DataId '{id}': {e}");
338        }
339        Self(id)
340    }
341}
342
343/// # Panics
344///
345/// Panics if `id` is not a valid data id: if it is empty, contains
346/// characters outside `[a-zA-Z0-9_./-]`, or has an empty path segment (a
347/// leading, trailing, or consecutive `/`). Prefer `id.parse::<DataId>()`
348/// when handling untrusted input.
349impl From<&str> for DataId {
350    fn from(id: &str) -> Self {
351        id.to_owned().into()
352    }
353}
354
355impl std::fmt::Display for DataId {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        std::fmt::Display::fmt(&self.0, f)
358    }
359}
360
361impl std::ops::Deref for DataId {
362    type Target = String;
363
364    fn deref(&self) -> &Self::Target {
365        &self.0
366    }
367}
368
369impl AsRef<String> for DataId {
370    fn as_ref(&self) -> &String {
371        &self.0
372    }
373}
374
375impl AsRef<str> for DataId {
376    fn as_ref(&self) -> &str {
377        &self.0
378    }
379}
380
381impl Borrow<String> for DataId {
382    fn borrow(&self) -> &String {
383        &self.0
384    }
385}
386
387impl Borrow<str> for DataId {
388    fn borrow(&self) -> &str {
389        &self.0
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn valid_node_ids() {
399        assert!(validate_node_id("my-node").is_ok());
400        assert!(validate_node_id("my_node").is_ok());
401        assert!(validate_node_id("MyNode123").is_ok());
402        assert!(validate_node_id("node.v2").is_ok());
403        assert!(validate_node_id("a").is_ok());
404    }
405
406    #[test]
407    fn invalid_node_ids() {
408        assert!(validate_node_id("").is_err());
409        assert!(validate_node_id("node/output").is_err());
410        assert!(validate_node_id("node name").is_err());
411        assert!(validate_node_id("node;rm").is_err());
412        assert!(validate_node_id("node\0").is_err());
413    }
414
415    #[test]
416    fn node_id_rejects_reserved_dora() {
417        // `dora` collides with the built-in input namespace and would make the
418        // node's outputs unsubscribable, so it is rejected up front.
419        assert!(validate_node_id("dora").is_err(), "dora must be rejected");
420        // Names that merely contain or extend `dora` are still fine.
421        assert!(validate_node_id("dora-node").is_ok());
422        assert!(validate_node_id("my-dora").is_ok());
423        assert!(validate_node_id("Dora").is_ok());
424    }
425
426    #[test]
427    fn node_id_rejects_dot_segments() {
428        // Dot-only ids resolve to parent directory when joined into filesystem paths
429        assert!(validate_node_id(".").is_err(), ". must be rejected");
430        assert!(validate_node_id("..").is_err(), ".. must be rejected");
431        // Any leading dot is rejected (mirrors hub-client is_valid_key_part)
432        assert!(
433            validate_node_id(".hidden").is_err(),
434            ".hidden must be rejected"
435        );
436        assert!(
437            validate_node_id(".config").is_err(),
438            ".config must be rejected"
439        );
440        // Embedded dots are still fine
441        assert!(
442            validate_node_id("node.v2").is_ok(),
443            "embedded dot is allowed"
444        );
445        assert!(
446            validate_node_id("a.b.c").is_ok(),
447            "multiple embedded dots are allowed"
448        );
449    }
450
451    #[test]
452    fn data_id_allows_slash_for_operators() {
453        // Operator outputs are namespaced: `operator-id/output-name`
454        assert!(validate_data_id("rust-operator/status").is_ok());
455        assert!(validate_data_id("op/output").is_ok());
456        assert!(validate_data_id("simple").is_ok());
457    }
458
459    #[test]
460    fn data_id_rejects_other_specials() {
461        assert!(validate_data_id("").is_err());
462        assert!(validate_data_id("has space").is_err());
463        assert!(validate_data_id("semi;colon").is_err());
464    }
465
466    #[test]
467    fn data_id_rejects_malformed_slash_patterns() {
468        assert!(validate_data_id("op/").is_err(), "trailing slash");
469        assert!(validate_data_id("/out").is_err(), "leading slash");
470        assert!(validate_data_id("a//b").is_err(), "double slash");
471        assert!(validate_data_id("/").is_err(), "bare slash");
472    }
473
474    #[test]
475    fn node_id_from_str_rejects_invalid() {
476        assert!(NodeId::from_str("hello").is_ok());
477        assert!(NodeId::from_str("hello/world").is_err());
478        assert!(NodeId::from_str("hello world").is_err());
479        assert!(NodeId::from_str("").is_err());
480    }
481
482    #[test]
483    #[should_panic(expected = "invalid NodeId")]
484    fn node_id_from_string_panics_on_invalid() {
485        let _id: NodeId = "bad/id".to_string().into();
486    }
487
488    #[test]
489    fn node_id_parse_rejects_invalid() {
490        assert!("hello".parse::<NodeId>().is_ok());
491        assert!("bad/id".parse::<NodeId>().is_err());
492        assert!("".parse::<NodeId>().is_err());
493    }
494
495    #[test]
496    fn data_id_parse_rejects_invalid() {
497        assert!("output".parse::<DataId>().is_ok());
498        assert!("bad;id".parse::<DataId>().is_err());
499    }
500
501    #[test]
502    fn node_id_deserialize_rejects_invalid() {
503        let result: Result<NodeId, _> = serde_json::from_str("\"bad/id\"");
504        assert!(result.is_err());
505    }
506
507    #[test]
508    fn data_id_deserialize_rejects_invalid() {
509        let result: Result<DataId, _> = serde_json::from_str("\"bad;id\"");
510        assert!(result.is_err());
511    }
512}