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