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}