Skip to main content

smb2_client/
msg.rs

1//! SMB2 request bodies and response parsers (MS-SMB2 §2.2). Offsets in the on-wire
2//! `*Offset` fields are measured from the start of the SMB2 header (i.e. `64 + body_off`).
3
4use crate::{Result, SmbError};
5
6fn utf16le(s: &str) -> Vec<u8> {
7    s.encode_utf16().flat_map(u16::to_le_bytes).collect()
8}
9fn u16(b: &[u8], o: usize) -> u16 {
10    u16::from_le_bytes([b[o], b[o + 1]])
11}
12fn u32(b: &[u8], o: usize) -> u32 {
13    u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
14}
15
16// ---- NEGOTIATE (§2.2.3) ---------------------------------------------------
17
18/// Offer dialect 2.1.0 with a random client GUID.
19pub fn negotiate(client_guid: &[u8; 16]) -> Vec<u8> {
20    // Offer SMB 2.0.2 (Server 2008/R2) and 2.1.0. The server picks the highest it supports and
21    // negotiates *down*, so this reaches 2008 through 2025 (2012/2016/2019/2022/2025 all accept
22    // 2.1.0 — validated live against Server 2025). Both sign with HMAC-SHA256.
23    //
24    // SMB 3.0.x (AES-CMAC) support exists in header.rs (sign_v3 / kdf_signing_key) and the
25    // client branches on the negotiated dialect, but 3.x is not offered yet — it's only needed
26    // for servers hardened to refuse SMB2 entirely, and the CMAC path isn't validated.
27    let dialects: [u16; 2] = [0x0202, 0x0210];
28    let mut b = Vec::new();
29    b.extend_from_slice(&36u16.to_le_bytes()); // StructureSize
30    b.extend_from_slice(&(dialects.len() as u16).to_le_bytes()); // DialectCount
31    b.extend_from_slice(&0x0001u16.to_le_bytes()); // SecurityMode = SIGNING_ENABLED
32    b.extend_from_slice(&0u16.to_le_bytes()); // Reserved
33    b.extend_from_slice(&0u32.to_le_bytes()); // Capabilities
34    b.extend_from_slice(client_guid);
35    b.extend_from_slice(&0u64.to_le_bytes()); // ClientStartTime
36    for dialect in dialects {
37        b.extend_from_slice(&dialect.to_le_bytes());
38    }
39    b
40}
41
42// ---- SESSION_SETUP (§2.2.5 / §2.2.6) --------------------------------------
43
44/// The security buffer holds a raw NTLMSSP token.
45pub fn session_setup(token: &[u8]) -> Vec<u8> {
46    let mut b = Vec::new();
47    b.extend_from_slice(&25u16.to_le_bytes()); // StructureSize
48    b.push(0); // Flags
49    b.push(0x01); // SecurityMode = SIGNING_ENABLED
50    b.extend_from_slice(&0u32.to_le_bytes()); // Capabilities
51    b.extend_from_slice(&0u32.to_le_bytes()); // Channel
52    let sec_off = 64u16 + 24; // header + fixed part
53    b.extend_from_slice(&sec_off.to_le_bytes()); // SecurityBufferOffset
54    b.extend_from_slice(&(token.len() as u16).to_le_bytes()); // SecurityBufferLength
55    b.extend_from_slice(&0u64.to_le_bytes()); // PreviousSessionId
56    b.extend_from_slice(token);
57    b
58}
59
60/// Extract the security buffer (server NTLM token) from a SESSION_SETUP response.
61pub fn session_setup_token(msg: &[u8]) -> Result<Vec<u8>> {
62    // body starts at 64; StructureSize(2), SessionFlags(2), SecBufOffset(2), SecBufLength(2)
63    let body = msg.get(64..).ok_or(SmbError::Truncated)?;
64    let off = u16(body, 4) as usize; // from SMB header start
65    let len = u16(body, 6) as usize;
66    msg.get(off..off + len)
67        .map(|s| s.to_vec())
68        .ok_or(SmbError::Truncated)
69}
70
71// ---- TREE_CONNECT (§2.2.9) ------------------------------------------------
72
73pub fn tree_connect(path: &str) -> Vec<u8> {
74    let name = utf16le(path);
75    let mut b = Vec::new();
76    b.extend_from_slice(&9u16.to_le_bytes()); // StructureSize
77    b.extend_from_slice(&0u16.to_le_bytes()); // Reserved/Flags
78    let path_off = 64u16 + 8;
79    b.extend_from_slice(&path_off.to_le_bytes()); // PathOffset
80    b.extend_from_slice(&(name.len() as u16).to_le_bytes()); // PathLength
81    b.extend_from_slice(&name);
82    b
83}
84
85// ---- CREATE (§2.2.13 / §2.2.14) -------------------------------------------
86
87/// Open a named pipe (e.g. "samr") on the IPC$ tree.
88pub fn create_pipe(name: &str) -> Vec<u8> {
89    let n = utf16le(name);
90    let mut b = Vec::new();
91    b.extend_from_slice(&57u16.to_le_bytes()); // StructureSize
92    b.push(0); // SecurityFlags
93    b.push(0); // RequestedOplockLevel
94    b.extend_from_slice(&2u32.to_le_bytes()); // ImpersonationLevel = Impersonation
95    b.extend_from_slice(&0u64.to_le_bytes()); // SmbCreateFlags
96    b.extend_from_slice(&0u64.to_le_bytes()); // Reserved
97    b.extend_from_slice(&0x0012_019Fu32.to_le_bytes()); // DesiredAccess: read+write data/EA/attrs (WRITE needs FILE_WRITE_DATA for a fire-and-forget AUTH3)
98    b.extend_from_slice(&0u32.to_le_bytes()); // FileAttributes
99    b.extend_from_slice(&0x0000_0007u32.to_le_bytes()); // ShareAccess = R|W|D
100    b.extend_from_slice(&0x0000_0001u32.to_le_bytes()); // CreateDisposition = OPEN
101    b.extend_from_slice(&0u32.to_le_bytes()); // CreateOptions
102    let name_off = 64u16 + 56;
103    b.extend_from_slice(&name_off.to_le_bytes()); // NameOffset
104    b.extend_from_slice(&(n.len() as u16).to_le_bytes()); // NameLength
105    b.extend_from_slice(&0u32.to_le_bytes()); // CreateContextsOffset
106    b.extend_from_slice(&0u32.to_le_bytes()); // CreateContextsLength
107    b.extend_from_slice(&n);
108    b
109}
110
111/// Generic disk-file CREATE (§2.2.13). `path` is relative to the connected share root (no
112/// leading backslash). Callers pass the access mask, share mode, disposition, and options.
113pub fn create_file(path: &str, access: u32, share: u32, disposition: u32, options: u32) -> Vec<u8> {
114    let n = utf16le(path);
115    let mut b = Vec::new();
116    b.extend_from_slice(&57u16.to_le_bytes()); // StructureSize
117    b.push(0); // SecurityFlags
118    b.push(0); // RequestedOplockLevel
119    b.extend_from_slice(&2u32.to_le_bytes()); // ImpersonationLevel = Impersonation
120    b.extend_from_slice(&0u64.to_le_bytes()); // SmbCreateFlags
121    b.extend_from_slice(&0u64.to_le_bytes()); // Reserved
122    b.extend_from_slice(&access.to_le_bytes()); // DesiredAccess
123    b.extend_from_slice(&0u32.to_le_bytes()); // FileAttributes (ignored on OPEN)
124    b.extend_from_slice(&share.to_le_bytes()); // ShareAccess
125    b.extend_from_slice(&disposition.to_le_bytes()); // CreateDisposition
126    b.extend_from_slice(&options.to_le_bytes()); // CreateOptions
127
128    // NameOffset always points at the buffer position, and the variable buffer
129    // is always present (≥1 byte). Opening the share root (empty name) needs
130    // NameLength=0 but a NameOffset that still addresses a real byte in the
131    // message plus that mandatory padding byte — Windows returns
132    // STATUS_INVALID_PARAMETER for a 57-byte body whose name buffer is absent.
133    let name_off = 64u16 + 56;
134    b.extend_from_slice(&name_off.to_le_bytes()); // NameOffset
135    b.extend_from_slice(&(n.len() as u16).to_le_bytes()); // NameLength
136    b.extend_from_slice(&0u32.to_le_bytes()); // CreateContextsOffset
137    b.extend_from_slice(&0u32.to_le_bytes()); // CreateContextsLength
138    if n.is_empty() {
139        b.push(0); // mandatory 1-byte Buffer when there is no name
140    } else {
141        b.extend_from_slice(&n);
142    }
143    b
144}
145
146/// SMB2 READ (§2.2.19): read `length` bytes at `offset` from the open file.
147pub fn read_req(file_id: &[u8; 16], offset: u64, length: u32) -> Vec<u8> {
148    let mut b = Vec::new();
149    b.extend_from_slice(&49u16.to_le_bytes()); // StructureSize
150    b.push(0); // Padding
151    b.push(0); // Flags
152    b.extend_from_slice(&length.to_le_bytes()); // Length
153    b.extend_from_slice(&offset.to_le_bytes()); // Offset
154    b.extend_from_slice(file_id);
155    b.extend_from_slice(&0u32.to_le_bytes()); // MinimumCount
156    b.extend_from_slice(&0u32.to_le_bytes()); // Channel
157    b.extend_from_slice(&0u32.to_le_bytes()); // RemainingBytes
158    b.extend_from_slice(&0u16.to_le_bytes()); // ReadChannelInfoOffset
159    b.extend_from_slice(&0u16.to_le_bytes()); // ReadChannelInfoLength
160    b.push(0); // Buffer (min 1 byte)
161    b
162}
163
164/// Extract the data returned by a READ response (§2.2.20).
165pub fn read_output(msg: &[u8]) -> Result<Vec<u8>> {
166    let body = msg.get(64..).ok_or(SmbError::Truncated)?;
167    let data_off = *body.get(2).ok_or(SmbError::Truncated)? as usize; // DataOffset, from header start
168    let data_len = u32(body, 4) as usize;
169    msg.get(data_off..data_off + data_len)
170        .map(|s| s.to_vec())
171        .ok_or(SmbError::Truncated)
172}
173
174/// SMB2 WRITE (§2.2.21): write `data` to the open handle at `offset`.
175pub fn write_req(file_id: &[u8; 16], offset: u64, data: &[u8]) -> Vec<u8> {
176    let mut b = Vec::new();
177    b.extend_from_slice(&49u16.to_le_bytes()); // StructureSize
178    b.extend_from_slice(&(64u16 + 48).to_le_bytes()); // DataOffset (header + 48-byte body)
179    b.extend_from_slice(&(data.len() as u32).to_le_bytes()); // Length
180    b.extend_from_slice(&offset.to_le_bytes()); // Offset
181    b.extend_from_slice(file_id);
182    b.extend_from_slice(&0u32.to_le_bytes()); // Channel
183    b.extend_from_slice(&0u32.to_le_bytes()); // RemainingBytes
184    b.extend_from_slice(&0u16.to_le_bytes()); // WriteChannelInfoOffset
185    b.extend_from_slice(&0u16.to_le_bytes()); // WriteChannelInfoLength
186    b.extend_from_slice(&0u32.to_le_bytes()); // Flags
187    b.extend_from_slice(data);
188    b
189}
190
191/// SMB2 CLOSE (§2.2.15).
192pub fn close_req(file_id: &[u8; 16]) -> Vec<u8> {
193    let mut b = Vec::new();
194    b.extend_from_slice(&24u16.to_le_bytes()); // StructureSize
195    b.extend_from_slice(&0u16.to_le_bytes()); // Flags
196    b.extend_from_slice(&0u32.to_le_bytes()); // Reserved
197    b.extend_from_slice(file_id);
198    b
199}
200
201/// FileId (16 bytes) from a CREATE response.
202pub fn create_file_id(msg: &[u8]) -> Result<[u8; 16]> {
203    // FileId sits at body offset 64 → absolute 128.
204    msg.get(128..144)
205        .map(|s| s.try_into().unwrap())
206        .ok_or(SmbError::Truncated)
207}
208
209/// One entry from a directory enumeration (FileDirectoryInformation, class 1).
210#[derive(Clone, Debug, PartialEq, Eq)]
211pub struct DirEntry {
212    pub name: String,
213    pub is_dir: bool,
214    pub size: u64,
215}
216
217/// FileDirectoryInformation class (§2.4.10) — the classic
218/// name+attrs+size directory-enumeration info-class. Exposed publicly
219/// in 0.2.4 (adopted from g0h4n's PR #1) so downstream callers building
220/// custom `query_directory_req`-shaped requests do not have to remember
221/// the magic byte. Additional info classes (FileFullDirectoryInformation
222/// class 2, FileBothDirectoryInformation class 3, etc.) can be added
223/// alongside if needed.
224pub const FILE_DIRECTORY_INFORMATION: u8 = 0x01;
225
226/// Raw output-buffer extractor for a QUERY_DIRECTORY response (§2.2.34).
227/// Returns the whole info-class buffer as bytes; callers that want the
228/// higher-level `Vec<DirEntry>` decoding call [`parse_directory_info`]
229/// instead. Adopted from g0h4n's PR #1 for 0.2.4 — useful when a caller
230/// wants to walk a non-FileDirectoryInformation info-class from the same
231/// wire framing. Bounds-checked: a truncated response returns
232/// `SmbError::Truncated`, never panics through the direct-indexing
233/// helpers.
234pub fn query_directory_output(msg: &[u8]) -> Result<Vec<u8>> {
235    let body = msg.get(64..).ok_or(SmbError::Truncated)?;
236    if body.len() < 8 {
237        return Err(SmbError::Truncated);
238    }
239    let off = u16(body, 2) as usize; // OutputBufferOffset, from header start
240    let len = u32(body, 4) as usize; // OutputBufferLength
241    msg.get(off..off.checked_add(len).ok_or(SmbError::Truncated)?)
242        .map(|s| s.to_vec())
243        .ok_or(SmbError::Truncated)
244}
245
246/// SMB2 QUERY_DIRECTORY (§2.2.33): enumerate an open directory handle using
247/// FileDirectoryInformation (class 1). `pattern` is the search wildcard
248/// (typically `*`); on continuation calls the server ignores it and resumes
249/// from where the handle left off, so passing `*` every time is correct.
250pub fn query_directory_req(file_id: &[u8; 16], pattern: &str, output_len: u32) -> Vec<u8> {
251    let n = utf16le(pattern);
252    let mut b = Vec::new();
253    b.extend_from_slice(&33u16.to_le_bytes()); // StructureSize (fixed 33)
254    b.push(FILE_DIRECTORY_INFORMATION); // FileInformationClass
255    b.push(0); // Flags (0: resume from handle position)
256    b.extend_from_slice(&0u32.to_le_bytes()); // FileIndex
257    b.extend_from_slice(file_id);
258    let name_off = 64u16 + 32; // header + 32-byte fixed body
259    b.extend_from_slice(&name_off.to_le_bytes()); // FileNameOffset
260    b.extend_from_slice(&(n.len() as u16).to_le_bytes()); // FileNameLength
261    b.extend_from_slice(&output_len.to_le_bytes()); // OutputBufferLength
262    if n.is_empty() {
263        b.push(0); // Buffer min 1 byte
264    } else {
265        b.extend_from_slice(&n);
266    }
267    b
268}
269
270/// Parse a QUERY_DIRECTORY response (§2.2.34) carrying FileDirectoryInformation
271/// entries. Bounds-checked and loop-bounded: a hostile server cannot drive an
272/// out-of-range read or a non-terminating walk (a NextEntryOffset that fails to
273/// advance, or an entry claiming a name longer than the buffer, ends parsing).
274pub fn parse_directory_info(msg: &[u8]) -> Result<Vec<DirEntry>> {
275    const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
276    let body = msg.get(64..).ok_or(SmbError::Truncated)?;
277    // Response fixed part: StructureSize(2), OutputBufferOffset(2), OutputBufferLength(4).
278    // Guard its 8 bytes before the direct-indexing u16/u32 helpers touch them —
279    // a truncated response must return empty, not panic.
280    if body.len() < 8 {
281        return Ok(Vec::new());
282    }
283    let out_off = u16(body, 2) as usize; // from header start
284    let out_len = u32(body, 4) as usize;
285    let buf = msg
286        .get(out_off..out_off.checked_add(out_len).ok_or(SmbError::Truncated)?)
287        .ok_or(SmbError::Truncated)?;
288
289    let mut entries = Vec::new();
290    let mut pos = 0usize;
291    // Cap iterations well above any real directory to bound a malformed chain.
292    for _ in 0..100_000 {
293        let rec = match buf.get(pos..) {
294            Some(r) if r.len() >= 64 => r,
295            _ => break,
296        };
297        let next = u32(rec, 0) as usize; // NextEntryOffset
298        let attrs = u32(rec, 56); // FileAttributes
299        let name_len = u32(rec, 60) as usize; // FileNameLength (bytes)
300                                              // FileName starts at fixed offset 64 within the record.
301        if let Some(name_bytes) = rec.get(64..64usize.saturating_add(name_len)) {
302            let units: Vec<u16> = name_bytes
303                .chunks_exact(2)
304                .map(|c| u16::from_le_bytes([c[0], c[1]]))
305                .collect();
306            let name = String::from_utf16_lossy(&units);
307            if name != "." && name != ".." && !name.is_empty() {
308                entries.push(DirEntry {
309                    name,
310                    is_dir: attrs & FILE_ATTRIBUTE_DIRECTORY != 0,
311                    size: u64::from_le_bytes(
312                        rec.get(40..48)
313                            .and_then(|s| s.try_into().ok())
314                            .unwrap_or([0; 8]),
315                    ),
316                });
317            }
318        } else {
319            break; // name overruns the record → stop, don't read OOB
320        }
321        if next == 0 {
322            break; // last entry
323        }
324        // NextEntryOffset must strictly advance, else a hostile 0-cycle loops forever.
325        pos = match pos.checked_add(next) {
326            Some(p) if p > pos => p,
327            _ => break,
328        };
329    }
330    Ok(entries)
331}
332
333// ---- IOCTL (§2.2.31 / §2.2.32) --------------------------------------------
334
335pub const FSCTL_PIPE_TRANSCEIVE: u32 = 0x0011_C017;
336
337/// Send `input` through the pipe and read the response in one round trip.
338pub fn ioctl_transceive(file_id: &[u8; 16], input: &[u8]) -> Vec<u8> {
339    let mut b = Vec::new();
340    b.extend_from_slice(&57u16.to_le_bytes()); // StructureSize
341    b.extend_from_slice(&0u16.to_le_bytes()); // Reserved
342    b.extend_from_slice(&FSCTL_PIPE_TRANSCEIVE.to_le_bytes()); // CtlCode
343    b.extend_from_slice(file_id);
344    let input_off = 64u32 + 56;
345    b.extend_from_slice(&input_off.to_le_bytes()); // InputOffset
346    b.extend_from_slice(&(input.len() as u32).to_le_bytes()); // InputCount
347    b.extend_from_slice(&0u32.to_le_bytes()); // MaxInputResponse
348    b.extend_from_slice(&input_off.to_le_bytes()); // OutputOffset
349    b.extend_from_slice(&0u32.to_le_bytes()); // OutputCount
350    b.extend_from_slice(&0x0001_0000u32.to_le_bytes()); // MaxOutputResponse (64 KiB — SMB2.1 max transact)
351    b.extend_from_slice(&0x0000_0001u32.to_le_bytes()); // Flags = IS_FSCTL
352    b.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
353    b.extend_from_slice(input);
354    b
355}
356
357/// Extract the pipe output (RPC response bytes) from an IOCTL response.
358pub fn ioctl_output(msg: &[u8]) -> Result<Vec<u8>> {
359    // response body: StructureSize(2) Reserved(2) CtlCode(4) FileId(16)
360    // InputOffset(4) InputCount(4) OutputOffset(4) OutputCount(4) ...
361    let body = msg.get(64..).ok_or(SmbError::Truncated)?;
362    let out_off = u32(body, 32) as usize; // from SMB header start
363    let out_len = u32(body, 36) as usize;
364    msg.get(out_off..out_off + out_len)
365        .map(|s| s.to_vec())
366        .ok_or(SmbError::Truncated)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn negotiate_offers_dialect_210() {
375        let b = negotiate(&[0; 16]);
376        assert_eq!(u16(&b, 0), 36); // StructureSize
377        assert_eq!(u16(&b, 2), 2); // DialectCount (2.0.2 + 2.1.0)
378                                   // dialects at 36 (fixed part) — after 4+2+2+4+16+8 = 36
379        assert_eq!(u16(&b, 36), 0x0202);
380        assert_eq!(u16(&b, 38), 0x0210);
381    }
382
383    #[test]
384    fn create_pipe_name_offset_correct() {
385        let b = create_pipe("samr");
386        assert_eq!(u16(&b, 0), 57);
387        assert_eq!(u16(&b, 44), 64 + 56); // NameOffset field
388        assert_eq!(u16(&b, 46), 8); // "samr" = 4 wchar * 2
389    }
390
391    #[test]
392    fn ioctl_uses_transceive_ctlcode() {
393        let b = ioctl_transceive(&[0; 16], &[1, 2, 3]);
394        assert_eq!(u32(&b, 4), FSCTL_PIPE_TRANSCEIVE);
395        assert_eq!(u32(&b, 28), 3); // InputCount
396    }
397
398    #[test]
399    fn query_directory_req_shape() {
400        let b = query_directory_req(&[0; 16], "*", 0x1_0000);
401        assert_eq!(u16(&b, 0), 33); // StructureSize
402        assert_eq!(b[2], 0x01); // FileInformationClass = FileDirectoryInformation
403        assert_eq!(u16(&b, 24), 64 + 32); // FileNameOffset
404        assert_eq!(u16(&b, 26), 2); // FileNameLength ("*" = 1 wchar × 2)
405        assert_eq!(u32(&b, 28), 0x1_0000); // OutputBufferLength
406    }
407
408    // Hand-build a QUERY_DIRECTORY response with two FileDirectoryInformation
409    // records (a directory "Policies" and a file "GptTmpl.inf") plus the "."/".."
410    // entries that must be filtered. Validates the NDR-free info walk.
411    #[test]
412    fn parse_directory_info_reads_entries_and_filters_dot() {
413        fn rec(out: &mut Vec<u8>, next: u32, attrs: u32, size: u64, name: &str) {
414            let units: Vec<u16> = name.encode_utf16().collect();
415            let name_bytes: Vec<u8> = units.iter().flat_map(|u| u.to_le_bytes()).collect();
416            let start = out.len();
417            out.extend_from_slice(&next.to_le_bytes()); // 0 NextEntryOffset
418            out.extend_from_slice(&0u32.to_le_bytes()); // 4 FileIndex
419            out.extend_from_slice(&[0u8; 32]); // 8..40 four FILETIMEs
420            out.extend_from_slice(&size.to_le_bytes()); // 40 EndOfFile
421            out.extend_from_slice(&0u64.to_le_bytes()); // 48 AllocationSize
422            out.extend_from_slice(&attrs.to_le_bytes()); // 56 FileAttributes
423            out.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes()); // 60 FileNameLength
424            out.extend_from_slice(&name_bytes); // 64.. FileName
425            if next != 0 {
426                // pad this record out to exactly `next` bytes
427                while out.len() - start < next as usize {
428                    out.push(0);
429                }
430            }
431        }
432        let mut buf = Vec::new();
433        rec(&mut buf, 72, 0x10, 0, "."); // filtered
434        rec(&mut buf, 72, 0x10, 0, ".."); // filtered
435        rec(&mut buf, 80, 0x10, 0, "Policies"); // dir
436        rec(&mut buf, 0, 0x20, 1234, "GptTmpl.inf"); // file (last)
437
438        // Wrap in an SMB2 response: 64-byte header + fixed part (StructureSize,
439        // OutputBufferOffset, OutputBufferLength), then the buffer.
440        let out_off = 64u16 + 8;
441        let mut msg = vec![0u8; 64];
442        msg.extend_from_slice(&9u16.to_le_bytes()); // StructureSize
443        msg.extend_from_slice(&out_off.to_le_bytes()); // OutputBufferOffset
444        msg.extend_from_slice(&(buf.len() as u32).to_le_bytes()); // OutputBufferLength
445        msg.extend_from_slice(&buf);
446
447        let entries = parse_directory_info(&msg).unwrap();
448        assert_eq!(entries.len(), 2);
449        assert_eq!(entries[0].name, "Policies");
450        assert!(entries[0].is_dir);
451        assert_eq!(entries[1].name, "GptTmpl.inf");
452        assert!(!entries[1].is_dir);
453        assert_eq!(entries[1].size, 1234);
454    }
455
456    #[test]
457    fn parse_directory_info_survives_hostile_input() {
458        // Truncated / zero buffers must not panic.
459        for cut in 0..80 {
460            let _ = parse_directory_info(&vec![0u8; cut]);
461        }
462        // A record whose NextEntryOffset does not advance (0-cycle guard) and a
463        // name_len that overruns the record must terminate, not loop/OOB.
464        let mut buf = vec![0u8; 64];
465        buf[60..64].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // FileNameLength = u32::MAX
466        let out_off = 64u16 + 8;
467        let mut msg = vec![0u8; 64];
468        msg.extend_from_slice(&9u16.to_le_bytes());
469        msg.extend_from_slice(&out_off.to_le_bytes());
470        msg.extend_from_slice(&(buf.len() as u32).to_le_bytes());
471        msg.extend_from_slice(&buf);
472        let entries = parse_directory_info(&msg).unwrap();
473        assert!(entries.is_empty()); // name overrun → skipped, next=0 → stop
474    }
475
476    #[test]
477    fn create_file_carries_access_and_options() {
478        let b = create_file("Windows\\Temp\\x.out", 0x0013_0081, 0x7, 1, 0x1060);
479        assert_eq!(u16(&b, 0), 57); // StructureSize
480        assert_eq!(u32(&b, 24), 0x0013_0081); // DesiredAccess
481        assert_eq!(u32(&b, 32), 0x7); // ShareAccess
482        assert_eq!(u32(&b, 36), 1); // CreateDisposition = FILE_OPEN
483        assert_eq!(u32(&b, 40), 0x1060); // CreateOptions (incl DELETE_ON_CLOSE)
484        assert_eq!(u16(&b, 44), 64 + 56); // NameOffset
485        assert_eq!(
486            u16(&b, 46),
487            "Windows\\Temp\\x.out".chars().count() as u16 * 2
488        );
489    }
490
491    #[test]
492    fn read_req_offset_and_length() {
493        let b = read_req(&[0xAB; 16], 0x1_0000, 0x4000);
494        assert_eq!(u16(&b, 0), 49); // StructureSize
495        assert_eq!(u32(&b, 4), 0x4000); // Length
496        assert_eq!(u32(&b, 8), 0x1_0000); // Offset (low dword)
497        assert_eq!(&b[16..32], &[0xAB; 16]); // FileId
498    }
499
500    #[test]
501    fn close_req_shape() {
502        let b = close_req(&[0xCD; 16]);
503        assert_eq!(u16(&b, 0), 24); // StructureSize
504        assert_eq!(&b[8..24], &[0xCD; 16]); // FileId
505    }
506}