Skip to main content

supreme_hook_config/
dir_listing.rs

1//! Pure logic supporting `hooks::query_dir`'s directory-listing hook: buffer
2//! rewriting, plus the glob-style filename matching used to decide which
3//! mod-provided files belong in a given listing.
4//!
5//! This used to live entirely as raw-pointer arithmetic directly inside
6//! `hooks::query_dir`'s detour closure, the riskiest `unsafe` code in the
7//! proxy crate (walking and mutating an OS-owned, variable-length-record
8//! buffer) and, until now, the least testable, since it only ever existed
9//! as a `*mut u8` live during an actual `NtQueryDirectoryFile` call.
10//!
11//! Everything here instead takes plain, owned/borrowed values, a
12//! `&mut [u8]` for the buffer functions, `&str` for the matcher, and uses
13//! ordinary slice indexing: an out-of-bounds access panics loudly instead
14//! of corrupting memory silently, and the whole thing can be exercised
15//! against a hand-built buffer in a plain `#[test]`, with no OS, no
16//! Windows target, and no running game involved. `hooks::query_dir`'s
17//! detour is now a thin `unsafe` wrapper that builds a `&mut [u8]` view of
18//! the real syscall buffer via
19//! `slice::from_raw_parts_mut(file_information as *mut u8, length as usize)`
20//! and hands it to the functions below, the `unsafe` is now confined to
21//! that one slice construction instead of spread across every field access.
22
23/// Byte offsets of the three fields every supported `FileXxxInformation`
24/// variant has, relative to the start of one directory-listing entry.
25/// `hooks::query_dir::get_dir_info_offsets` derives these (via
26/// `offset_of!` on the real struct layouts) and passes them in here; this
27/// module doesn't know about any specific info class, only these offsets.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct EntryOffsets {
30    pub next_entry_offset: usize,
31    pub file_name_length: usize,
32    pub file_name: usize,
33}
34
35fn read_u32(buf: &[u8], pos: usize) -> u32 {
36    u32::from_ne_bytes(buf[pos..pos + 4].try_into().unwrap())
37}
38
39fn write_u32(buf: &mut [u8], pos: usize, value: u32) {
40    buf[pos..pos + 4].copy_from_slice(&value.to_ne_bytes());
41}
42
43/// Result of [`filter_redirected_entries`].
44#[derive(Debug, PartialEq, Eq)]
45pub struct FilterResult {
46    /// Total bytes occupied by the retained entries, starting at `buf[0]`.
47    pub bytes_written: usize,
48    /// Byte offset of the last retained entry's header, if any. Callers
49    /// that want to append more entries after filtering (e.g. injecting
50    /// mod-only entries via [`write_directory_entry`]) pass this as that
51    /// function's `prev_entry_offset` to keep the chain intact.
52    pub last_entry_offset: Option<usize>,
53}
54
55/// Compacts `buf[..valid_len]` in place: entries are `FileXxxInformation`-
56/// shaped records chained by `NextEntryOffset`, and any entry whose
57/// rel_key (`rel_dir/name`, or bare `name` when `rel_dir` is empty,
58/// lowercased) makes `is_redirected` return `true` is dropped. Retained
59/// entries are copied down to close the resulting gap and rechained.
60///
61/// Traversal rules mirror the original raw-pointer loop this replaces,
62/// with one deliberate correctness fix: see the comment at the
63/// `entry_name_len` bounds check below. Everything else, including
64/// trusting `NextEntryOffset == 0` to mean "last entry", the same contract
65/// the real syscall's output follows, is unchanged.
66pub fn filter_redirected_entries(
67    buf: &mut [u8],
68    valid_len: usize,
69    offsets: EntryOffsets,
70    rel_dir: &str,
71    mut is_redirected: impl FnMut(&str) -> bool,
72) -> FilterResult {
73    let mut read_pos = 0usize;
74    let mut write_pos = 0usize;
75    let mut last_entry_offset: Option<usize> = None;
76
77    loop {
78        if read_pos + offsets.file_name_length + 4 > valid_len {
79            break;
80        }
81        let next_offset = read_u32(buf, read_pos + offsets.next_entry_offset) as usize;
82        let entry_name_len = read_u32(buf, read_pos + offsets.file_name_length) as usize;
83
84        // The original check here was `offsets.file_name + entry_name_len
85        // > valid_len`, bounding entry_name_len against the *total*
86        // buffer length rather than the space actually remaining from
87        // read_pos. That's the right check for the very first entry
88        // (where read_pos == 0) but not for any entry after it: a
89        // corrupt/oversized entry_name_len deep in the buffer could pass
90        // that check while still describing a name that runs past
91        // valid_len, and the read below would go out of bounds. Bounding
92        // against the remaining space from read_pos is what the check was
93        // actually trying to express, see the
94        // `filter_stops_at_a_truncated_final_entry_instead_of_reading_out_of_bounds`
95        // test for the case this fixes.
96        if read_pos + offsets.file_name + entry_name_len > valid_len {
97            break;
98        }
99
100        let entry_size = if next_offset > 0 {
101            next_offset
102        } else {
103            (offsets.file_name + entry_name_len + 7) & !7
104        };
105
106        let name_pos = read_pos + offsets.file_name;
107        let name_u16: Vec<u16> = buf[name_pos..name_pos + entry_name_len]
108            .as_chunks::<2>()
109            .0
110            .iter()
111            .map(|pair| u16::from_ne_bytes(*pair))
112            .collect();
113        let name = String::from_utf16_lossy(&name_u16).to_lowercase();
114
115        let rel_key = if rel_dir.is_empty() {
116            name
117        } else {
118            format!("{rel_dir}/{name}")
119        };
120
121        if !is_redirected(&rel_key) {
122            if write_pos != read_pos {
123                buf.copy_within(read_pos..read_pos + entry_size, write_pos);
124            }
125            if let Some(prev) = last_entry_offset {
126                write_u32(
127                    buf,
128                    prev + offsets.next_entry_offset,
129                    (write_pos - prev) as u32,
130                );
131            }
132            last_entry_offset = Some(write_pos);
133            write_pos += entry_size;
134        }
135
136        if next_offset == 0 {
137            break;
138        }
139        if read_pos + next_offset >= valid_len {
140            break;
141        }
142        read_pos += next_offset;
143    }
144
145    if let Some(prev) = last_entry_offset {
146        write_u32(buf, prev + offsets.next_entry_offset, 0);
147    }
148
149    FilterResult {
150        bytes_written: write_pos,
151        last_entry_offset,
152    }
153}
154
155/// Writes one synthetic directory entry (an injected mod-only filename) at
156/// `buf[pos..]`, matching the layout the original inline code built:
157/// zeroed header, `NextEntryOffset` left at 0 (patched later, on the
158/// *previous* entry, once this one is known to exist, see
159/// `prev_entry_offset` below), `FileAttributes` set to
160/// `FILE_ATTRIBUTE_NORMAL` when this info class has that field
161/// (`offsets.file_name >= 60` mirrors the original's `name_off >= 60`
162/// check, every supported class with a `file_name` offset that high also
163/// has `file_attributes` at byte 56), `FileNameLength`, and the name
164/// itself.
165///
166/// If `prev_entry_offset` is `Some`, that earlier entry's
167/// `NextEntryOffset` is patched to point at this one, the equivalent of
168/// the original loop's `prev_entry_ptr` bookkeeping.
169///
170/// Returns `None` (writing nothing) if the entry wouldn't fit in
171/// `buf[pos..]`, matching the original's `if write_ptr + entry_size >
172/// buf_end { break; }` check, callers should stop injecting further
173/// entries when this returns `None`, same as the original loop did.
174pub fn write_directory_entry(
175    buf: &mut [u8],
176    pos: usize,
177    offsets: EntryOffsets,
178    name: &str,
179    prev_entry_offset: Option<usize>,
180) -> Option<usize> {
181    const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080;
182
183    let wide_name: Vec<u16> = name.encode_utf16().collect();
184    let name_bytes = wide_name.len() * 2;
185    let entry_size = (offsets.file_name + name_bytes + 7) & !7;
186
187    if pos + entry_size > buf.len() {
188        return None;
189    }
190
191    buf[pos..pos + entry_size].fill(0);
192    write_u32(buf, pos + offsets.next_entry_offset, 0);
193    if offsets.file_name >= 60 {
194        write_u32(buf, pos + 56, FILE_ATTRIBUTE_NORMAL);
195    }
196    write_u32(buf, pos + offsets.file_name_length, name_bytes as u32);
197
198    let name_pos = pos + offsets.file_name;
199    for (i, unit) in wide_name.iter().enumerate() {
200        buf[name_pos + i * 2..name_pos + i * 2 + 2].copy_from_slice(&unit.to_ne_bytes());
201    }
202
203    if let Some(prev) = prev_entry_offset {
204        write_u32(buf, prev + offsets.next_entry_offset, (pos - prev) as u32);
205    }
206
207    Some(entry_size)
208}
209
210/// Glob-style filename matcher supporting `*` (any run of characters,
211/// including none) and `?` (exactly one character), via a straightforward
212/// O(n·m) dynamic-programming table over the two strings' characters.
213///
214/// Case-sensitive by itself, `hooks::query_dir`'s hook lowercases both the
215/// candidate name and the pattern before calling this, and any other
216/// caller wanting case-insensitive matching is responsible for doing the
217/// same; see `is_case_sensitive_by_itself` below.
218pub fn wildcard_match(name: &str, pattern: &str) -> bool {
219    let n: Vec<char> = name.chars().collect();
220    let p: Vec<char> = pattern.chars().collect();
221    let mut dp = vec![vec![false; p.len() + 1]; n.len() + 1];
222    dp[0][0] = true;
223    for j in 1..=p.len() {
224        if p[j - 1] == '*' {
225            dp[0][j] = dp[0][j - 1];
226        }
227    }
228    for i in 1..=n.len() {
229        for j in 1..=p.len() {
230            dp[i][j] = if p[j - 1] == '*' {
231                dp[i - 1][j] || dp[i][j - 1]
232            } else if p[j - 1] == '?' || p[j - 1] == n[i - 1] {
233                dp[i - 1][j - 1]
234            } else {
235                false
236            };
237        }
238    }
239    dp[n.len()][p.len()]
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    /// `FileNamesInformation`-shaped offsets: NextEntryOffset @ 0,
247    /// FileIndex @ 4, FileNameLength @ 8, FileName @ 12. The smallest of
248    /// the supported info classes, and enough to exercise both functions
249    /// above without needing the larger structs' irrelevant padding.
250    const NAMES_OFFSETS: EntryOffsets = EntryOffsets {
251        next_entry_offset: 0,
252        file_name_length: 8,
253        file_name: 12,
254    };
255
256    /// Builds a `FileNamesInformation`-shaped buffer out of `names`,
257    /// chained via `NextEntryOffset` the same way the real syscall would,
258    /// with the last entry's `NextEntryOffset` left at 0.
259    fn build_names_buffer(names: &[&str]) -> Vec<u8> {
260        let mut buf = Vec::new();
261        let mut entry_starts = Vec::new();
262        for name in names {
263            entry_starts.push(buf.len());
264            let wide: Vec<u16> = name.encode_utf16().collect();
265            let name_bytes = wide.len() * 2;
266            let entry_size = (NAMES_OFFSETS.file_name + name_bytes + 7) & !7;
267            let mut entry = vec![0u8; entry_size];
268            entry[8..12].copy_from_slice(&(name_bytes as u32).to_ne_bytes());
269            for (i, unit) in wide.iter().enumerate() {
270                entry[12 + i * 2..12 + i * 2 + 2].copy_from_slice(&unit.to_ne_bytes());
271            }
272            buf.extend_from_slice(&entry);
273        }
274        for i in 0..entry_starts.len() {
275            let next = if i + 1 < entry_starts.len() {
276                (entry_starts[i + 1] - entry_starts[i]) as u32
277            } else {
278                0
279            };
280            buf[entry_starts[i]..entry_starts[i] + 4].copy_from_slice(&next.to_ne_bytes());
281        }
282        buf
283    }
284
285    /// Test-only helper mirroring how a caller reads back a buffer's
286    /// entries, for asserting on results.
287    fn read_all_names(buf: &[u8], valid_len: usize, offsets: EntryOffsets) -> Vec<String> {
288        let mut names = Vec::new();
289        let mut pos = 0;
290        loop {
291            if pos + offsets.file_name_length + 4 > valid_len {
292                break;
293            }
294            let next = read_u32(buf, pos + offsets.next_entry_offset) as usize;
295            let name_len = read_u32(buf, pos + offsets.file_name_length) as usize;
296            let name_pos = pos + offsets.file_name;
297            let wide: Vec<u16> = buf[name_pos..name_pos + name_len]
298                .as_chunks::<2>()
299                .0
300                .iter()
301                .map(|pair| u16::from_ne_bytes(*pair))
302                .collect();
303            names.push(String::from_utf16_lossy(&wide));
304            if next == 0 {
305                break;
306            }
307            pos += next;
308        }
309        names
310    }
311
312    // --- filter_redirected_entries ---
313
314    #[test]
315    fn filter_keeps_all_entries_when_nothing_is_redirected() {
316        let mut buf = build_names_buffer(&["save1.dat", "save2.dat", "save3.dat"]);
317        let valid_len = buf.len();
318        let result = filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |_| false);
319        assert_eq!(result.bytes_written, valid_len);
320        assert_eq!(
321            read_all_names(&buf, result.bytes_written, NAMES_OFFSETS),
322            vec!["save1.dat", "save2.dat", "save3.dat"]
323        );
324    }
325
326    #[test]
327    fn filter_drops_a_middle_entry_and_rechains_around_it() {
328        let mut buf = build_names_buffer(&["keep1.dat", "drop.dat", "keep2.dat"]);
329        let valid_len = buf.len();
330        let result = filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |name| {
331            name == "drop.dat"
332        });
333        assert_eq!(
334            read_all_names(&buf, result.bytes_written, NAMES_OFFSETS),
335            vec!["keep1.dat", "keep2.dat"]
336        );
337    }
338
339    #[test]
340    fn filter_drops_the_first_entry() {
341        let mut buf = build_names_buffer(&["drop.dat", "keep.dat"]);
342        let valid_len = buf.len();
343        let result = filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |name| {
344            name == "drop.dat"
345        });
346        assert_eq!(
347            read_all_names(&buf, result.bytes_written, NAMES_OFFSETS),
348            vec!["keep.dat"]
349        );
350    }
351
352    #[test]
353    fn filter_drops_the_last_entry_and_renulls_the_new_last_next_offset() {
354        let mut buf = build_names_buffer(&["keep.dat", "drop.dat"]);
355        let valid_len = buf.len();
356        let result = filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |name| {
357            name == "drop.dat"
358        });
359        assert_eq!(
360            read_all_names(&buf, result.bytes_written, NAMES_OFFSETS),
361            vec!["keep.dat"]
362        );
363        // "keep.dat" is now the last surviving entry; its NextEntryOffset
364        // must be re-nulled even though it wasn't 0 in the original buffer
365        // (it originally pointed at "drop.dat").
366        let last = result.last_entry_offset.unwrap();
367        assert_eq!(read_u32(&buf, last + NAMES_OFFSETS.next_entry_offset), 0);
368    }
369
370    #[test]
371    fn filter_drops_everything() {
372        let mut buf = build_names_buffer(&["drop1.dat", "drop2.dat"]);
373        let valid_len = buf.len();
374        let result = filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |_| true);
375        assert_eq!(result.bytes_written, 0);
376        assert_eq!(result.last_entry_offset, None);
377    }
378
379    #[test]
380    fn filter_passes_lowercased_rel_dir_prefixed_key_to_the_predicate() {
381        let mut buf = build_names_buffer(&["Save1.DAT"]);
382        let valid_len = buf.len();
383        let mut seen = None;
384        filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "data/saves", |key| {
385            seen = Some(key.to_string());
386            false
387        });
388        assert_eq!(seen.as_deref(), Some("data/saves/save1.dat"));
389    }
390
391    #[test]
392    fn filter_with_empty_rel_dir_uses_bare_lowercased_name_as_key() {
393        let mut buf = build_names_buffer(&["Save1.DAT"]);
394        let valid_len = buf.len();
395        let mut seen = None;
396        filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |key| {
397            seen = Some(key.to_string());
398            false
399        });
400        assert_eq!(seen.as_deref(), Some("save1.dat"));
401    }
402
403    #[test]
404    fn filter_stops_at_a_truncated_final_entry_instead_of_reading_out_of_bounds() {
405        // A buffer whose second entry claims a FileNameLength longer than
406        // the space actually left in it. With the fixed bounds check (see
407        // the comment in filter_redirected_entries), the scan stops before
408        // that entry instead of indexing past valid_len.
409        let mut buf = build_names_buffer(&["ok.dat"]);
410        let ok_entry_size = buf.len();
411
412        // Append a second entry header claiming a huge file_name_length,
413        // with no actual name bytes behind it.
414        buf.extend_from_slice(&0u32.to_ne_bytes()); // next_entry_offset
415        buf.extend_from_slice(&0u32.to_ne_bytes()); // file_index
416        buf.extend_from_slice(&1000u32.to_ne_bytes()); // file_name_length (bogus)
417        let valid_len = buf.len(); // far short of 1000 bytes of name data
418
419        // Chain the first entry into the truncated second one
420        // (build_names_buffer left NextEntryOffset at 0 since "ok.dat" was
421        // the only name originally passed in).
422        buf[0..4].copy_from_slice(&(ok_entry_size as u32).to_ne_bytes());
423
424        let result = filter_redirected_entries(&mut buf, valid_len, NAMES_OFFSETS, "", |_| false);
425        assert_eq!(
426            read_all_names(&buf, result.bytes_written, NAMES_OFFSETS),
427            vec!["ok.dat"]
428        );
429    }
430
431    // --- write_directory_entry ---
432
433    #[test]
434    fn write_directory_entry_writes_name_and_length() {
435        let mut buf = vec![0u8; 64];
436        let written = write_directory_entry(&mut buf, 0, NAMES_OFFSETS, "mod.dat", None);
437        assert!(written.is_some());
438        let name_len = read_u32(&buf, NAMES_OFFSETS.file_name_length) as usize;
439        assert_eq!(name_len, "mod.dat".encode_utf16().count() * 2);
440        assert_eq!(read_all_names(&buf, buf.len(), NAMES_OFFSETS)[0], "mod.dat");
441    }
442
443    #[test]
444    fn write_directory_entry_chains_previous_entrys_next_offset() {
445        let mut buf = vec![0u8; 128];
446        let first_size =
447            write_directory_entry(&mut buf, 0, NAMES_OFFSETS, "first.dat", None).unwrap();
448        write_directory_entry(&mut buf, first_size, NAMES_OFFSETS, "second.dat", Some(0));
449
450        assert_eq!(
451            read_all_names(&buf, buf.len(), NAMES_OFFSETS),
452            vec!["first.dat", "second.dat"]
453        );
454    }
455
456    #[test]
457    fn write_directory_entry_returns_none_when_it_would_not_fit() {
458        let mut buf = vec![0u8; 16]; // too small for the header plus any name
459        let written = write_directory_entry(&mut buf, 0, NAMES_OFFSETS, "toolong.dat", None);
460        assert_eq!(written, None);
461    }
462
463    #[test]
464    fn write_directory_entry_sets_file_attribute_normal_when_offset_supports_it() {
465        // FileDirectoryInformation-shaped offsets: file_attributes @ 56,
466        // file_name_length @ 60, file_name @ 64 -- matches
467        // hooks::query_dir::FILE_DIRECTORY_INFO_OFFSETS.
468        let dir_offsets = EntryOffsets {
469            next_entry_offset: 0,
470            file_name_length: 60,
471            file_name: 64,
472        };
473        let mut buf = vec![0u8; 128];
474        write_directory_entry(&mut buf, 0, dir_offsets, "mod.dat", None);
475        assert_eq!(read_u32(&buf, 56), 0x0000_0080); // FILE_ATTRIBUTE_NORMAL
476    }
477
478    #[test]
479    fn write_directory_entry_skips_file_attribute_write_for_small_headers() {
480        // FileNamesInformation has no FileAttributes field, and byte 56 is
481        // out of bounds for this 32-byte buffer entirely -- the
482        // `offsets.file_name >= 60` guard must skip that write rather than
483        // panic on an out-of-bounds index or corrupt an unrelated field.
484        let mut buf = vec![0xAAu8; 32];
485        write_directory_entry(&mut buf, 0, NAMES_OFFSETS, "x.dat", None);
486        let name_len = read_u32(&buf, NAMES_OFFSETS.file_name_length) as usize;
487        assert_eq!(name_len, "x.dat".encode_utf16().count() * 2);
488    }
489}
490
491#[cfg(test)]
492mod wildcard_match_tests {
493    use super::wildcard_match;
494
495    #[test]
496    fn exact_match() {
497        assert!(wildcard_match("save.dat", "save.dat"));
498    }
499
500    #[test]
501    fn no_match_different_name() {
502        assert!(!wildcard_match("save.dat", "config.dat"));
503    }
504
505    #[test]
506    fn star_matches_everything() {
507        assert!(wildcard_match("save.dat", "*"));
508        assert!(wildcard_match("", "*"));
509    }
510
511    #[test]
512    fn star_matches_suffix() {
513        assert!(wildcard_match("save.dat", "*.dat"));
514        assert!(!wildcard_match("save.txt", "*.dat"));
515    }
516
517    #[test]
518    fn star_matches_prefix() {
519        assert!(wildcard_match("save1.dat", "save*"));
520        assert!(!wildcard_match("config.dat", "save*"));
521    }
522
523    #[test]
524    fn star_matches_middle() {
525        assert!(wildcard_match("save_001_final.dat", "save*final.dat"));
526    }
527
528    #[test]
529    fn star_can_match_empty_string() {
530        assert!(wildcard_match("save.dat", "save*.dat"));
531    }
532
533    #[test]
534    fn question_mark_matches_exactly_one_char() {
535        assert!(wildcard_match("save1.dat", "save?.dat"));
536        assert!(!wildcard_match("save12.dat", "save?.dat"));
537        assert!(!wildcard_match("save.dat", "save?.dat"));
538    }
539
540    #[test]
541    fn multiple_stars() {
542        assert!(wildcard_match(
543            "save_001_backup_final.dat",
544            "save*backup*.dat"
545        ));
546    }
547
548    #[test]
549    fn empty_pattern_only_matches_empty_name() {
550        assert!(wildcard_match("", ""));
551        assert!(!wildcard_match("save.dat", ""));
552    }
553
554    #[test]
555    fn empty_name_matches_only_all_star_pattern() {
556        assert!(wildcard_match("", "***"));
557        assert!(!wildcard_match("", "save*"));
558    }
559
560    // wildcard_match itself is case-sensitive; callers are responsible for
561    // lowercasing both sides first (as query_dir.rs's hook does via
562    // to_lowercase() before ever calling this). Documenting that contract
563    // here as a test, since a future call site that forgets to lowercase
564    // would fail silently (no match, not a panic) rather than loudly.
565    #[test]
566    fn is_case_sensitive_by_itself() {
567        assert!(!wildcard_match("Save.dat", "save.dat"));
568    }
569}