Skip to main content

atuin_common/string/
non_nul_str.rs

1//! A string proven to contain no NUL bytes.
2
3use std::fmt;
4use std::ops::Deref;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8/// A string proven to contain no NUL byte.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub struct NonNulStr<T = String>(T);
11
12/// The error returned when a string contains a NUL byte.
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14#[error("string contains a NUL byte at index {index}")]
15pub struct ContainsNul {
16    /// Byte index of the first NUL.
17    pub index: usize,
18}
19
20impl<T: AsRef<str>> NonNulStr<T> {
21    /// Wrap `inner`, or fail if it contains a NUL byte.
22    pub fn new(inner: T) -> Result<Self, ContainsNul> {
23        match inner.as_ref().find('\0') {
24            Some(index) => Err(ContainsNul { index }),
25            None => Ok(Self(inner)),
26        }
27    }
28
29    /// The wrapped string as a slice.
30    pub fn as_str(&self) -> &str {
31        self.0.as_ref()
32    }
33}
34
35impl<T: AsRef<str>> Deref for NonNulStr<T> {
36    type Target = str;
37
38    fn deref(&self) -> &str {
39        self.0.as_ref()
40    }
41}
42
43impl<T: AsRef<str>> AsRef<str> for NonNulStr<T> {
44    fn as_ref(&self) -> &str {
45        self.0.as_ref()
46    }
47}
48
49impl<T: AsRef<str>> fmt::Display for NonNulStr<T> {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(self.0.as_ref())
52    }
53}
54
55impl<T: AsRef<str>> Serialize for NonNulStr<T> {
56    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
57        serializer.serialize_str(self.0.as_ref())
58    }
59}
60
61impl<'de, T> Deserialize<'de> for NonNulStr<T>
62where
63    T: Deserialize<'de> + AsRef<str>,
64{
65    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
66        let inner = T::deserialize(deserializer)?;
67        Self::new(inner).map_err(serde::de::Error::custom)
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use proptest::prelude::*;
74    use rstest::rstest;
75
76    use super::{ContainsNul, NonNulStr};
77
78    #[rstest]
79    #[case("echo hello")]
80    #[case("")]
81    #[case("🦀 build --release")]
82    #[case("ls -la | grep foo")]
83    fn wraps_nul_free_strings(#[case] input: &str) {
84        assert_eq!(NonNulStr::new(input).unwrap().as_str(), input);
85    }
86
87    #[rstest]
88    #[case::interior("echo hi\0rm -rf /", 7)]
89    #[case::trailing("ls\0", 2)]
90    #[case::leading("\0danger", 0)]
91    #[case::first_of_many("a\0b\0c", 1)]
92    fn rejects_strings_with_nul(#[case] input: &str, #[case] index: usize) {
93        assert_eq!(NonNulStr::new(input), Err(ContainsNul { index }));
94    }
95
96    #[rstest]
97    fn serializes_as_plain_string() {
98        let json = serde_json::to_string(&NonNulStr::new("echo hi").unwrap()).unwrap();
99        assert_eq!(json, r#""echo hi""#);
100    }
101
102    #[rstest]
103    fn deserializes_nul_free_string() {
104        let c: NonNulStr<String> = serde_json::from_str(r#""echo hi""#).unwrap();
105        assert_eq!(c.as_str(), "echo hi");
106    }
107
108    #[rstest]
109    // A JSON string carrying a NUL fails to deserialize — it is not trimmed.
110    #[case::embedded_nul(serde_json::to_string("echo hi\0rm -rf /").unwrap())]
111    // A number is not a command; serde surfaces a data-category error.
112    #[case::non_string("42".to_string())]
113    fn deserialize_rejects(#[case] json: String) {
114        let err = serde_json::from_str::<NonNulStr<String>>(&json).unwrap_err();
115        assert!(err.is_data());
116    }
117
118    proptest! {
119        /// Wrapping succeeds iff there is no NUL, and reports the first NUL's index.
120        #[rstest]
121        fn validates_against_nul(s in r"(?s).*") {
122            let result = NonNulStr::new(s.as_str());
123            match s.find('\0') {
124                None => {
125                    let command = result.unwrap();
126                    prop_assert_eq!(command.as_str(), s.as_str());
127                }
128                Some(index) => prop_assert_eq!(result.unwrap_err().index, index),
129            }
130        }
131
132        /// A NUL-free command serialize → deserialize round-trips unchanged.
133        #[rstest]
134        fn serde_round_trip(s in r"[^\x00]*") {
135            let original = NonNulStr::new(s).unwrap();
136            let json = serde_json::to_string(&original).unwrap();
137            let back: NonNulStr<String> = serde_json::from_str(&json).unwrap();
138            prop_assert_eq!(back, original);
139        }
140    }
141}