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 `.`, and is not empty.
8///
9/// NodeIds must NOT contain `/` because `/` is the separator between
10/// `<node_id>/<output_id>` in input mapping syntax.
11///
12/// Leading dots are rejected because the node id is joined into filesystem paths
13/// (e.g. `managed_python_env_dir` appends `node.id` under `.dora/python-envs/`).
14/// A node id of `..` or `.` would resolve to a parent directory, and
15/// `uv venv --clear` would then wipe that directory — destroying sibling envs.
16/// This mirrors the `is_valid_key_part` guard in the hub-client index.
17fn validate_node_id(id: &str) -> Result<(), InvalidId> {
18    if id.is_empty() {
19        return Err(InvalidId("identifier must not be empty".into()));
20    }
21    if id.starts_with('.') {
22        return Err(InvalidId(format!(
23            "identifier '{id}' must not start with '.' \
24             (dot-segments such as '.' and '..' can traverse parent directories)"
25        )));
26    }
27    if let Some(ch) = id
28        .chars()
29        .find(|c| !c.is_ascii_alphanumeric() && *c != '_' && *c != '-' && *c != '.')
30    {
31        return Err(InvalidId(format!(
32            "identifier contains invalid character '{ch}' -- only [a-zA-Z0-9_.-] are allowed"
33        )));
34    }
35    Ok(())
36}
37
38/// Validate that a data identifier contains only safe characters: `[a-zA-Z0-9_./-]`.
39///
40/// DataIds allow `/` because runtime-node operator outputs are
41/// namespaced as `<operator-id>/<output-name>` (e.g. `rust-operator/status`).
42/// The input mapping syntax `<node-id>/<output-id>` splits on the
43/// FIRST `/`, so subsequent slashes are part of the DataId.
44fn validate_data_id(id: &str) -> Result<(), InvalidId> {
45    if id.is_empty() {
46        return Err(InvalidId("identifier must not be empty".into()));
47    }
48    if let Some(ch) = id
49        .chars()
50        .find(|c| !c.is_ascii_alphanumeric() && *c != '_' && *c != '-' && *c != '.' && *c != '/')
51    {
52        return Err(InvalidId(format!(
53            "identifier contains invalid character '{ch}' -- only [a-zA-Z0-9_./-] are allowed"
54        )));
55    }
56    // Reject malformed slash patterns that would produce empty segments
57    // when split (e.g. "op/", "/out", "a//b"). These would cause panics
58    // in downstream code that does DataId::from(segment) on the split
59    // result (e.g. descriptor/validate.rs:379).
60    if id.starts_with('/') || id.ends_with('/') || id.contains("//") {
61        return Err(InvalidId(format!(
62            "identifier '{id}' has empty path segment -- \
63             leading, trailing, or consecutive '/' are not allowed"
64        )));
65    }
66    Ok(())
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct InvalidId(pub String);
71
72impl std::fmt::Display for InvalidId {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}", self.0)
75    }
76}
77
78impl std::error::Error for InvalidId {}
79
80#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, JsonSchema)]
81pub struct NodeId(pub(crate) String);
82
83impl<'de> Deserialize<'de> for NodeId {
84    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
85    where
86        D: serde::Deserializer<'de>,
87    {
88        let s = String::deserialize(deserializer)?;
89        validate_node_id(&s).map_err(serde::de::Error::custom)?;
90        Ok(NodeId(s))
91    }
92}
93
94impl FromStr for NodeId {
95    type Err = InvalidId;
96
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        validate_node_id(s)?;
99        Ok(Self(s.to_owned()))
100    }
101}
102
103/// # Panics
104///
105/// Panics if `id` contains invalid characters (not in `[a-zA-Z0-9_.-]`).
106///
107/// **For untrusted input, use `id.parse::<NodeId>()`** which calls
108/// `FromStr::from_str` and returns `Result<Self, InvalidId>`.
109///
110/// Do NOT use `NodeId::try_from(s)`: `TryFrom<String>` is the
111/// auto-derived blanket impl that delegates to this `From` impl, so it
112/// panics exactly like `.into()`. Only `parse::<NodeId>()` / `from_str`
113/// is fallible.
114impl From<String> for NodeId {
115    fn from(id: String) -> Self {
116        if let Err(e) = validate_node_id(&id) {
117            panic!("invalid NodeId '{id}': {e}");
118        }
119        Self(id)
120    }
121}
122
123impl std::fmt::Display for NodeId {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        std::fmt::Display::fmt(&self.0, f)
126    }
127}
128
129impl AsRef<str> for NodeId {
130    fn as_ref(&self) -> &str {
131        &self.0
132    }
133}
134
135#[derive(
136    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
137)]
138pub struct OperatorId(String);
139
140impl FromStr for OperatorId {
141    type Err = Infallible;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        Ok(Self(s.to_owned()))
145    }
146}
147
148impl From<String> for OperatorId {
149    fn from(id: String) -> Self {
150        Self(id)
151    }
152}
153
154impl std::fmt::Display for OperatorId {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        std::fmt::Display::fmt(&self.0, f)
157    }
158}
159
160impl AsRef<str> for OperatorId {
161    fn as_ref(&self) -> &str {
162        &self.0
163    }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, JsonSchema)]
167pub struct DataId(String);
168
169impl<'de> Deserialize<'de> for DataId {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: serde::Deserializer<'de>,
173    {
174        let s = String::deserialize(deserializer)?;
175        validate_data_id(&s).map_err(serde::de::Error::custom)?;
176        Ok(DataId(s))
177    }
178}
179
180impl FromStr for DataId {
181    type Err = InvalidId;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        validate_data_id(s)?;
185        Ok(Self(s.to_owned()))
186    }
187}
188
189impl From<DataId> for String {
190    fn from(id: DataId) -> Self {
191        id.0
192    }
193}
194
195/// # Panics
196///
197/// Panics if `id` contains invalid characters (not in `[a-zA-Z0-9_./-]`).
198///
199/// **For untrusted input, use `id.parse::<DataId>()`** which calls
200/// `FromStr::from_str` and returns `Result<Self, InvalidId>`.
201///
202/// Do NOT use `DataId::try_from(s)`: `TryFrom<String>` is the
203/// auto-derived blanket impl that delegates to this `From` impl, so it
204/// panics exactly like `.into()`. Only `parse::<DataId>()` / `from_str`
205/// is fallible.
206impl From<String> for DataId {
207    fn from(id: String) -> Self {
208        if let Err(e) = validate_data_id(&id) {
209            panic!("invalid DataId '{id}': {e}");
210        }
211        Self(id)
212    }
213}
214
215/// # Panics
216///
217/// Panics if `id` contains invalid characters. Prefer `id.parse::<DataId>()`
218/// when handling untrusted input.
219impl From<&str> for DataId {
220    fn from(id: &str) -> Self {
221        id.to_owned().into()
222    }
223}
224
225impl std::fmt::Display for DataId {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        std::fmt::Display::fmt(&self.0, f)
228    }
229}
230
231impl std::ops::Deref for DataId {
232    type Target = String;
233
234    fn deref(&self) -> &Self::Target {
235        &self.0
236    }
237}
238
239impl AsRef<String> for DataId {
240    fn as_ref(&self) -> &String {
241        &self.0
242    }
243}
244
245impl AsRef<str> for DataId {
246    fn as_ref(&self) -> &str {
247        &self.0
248    }
249}
250
251impl Borrow<String> for DataId {
252    fn borrow(&self) -> &String {
253        &self.0
254    }
255}
256
257impl Borrow<str> for DataId {
258    fn borrow(&self) -> &str {
259        &self.0
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn valid_node_ids() {
269        assert!(validate_node_id("my-node").is_ok());
270        assert!(validate_node_id("my_node").is_ok());
271        assert!(validate_node_id("MyNode123").is_ok());
272        assert!(validate_node_id("node.v2").is_ok());
273        assert!(validate_node_id("a").is_ok());
274    }
275
276    #[test]
277    fn invalid_node_ids() {
278        assert!(validate_node_id("").is_err());
279        assert!(validate_node_id("node/output").is_err());
280        assert!(validate_node_id("node name").is_err());
281        assert!(validate_node_id("node;rm").is_err());
282        assert!(validate_node_id("node\0").is_err());
283    }
284
285    #[test]
286    fn node_id_rejects_dot_segments() {
287        // Dot-only ids resolve to parent directory when joined into filesystem paths
288        assert!(validate_node_id(".").is_err(), ". must be rejected");
289        assert!(validate_node_id("..").is_err(), ".. must be rejected");
290        // Any leading dot is rejected (mirrors hub-client is_valid_key_part)
291        assert!(
292            validate_node_id(".hidden").is_err(),
293            ".hidden must be rejected"
294        );
295        assert!(
296            validate_node_id(".config").is_err(),
297            ".config must be rejected"
298        );
299        // Embedded dots are still fine
300        assert!(
301            validate_node_id("node.v2").is_ok(),
302            "embedded dot is allowed"
303        );
304        assert!(
305            validate_node_id("a.b.c").is_ok(),
306            "multiple embedded dots are allowed"
307        );
308    }
309
310    #[test]
311    fn data_id_allows_slash_for_operators() {
312        // Operator outputs are namespaced: `operator-id/output-name`
313        assert!(validate_data_id("rust-operator/status").is_ok());
314        assert!(validate_data_id("op/output").is_ok());
315        assert!(validate_data_id("simple").is_ok());
316    }
317
318    #[test]
319    fn data_id_rejects_other_specials() {
320        assert!(validate_data_id("").is_err());
321        assert!(validate_data_id("has space").is_err());
322        assert!(validate_data_id("semi;colon").is_err());
323    }
324
325    #[test]
326    fn data_id_rejects_malformed_slash_patterns() {
327        assert!(validate_data_id("op/").is_err(), "trailing slash");
328        assert!(validate_data_id("/out").is_err(), "leading slash");
329        assert!(validate_data_id("a//b").is_err(), "double slash");
330        assert!(validate_data_id("/").is_err(), "bare slash");
331    }
332
333    #[test]
334    fn node_id_from_str_rejects_invalid() {
335        assert!(NodeId::from_str("hello").is_ok());
336        assert!(NodeId::from_str("hello/world").is_err());
337        assert!(NodeId::from_str("hello world").is_err());
338        assert!(NodeId::from_str("").is_err());
339    }
340
341    #[test]
342    #[should_panic(expected = "invalid NodeId")]
343    fn node_id_from_string_panics_on_invalid() {
344        let _id: NodeId = "bad/id".to_string().into();
345    }
346
347    #[test]
348    fn node_id_parse_rejects_invalid() {
349        assert!("hello".parse::<NodeId>().is_ok());
350        assert!("bad/id".parse::<NodeId>().is_err());
351        assert!("".parse::<NodeId>().is_err());
352    }
353
354    #[test]
355    fn data_id_parse_rejects_invalid() {
356        assert!("output".parse::<DataId>().is_ok());
357        assert!("bad;id".parse::<DataId>().is_err());
358    }
359
360    #[test]
361    fn node_id_deserialize_rejects_invalid() {
362        let result: Result<NodeId, _> = serde_json::from_str("\"bad/id\"");
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn data_id_deserialize_rejects_invalid() {
368        let result: Result<DataId, _> = serde_json::from_str("\"bad;id\"");
369        assert!(result.is_err());
370    }
371}