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}