atuin_common/string/
non_nul_str.rs1use std::fmt;
4use std::ops::Deref;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub struct NonNulStr<T = String>(T);
11
12#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14#[error("string contains a NUL byte at index {index}")]
15pub struct ContainsNul {
16 pub index: usize,
18}
19
20impl<T: AsRef<str>> NonNulStr<T> {
21 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 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 #[case::embedded_nul(serde_json::to_string("echo hi\0rm -rf /").unwrap())]
111 #[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 #[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 #[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}