Skip to main content

hadris_common/types/
file.rs

1use core::{fmt, ops::Range};
2
3/// A fixed-length, stack-allocated filename buffer.
4///
5/// Stores up to `N` bytes of filename data on the stack without
6/// heap allocation. Useful for no-std filesystem implementations
7/// where filenames have a known maximum length.
8///
9/// # Example
10///
11/// ```rust
12/// use hadris_common::types::file::FixedFilename;
13///
14/// let name = FixedFilename::<64>::from(b"readme.txt".as_slice());
15/// assert_eq!(name.len(), 10);
16/// assert_eq!(name.as_str(), "readme.txt");
17/// assert!(!name.is_empty());
18/// ```
19#[derive(Clone, Copy, PartialEq, Eq)]
20pub struct FixedFilename<const N: usize> {
21    pub data: [u8; N],
22    pub len: usize,
23}
24
25impl<const N: usize> FixedFilename<N> {
26    pub const fn empty() -> Self {
27        Self {
28            data: [0; N],
29            len: 0,
30        }
31    }
32
33    pub const fn with_size(size: usize) -> Self {
34        assert!(size <= N);
35        Self {
36            data: [0; N],
37            len: size,
38        }
39    }
40
41    /// Borrow the contents as a `&str`.
42    ///
43    /// # Panics
44    ///
45    /// Panics if the bytes are not valid UTF-8. Use [`Self::try_as_str`] for
46    /// a fallible variant. Note: prior versions used `from_utf8_unchecked`,
47    /// which was unsound because [`Self::as_bytes_mut`] (and the other
48    /// byte-level constructors) accept arbitrary bytes safely.
49    pub fn as_str(&self) -> &str {
50        core::str::from_utf8(self.as_bytes()).expect("FixedFilename contains invalid UTF-8")
51    }
52
53    /// Borrow the contents as a `&str` if they are valid UTF-8.
54    pub fn try_as_str(&self) -> Result<&str, core::str::Utf8Error> {
55        core::str::from_utf8(self.as_bytes())
56    }
57
58    pub fn allocate(&mut self, bytes: usize) {
59        let len = self.len;
60        assert!(bytes + len <= N);
61        self.len += bytes;
62        //self.data[len..self.len]
63    }
64
65    pub fn as_bytes(&self) -> &[u8] {
66        &self.data[0..self.len]
67    }
68
69    pub fn as_bytes_mut(&mut self) -> &mut [u8] {
70        &mut self.data[0..self.len]
71    }
72
73    pub fn truncate(&mut self, new_size: usize) {
74        assert!(new_size <= N);
75        self.len = new_size;
76    }
77
78    pub fn len(&self) -> usize {
79        self.len
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.len == 0
84    }
85
86    pub fn push_slice(&mut self, slice: &[u8]) -> Range<usize> {
87        assert!(self.len + slice.len() <= self.data.len());
88        let start = self.len;
89        self.len += slice.len();
90        self.data[start..self.len].copy_from_slice(slice);
91        start..self.len
92    }
93
94    pub fn push_byte(&mut self, b: u8) -> usize {
95        assert!(self.len < N);
96        self.data[self.len] = b;
97        self.len += 1;
98        self.len - 1
99    }
100
101    pub fn try_push_slice(&mut self, slice: &[u8]) -> Option<Range<usize>> {
102        if self.len + slice.len() > N {
103            return None;
104        }
105        Some(self.push_slice(slice))
106    }
107
108    pub fn try_push_byte(&mut self, b: u8) -> Option<usize> {
109        if self.len >= N {
110            return None;
111        }
112        Some(self.push_byte(b))
113    }
114
115    pub fn remaining_capacity(&self) -> usize {
116        N - self.len
117    }
118}
119
120impl<const N: usize> fmt::Debug for FixedFilename<N> {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self.try_as_str() {
123            Ok(s) => f.debug_tuple("FixedFilename").field(&s).finish(),
124            Err(_) => f
125                .debug_tuple("FixedFilename")
126                .field(&self.as_bytes())
127                .finish(),
128        }
129    }
130}
131
132impl<const N: usize> fmt::Display for FixedFilename<N> {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self.try_as_str() {
135            Ok(s) => f.write_str(s),
136            Err(_) => write!(f, "{:?}", self.as_bytes()),
137        }
138    }
139}
140
141impl<const N: usize> From<&[u8]> for FixedFilename<N> {
142    fn from(value: &[u8]) -> Self {
143        assert!(value.len() <= N);
144        let mut str = FixedFilename::with_size(value.len());
145        str.data[0..value.len()].copy_from_slice(value);
146        str
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn try_as_str_rejects_invalid_utf8() {
156        // Regression test for issue #26: previously `as_str` used
157        // `from_utf8_unchecked`, which made it possible to invoke UB through
158        // entirely safe code by writing arbitrary bytes via `as_bytes_mut`.
159        let mut filename = FixedFilename::<10>::with_size(10);
160        filename.as_bytes_mut()[0] = 0xff;
161
162        assert!(filename.try_as_str().is_err());
163    }
164
165    #[test]
166    fn debug_does_not_panic_on_invalid_utf8() {
167        use core::fmt::Write as _;
168        struct Sink;
169        impl core::fmt::Write for Sink {
170            fn write_str(&mut self, _: &str) -> core::fmt::Result {
171                Ok(())
172            }
173        }
174        let mut filename = FixedFilename::<10>::with_size(10);
175        filename.as_bytes_mut()[0] = 0xff;
176        // Should fall back to byte-slice formatting instead of panicking.
177        write!(Sink, "{:?}", filename).unwrap();
178        write!(Sink, "{}", filename).unwrap();
179    }
180
181    #[test]
182    fn try_as_str_round_trips_valid_utf8() {
183        let name = FixedFilename::<64>::from(b"readme.txt".as_slice());
184        assert_eq!(name.try_as_str().unwrap(), "readme.txt");
185        assert_eq!(name.as_str(), "readme.txt");
186    }
187
188    #[test]
189    #[should_panic(expected = "invalid UTF-8")]
190    fn as_str_panics_on_invalid_utf8() {
191        let mut filename = FixedFilename::<10>::with_size(10);
192        filename.as_bytes_mut()[0] = 0xff;
193        let _ = filename.as_str();
194    }
195}