Skip to main content

rustferry_core/
file_streams.rs

1//! Retained-handle inspection for regular-file data streams.
2
3#![cfg_attr(windows, allow(unsafe_code))]
4
5use std::{error::Error, fmt, fs::File};
6
7/// Stable class of a retained regular-file stream inspection failure.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum RegularFileStreamErrorKind {
11    /// The supplied handle does not identify a regular file.
12    NotRegularFile,
13    /// The operating system rejected the retained-handle stream query.
14    QueryFailed,
15    /// Stream metadata exceeded the fixed defensive query bound.
16    ResponseTooLarge,
17    /// The operating system returned malformed or ambiguous stream metadata.
18    MalformedResponse,
19    /// A named alternate data stream exists on the retained regular file.
20    NamedStream,
21}
22
23/// Secret-free retained-handle stream inspection error.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub struct RegularFileStreamError {
26    kind: RegularFileStreamErrorKind,
27    os_code: Option<i32>,
28}
29
30impl RegularFileStreamError {
31    const fn new(kind: RegularFileStreamErrorKind, os_code: Option<i32>) -> Self {
32        Self { kind, os_code }
33    }
34
35    /// Return the stable failure class.
36    pub const fn kind(self) -> RegularFileStreamErrorKind {
37        self.kind
38    }
39
40    /// Return a public operating-system error code when the query itself failed.
41    pub const fn os_code(self) -> Option<i32> {
42        self.os_code
43    }
44}
45
46impl fmt::Display for RegularFileStreamError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self.kind {
49            RegularFileStreamErrorKind::NotRegularFile => {
50                formatter.write_str("stream inspection requires a retained regular-file handle")
51            }
52            RegularFileStreamErrorKind::QueryFailed => {
53                if let Some(code) = self.os_code {
54                    write!(
55                        formatter,
56                        "retained file stream query failed with OS error {code}"
57                    )
58                } else {
59                    formatter.write_str("retained file stream query failed")
60                }
61            }
62            RegularFileStreamErrorKind::ResponseTooLarge => {
63                formatter.write_str("retained file stream metadata exceeded its fixed bound")
64            }
65            RegularFileStreamErrorKind::MalformedResponse => {
66                formatter.write_str("retained file stream metadata was malformed")
67            }
68            RegularFileStreamErrorKind::NamedStream => {
69                formatter.write_str("retained regular file has a named alternate data stream")
70            }
71        }
72    }
73}
74
75impl Error for RegularFileStreamError {}
76
77/// Verify that a retained regular-file handle exposes only its unnamed default data stream.
78///
79/// Windows performs a bounded handle query and rejects every named stream without retaining or
80/// rendering its name. Filesystems without Windows alternate data streams return success after
81/// confirming the handle is a regular file.
82///
83/// # Errors
84///
85/// Returns a typed failure for a non-file handle, query failure, oversized or malformed metadata,
86/// or any named alternate data stream.
87pub fn verify_regular_file_has_no_named_streams(file: &File) -> Result<(), RegularFileStreamError> {
88    let metadata = file.metadata().map_err(|error| {
89        RegularFileStreamError::new(
90            RegularFileStreamErrorKind::QueryFailed,
91            error.raw_os_error(),
92        )
93    })?;
94    if !metadata.is_file() {
95        return Err(RegularFileStreamError::new(
96            RegularFileStreamErrorKind::NotRegularFile,
97            None,
98        ));
99    }
100
101    #[cfg(windows)]
102    return windows::verify(file);
103
104    #[cfg(not(windows))]
105    Ok(())
106}
107
108#[cfg(windows)]
109mod windows {
110    use std::{fs::File, io, os::windows::io::AsRawHandle as _, slice};
111
112    use windows_sys::Win32::{
113        Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_MORE_DATA},
114        Storage::FileSystem::{FileStreamInfo, GetFileInformationByHandleEx},
115    };
116
117    use super::{RegularFileStreamError, RegularFileStreamErrorKind};
118
119    const INITIAL_QUERY_BYTES: usize = 1_024;
120    const MAX_QUERY_BYTES: usize = 1024 * 1024;
121    const ENTRY_HEADER_BYTES: usize = 24;
122    pub(super) fn verify(file: &File) -> Result<(), RegularFileStreamError> {
123        let mut capacity = INITIAL_QUERY_BYTES;
124        loop {
125            let mut storage = vec![0_u64; capacity / size_of::<u64>()];
126            let succeeded = unsafe {
127                GetFileInformationByHandleEx(
128                    file.as_raw_handle(),
129                    FileStreamInfo,
130                    storage.as_mut_ptr().cast(),
131                    u32::try_from(capacity).expect("bounded stream query capacity fits u32"),
132                )
133            };
134            if succeeded != 0 {
135                let bytes =
136                    unsafe { slice::from_raw_parts(storage.as_ptr().cast::<u8>(), capacity) };
137                return parse_streams(bytes);
138            }
139
140            let os_code = io::Error::last_os_error().raw_os_error();
141            let retryable = os_code
142                .and_then(|code| u32::try_from(code).ok())
143                .is_some_and(|code| code == ERROR_MORE_DATA || code == ERROR_INSUFFICIENT_BUFFER);
144            if !retryable {
145                return Err(RegularFileStreamError::new(
146                    RegularFileStreamErrorKind::QueryFailed,
147                    os_code,
148                ));
149            }
150            if capacity == MAX_QUERY_BYTES {
151                return Err(RegularFileStreamError::new(
152                    RegularFileStreamErrorKind::ResponseTooLarge,
153                    None,
154                ));
155            }
156            capacity = capacity.saturating_mul(2).min(MAX_QUERY_BYTES);
157        }
158    }
159
160    fn parse_streams(bytes: &[u8]) -> Result<(), RegularFileStreamError> {
161        let mut offset = 0_usize;
162        let mut entries = 0_usize;
163        loop {
164            let next = read_u32(bytes, offset)? as usize;
165            let name_bytes = read_u32(bytes, offset + 4)? as usize;
166            if name_bytes == 0 || !name_bytes.is_multiple_of(size_of::<u16>()) {
167                return Err(malformed());
168            }
169            let name_start = offset
170                .checked_add(ENTRY_HEADER_BYTES)
171                .ok_or_else(malformed)?;
172            let name_end = name_start.checked_add(name_bytes).ok_or_else(malformed)?;
173            let entry_end = if next == 0 {
174                bytes.len()
175            } else {
176                if !next.is_multiple_of(align_of::<u64>()) || next < ENTRY_HEADER_BYTES + name_bytes
177                {
178                    return Err(malformed());
179                }
180                offset.checked_add(next).ok_or_else(malformed)?
181            };
182            if name_end > entry_end || entry_end > bytes.len() {
183                return Err(malformed());
184            }
185            if !is_default_stream(&bytes[name_start..name_end]) {
186                return Err(RegularFileStreamError::new(
187                    RegularFileStreamErrorKind::NamedStream,
188                    None,
189                ));
190            }
191            entries = entries.checked_add(1).ok_or_else(malformed)?;
192            if entries != 1 || next == 0 {
193                break;
194            }
195            offset = entry_end;
196        }
197        if entries == 1 {
198            Ok(())
199        } else {
200            Err(malformed())
201        }
202    }
203
204    fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, RegularFileStreamError> {
205        let end = offset.checked_add(size_of::<u32>()).ok_or_else(malformed)?;
206        let raw: [u8; 4] = bytes
207            .get(offset..end)
208            .ok_or_else(malformed)?
209            .try_into()
210            .map_err(|_| malformed())?;
211        Ok(u32::from_ne_bytes(raw))
212    }
213
214    fn is_default_stream(name: &[u8]) -> bool {
215        if name.len() != 14 {
216            return false;
217        }
218        let mut units = [0_u16; 7];
219        for (index, chunk) in name.chunks_exact(2).enumerate() {
220            units[index] = u16::from_ne_bytes([chunk[0], chunk[1]]);
221        }
222        units[0] == u16::from(b':')
223            && units[1] == u16::from(b':')
224            && units[2] == u16::from(b'$')
225            && units[3..].iter().zip(b"DATA").all(|(actual, expected)| {
226                *actual == u16::from(*expected)
227                    || *actual == u16::from(expected.to_ascii_lowercase())
228            })
229    }
230
231    const fn malformed() -> RegularFileStreamError {
232        RegularFileStreamError::new(RegularFileStreamErrorKind::MalformedResponse, None)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::{fs, fs::File};
239
240    use super::{RegularFileStreamErrorKind, verify_regular_file_has_no_named_streams};
241
242    #[test]
243    fn regular_file_without_named_streams_is_accepted() {
244        let temporary = tempfile::tempdir().unwrap();
245        let path = temporary.path().join("source.txt");
246        fs::write(&path, b"source").unwrap();
247        verify_regular_file_has_no_named_streams(&File::open(path).unwrap()).unwrap();
248    }
249
250    #[cfg(not(windows))]
251    #[test]
252    fn directory_handle_is_not_a_regular_file() {
253        let temporary = tempfile::tempdir().unwrap();
254        let error =
255            verify_regular_file_has_no_named_streams(&File::open(temporary.path()).unwrap())
256                .unwrap_err();
257        assert_eq!(error.kind(), RegularFileStreamErrorKind::NotRegularFile);
258    }
259
260    #[cfg(windows)]
261    #[test]
262    fn retained_handle_rejects_named_stream_after_path_replacement() {
263        use std::{fs::OpenOptions, os::windows::fs::OpenOptionsExt as _};
264
265        const FILE_SHARE_READ: u32 = 0x0000_0001;
266        const FILE_SHARE_WRITE: u32 = 0x0000_0002;
267        const FILE_SHARE_DELETE: u32 = 0x0000_0004;
268
269        let temporary = tempfile::tempdir().unwrap();
270        let original = temporary.path().join("source.txt");
271        let moved = temporary.path().join("moved.txt");
272        fs::write(&original, b"source").unwrap();
273        fs::write(format!("{}:private", original.display()), b"credential").unwrap();
274        let retained = OpenOptions::new()
275            .read(true)
276            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
277            .open(&original)
278            .unwrap();
279        fs::rename(&original, &moved).unwrap();
280        fs::write(&original, b"clean replacement").unwrap();
281
282        let error = verify_regular_file_has_no_named_streams(&retained).unwrap_err();
283        assert_eq!(error.kind(), RegularFileStreamErrorKind::NamedStream);
284        assert!(!error.to_string().contains("private"));
285        verify_regular_file_has_no_named_streams(&File::open(original).unwrap()).unwrap();
286    }
287}