1use crate::Result;
7use crate::error::DaemonIdError;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt::{self, Display};
10use std::hash::Hash;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct DaemonId {
35 namespace: String,
36 name: String,
37}
38
39impl Default for DaemonId {
40 fn default() -> Self {
41 Self {
42 namespace: "global".to_string(),
43 name: "unknown".to_string(),
44 }
45 }
46}
47
48impl DaemonId {
49 #[cfg(test)]
64 pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
65 let namespace = namespace.into();
66 let name = name.into();
67
68 if let Err(e) = validate_component(&namespace, "namespace") {
70 panic!("Invalid namespace '{namespace}': {e}");
71 }
72 if let Err(e) = validate_component(&name, "name") {
73 panic!("Invalid name '{name}': {e}");
74 }
75
76 Self { namespace, name }
77 }
78
79 pub(crate) fn new_unchecked(namespace: impl Into<String>, name: impl Into<String>) -> Self {
89 Self {
90 namespace: namespace.into(),
91 name: name.into(),
92 }
93 }
94
95 pub fn try_new(namespace: impl Into<String>, name: impl Into<String>) -> Result<Self> {
99 let namespace = namespace.into();
100 let name = name.into();
101
102 validate_component(&namespace, "namespace")?;
103 validate_component(&name, "name")?;
104
105 Ok(Self { namespace, name })
106 }
107
108 pub fn parse(s: &str) -> Result<Self> {
122 validate_qualified_id(s)?;
123
124 let (ns, name) = s
126 .split_once('/')
127 .expect("validate_qualified_id ensures '/' is present");
128 Ok(Self {
129 namespace: ns.to_string(),
130 name: name.to_string(),
131 })
132 }
133
134 pub fn from_safe_path(s: &str) -> Result<Self> {
157 if let Some((ns, name)) = s.split_once("--") {
158 validate_component(ns, "namespace")?;
162 validate_component(name, "name")?;
163 Ok(Self {
164 namespace: ns.to_string(),
165 name: name.to_string(),
166 })
167 } else {
168 Err(DaemonIdError::InvalidSafePath {
169 path: s.to_string(),
170 }
171 .into())
172 }
173 }
174
175 pub fn namespace(&self) -> &str {
177 &self.namespace
178 }
179
180 pub fn pitchfork() -> Self {
184 Self::new_unchecked("global", "pitchfork")
186 }
187
188 pub fn name(&self) -> &str {
190 &self.name
191 }
192
193 pub fn qualified(&self) -> String {
195 format!("{}/{}", self.namespace, self.name)
196 }
197
198 pub fn safe_path(&self) -> String {
200 format!("{}--{}", self.namespace, self.name)
201 }
202
203 pub fn log_path(&self) -> std::path::PathBuf {
205 let safe = self.safe_path();
206 crate::env::PITCHFORK_LOGS_DIR
207 .join(&safe)
208 .join(format!("{safe}.log"))
209 }
210
211 pub fn styled_qualified(&self) -> String {
215 use crate::ui::style::ndim;
216 format!("{}/{}", ndim(&self.namespace), self.name)
217 }
218}
219
220impl Display for DaemonId {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(f, "{}/{}", self.namespace, self.name)
223 }
224}
225
226impl Serialize for DaemonId {
234 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
235 where
236 S: Serializer,
237 {
238 serializer.serialize_str(&self.qualified())
239 }
240}
241
242impl<'de> Deserialize<'de> for DaemonId {
244 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
245 where
246 D: Deserializer<'de>,
247 {
248 let s = String::deserialize(deserializer)?;
249 DaemonId::parse(&s).map_err(serde::de::Error::custom)
250 }
251}
252
253impl schemars::JsonSchema for DaemonId {
260 fn schema_name() -> std::borrow::Cow<'static, str> {
261 "DaemonId".into()
262 }
263
264 fn schema_id() -> std::borrow::Cow<'static, str> {
265 concat!(module_path!(), "::DaemonId").into()
266 }
267
268 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
269 schemars::json_schema!({
270 "type": "string",
271 "description": "Daemon name (e.g. 'api') or qualified ID ('namespace/name') for cross-namespace references",
272 "pattern": r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$",
273 "not": {
274 "pattern": r"\.\.|--|(^|/)-|-($|/)|^\.$|^\./|/\.$"
275 }
276 })
277 }
278}
279
280fn validate_component(s: &str, component_name: &str) -> Result<()> {
282 if s.is_empty() {
283 return Err(DaemonIdError::EmptyComponent {
284 component: component_name.to_string(),
285 }
286 .into());
287 }
288 if s.contains('/') {
289 return Err(DaemonIdError::PathSeparator {
290 id: s.to_string(),
291 sep: '/',
292 }
293 .into());
294 }
295 if s.contains('\\') {
296 return Err(DaemonIdError::PathSeparator {
297 id: s.to_string(),
298 sep: '\\',
299 }
300 .into());
301 }
302 if s.contains("..") {
303 return Err(DaemonIdError::ParentDirRef { id: s.to_string() }.into());
304 }
305 if s.contains("--") {
306 return Err(DaemonIdError::ReservedSequence { id: s.to_string() }.into());
307 }
308 if s.starts_with('-') || s.ends_with('-') {
309 return Err(DaemonIdError::LeadingTrailingDash { id: s.to_string() }.into());
310 }
311 if s.contains(' ') {
312 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
313 }
314 if s == "." {
315 return Err(DaemonIdError::CurrentDir.into());
316 }
317 if !s
318 .chars()
319 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
320 {
321 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
322 }
323 Ok(())
324}
325
326fn validate_qualified_id(s: &str) -> Result<()> {
328 if s.is_empty() {
329 return Err(DaemonIdError::Empty.into());
330 }
331 if s.contains('\\') {
332 return Err(DaemonIdError::PathSeparator {
333 id: s.to_string(),
334 sep: '\\',
335 }
336 .into());
337 }
338 if s.contains(' ') {
339 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
340 }
341 if !s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
342 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
343 }
344
345 let slash_count = s.chars().filter(|&c| c == '/').count();
347 if slash_count == 0 {
348 return Err(DaemonIdError::MissingNamespace { id: s.to_string() }.into());
349 }
350 if slash_count > 1 {
351 return Err(DaemonIdError::PathSeparator {
352 id: s.to_string(),
353 sep: '/',
354 }
355 .into());
356 }
357
358 let (ns, name) = s.split_once('/').unwrap();
360 if ns.is_empty() || name.is_empty() {
361 return Err(DaemonIdError::PathSeparator {
362 id: s.to_string(),
363 sep: '/',
364 }
365 .into());
366 }
367
368 validate_component(ns, "namespace")?;
371 validate_component(name, "name")?;
372
373 Ok(())
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_daemon_id_new() {
382 let id = DaemonId::new("global", "api");
383 assert_eq!(id.namespace(), "global");
384 assert_eq!(id.name(), "api");
385 assert_eq!(id.qualified(), "global/api");
386 assert_eq!(id.safe_path(), "global--api");
387 }
388
389 #[test]
390 fn test_daemon_id_parse() {
391 let id = DaemonId::parse("project-a/api").unwrap();
392 assert_eq!(id.namespace(), "project-a");
393 assert_eq!(id.name(), "api");
394
395 assert!(DaemonId::parse("api").is_err());
397
398 assert!(DaemonId::parse("/api").is_err());
400 assert!(DaemonId::parse("project/").is_err());
401
402 assert!(DaemonId::parse("a/b/c").is_err());
404 }
405
406 #[test]
407 fn test_daemon_id_from_safe_path() {
408 let id = DaemonId::from_safe_path("project-a--api").unwrap();
409 assert_eq!(id.namespace(), "project-a");
410 assert_eq!(id.name(), "api");
411
412 assert!(DaemonId::from_safe_path("projectapi").is_err());
414 }
415
416 #[test]
417 fn test_daemon_id_roundtrip() {
418 let original = DaemonId::new("my-project", "my-daemon");
419 let safe = original.safe_path();
420 let recovered = DaemonId::from_safe_path(&safe).unwrap();
421 assert_eq!(original, recovered);
422 }
423
424 #[test]
425 fn test_daemon_id_display() {
426 let id = DaemonId::new("global", "api");
427 assert_eq!(format!("{id}"), "global/api");
428 }
429
430 #[test]
431 fn test_daemon_id_serialize() {
432 let id = DaemonId::new("global", "api");
433 let json = serde_json::to_string(&id).unwrap();
434 assert_eq!(json, "\"global/api\"");
435
436 let deserialized: DaemonId = serde_json::from_str(&json).unwrap();
437 assert_eq!(id, deserialized);
438 }
439
440 #[test]
441 fn test_daemon_id_validation() {
442 assert!(DaemonId::try_new("global", "api").is_ok());
444 assert!(DaemonId::try_new("my-project", "my-daemon").is_ok());
445 assert!(DaemonId::try_new("project_a", "daemon_1").is_ok());
446
447 assert!(DaemonId::try_new("my--project", "api").is_err());
449 assert!(DaemonId::try_new("project", "my--daemon").is_err());
450
451 assert!(DaemonId::try_new("my/project", "api").is_err());
453 assert!(DaemonId::try_new("project", "my/daemon").is_err());
454
455 assert!(DaemonId::try_new("", "api").is_err());
457 assert!(DaemonId::try_new("project", "").is_err());
458 }
459
460 #[test]
461 fn test_daemon_id_ordering() {
462 let id1 = DaemonId::new("a", "x");
463 let id2 = DaemonId::new("a", "y");
464 let id3 = DaemonId::new("b", "x");
465
466 assert!(id1 < id2);
467 assert!(id2 < id3);
468 assert!(id1 < id3);
469 }
470
471 #[test]
473 fn test_from_safe_path_double_dash_in_namespace_rejected() {
474 assert!(DaemonId::from_safe_path("my--project--api").is_err());
478 assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
479 }
480
481 #[test]
482 fn test_from_safe_path_roundtrip_via_qualified() {
483 let id = DaemonId::from_safe_path("global--api").unwrap();
485 assert_eq!(id.namespace(), "global");
486 assert_eq!(id.name(), "api");
487 let recovered = DaemonId::parse(&id.qualified()).unwrap();
489 assert_eq!(recovered, id);
490 }
491
492 #[test]
493 fn test_from_safe_path_no_separator() {
494 assert!(DaemonId::from_safe_path("globalapi").is_err());
496 assert!(DaemonId::from_safe_path("api").is_err());
497 }
498
499 #[test]
500 fn test_from_safe_path_empty_parts() {
501 let result = DaemonId::from_safe_path("--api");
503 assert!(result.is_err());
504
505 let result = DaemonId::from_safe_path("namespace--");
507 assert!(result.is_err());
508 }
509
510 #[test]
512 fn test_parse_cross_namespace_dependency() {
513 let id = DaemonId::parse("other-project/postgres").unwrap();
515 assert_eq!(id.namespace(), "other-project");
516 assert_eq!(id.name(), "postgres");
517 }
518
519 #[test]
521 fn test_directory_with_double_dash_in_name() {
522 let result = DaemonId::try_new("my--project", "api");
524 assert!(result.is_err());
525
526 let result = DaemonId::from_safe_path("my--project--api");
530 assert!(
531 result.is_err(),
532 "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
533 );
534 }
535
536 #[test]
537 fn test_parse_dot_namespace_rejected() {
538 let result = DaemonId::parse("./api");
541 assert!(result.is_err());
542
543 let result = DaemonId::parse("../api");
545 assert!(result.is_err());
546 }
547
548 #[test]
550 fn test_daemon_id_toml_roundtrip() {
551 #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
552 struct TestConfig {
553 daemon_id: DaemonId,
554 }
555
556 let config = TestConfig {
557 daemon_id: DaemonId::new("my-project", "api"),
558 };
559
560 let toml_str = toml::to_string(&config).unwrap();
561 assert!(toml_str.contains("daemon_id = \"my-project/api\""));
562
563 let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
564 assert_eq!(config, recovered);
565 }
566
567 #[test]
568 fn test_daemon_id_json_roundtrip_in_map() {
569 use std::collections::HashMap;
570
571 let mut map: HashMap<String, DaemonId> = HashMap::new();
572 map.insert("primary".to_string(), DaemonId::new("global", "api"));
573 map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
574
575 let json = serde_json::to_string(&map).unwrap();
576 let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
577 assert_eq!(map, recovered);
578 }
579
580 #[test]
582 fn test_pitchfork_id() {
583 let id = DaemonId::pitchfork();
584 assert_eq!(id.namespace(), "global");
585 assert_eq!(id.name(), "pitchfork");
586 assert_eq!(id.qualified(), "global/pitchfork");
587 }
588
589 #[test]
591 fn test_daemon_id_rejects_unicode() {
592 assert!(DaemonId::try_new("プロジェクト", "api").is_err());
593 assert!(DaemonId::try_new("project", "工作者").is_err());
594 }
595
596 #[test]
597 fn test_daemon_id_rejects_control_chars() {
598 assert!(DaemonId::try_new("project\x00", "api").is_err());
599 assert!(DaemonId::try_new("project", "api\x1b").is_err());
600 }
601
602 #[test]
603 fn test_daemon_id_rejects_spaces() {
604 assert!(DaemonId::try_new("my project", "api").is_err());
605 assert!(DaemonId::try_new("project", "my api").is_err());
606 assert!(DaemonId::parse("my project/api").is_err());
607 }
608
609 #[test]
610 fn test_daemon_id_rejects_chars_outside_schema_pattern() {
611 assert!(DaemonId::try_new("project+alpha", "api").is_err());
613 assert!(DaemonId::try_new("project", "api@v1").is_err());
614 }
615
616 #[test]
617 fn test_daemon_id_rejects_leading_trailing_dash() {
618 assert!(DaemonId::try_new("-project", "api").is_err());
620 assert!(DaemonId::try_new("project", "-api").is_err());
621 assert!(DaemonId::try_new("project-", "api").is_err());
623 assert!(DaemonId::try_new("project", "api-").is_err());
624 let id = DaemonId::try_new("a", "b").unwrap();
626 let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
627 assert_eq!(id, recovered);
628 assert!(DaemonId::from_safe_path("a---b").is_err()); }
631
632 #[test]
633 fn test_daemon_id_rejects_parent_dir_traversal() {
634 assert!(DaemonId::try_new("project", "..").is_err());
635 assert!(DaemonId::try_new("..", "api").is_err());
636 assert!(DaemonId::parse("../api").is_err());
637 assert!(DaemonId::parse("project/..").is_err());
638 }
639
640 #[test]
641 fn test_daemon_id_rejects_current_dir() {
642 assert!(DaemonId::try_new(".", "api").is_err());
643 assert!(DaemonId::try_new("project", ".").is_err());
644 }
645
646 #[test]
648 fn test_daemon_id_hash_consistency() {
649 use std::collections::HashSet;
650
651 let id1 = DaemonId::new("project", "api");
652 let id2 = DaemonId::new("project", "api");
653 let id3 = DaemonId::parse("project/api").unwrap();
654
655 let mut set = HashSet::new();
656 set.insert(id1.clone());
657
658 assert!(set.contains(&id2));
660 assert!(set.contains(&id3));
661
662 assert_eq!(id1, id2);
664 assert_eq!(id2, id3);
665 }
666}