Skip to main content

smb_server_proto_smb2/
info.rs

1//! SMB2 information-level payload codecs ([MS-SMB2] §2.2.37/§2.2.39 with
2//! [MS-FSCC] §2.4/§2.5/§2.6 structures).
3
4use smb_server_proto::buf::Writer;
5
6/// FileInformationClass values ([MS-FSCC] §2.4).
7pub mod file_class {
8    /// FILE_DIRECTORY_INFORMATION (find only).
9    pub const DIRECTORY: u8 = 0x01;
10    /// FILE_FULL_DIRECTORY_INFORMATION (find only).
11    pub const FULL_DIRECTORY: u8 = 0x02;
12    /// FILE_BOTH_DIRECTORY_INFORMATION (find only).
13    pub const BOTH_DIRECTORY: u8 = 0x03;
14    /// FileRenameInformation.
15    pub const RENAME: u8 = 0x0A;
16    /// FileDispositionInformation.
17    pub const DISPOSITION: u8 = 0x0D;
18    /// FileAllocationInformation.
19    pub const ALLOCATION: u8 = 0x13;
20    /// FileEndOfFileInformation.
21    pub const END_OF_FILE: u8 = 0x14;
22    /// FileBasicInformation.
23    pub const BASIC: u8 = 0x04;
24    /// FileStandardInformation.
25    pub const STANDARD: u8 = 0x05;
26    /// FileInternalInformation.
27    pub const INTERNAL: u8 = 0x06;
28    /// FileEaInformation.
29    pub const EA: u8 = 0x07;
30    /// FileFullEaInformation ([MS-FSCC] §2.4.15).
31    pub const FULL_EA: u8 = 0x0F;
32    /// FileAccessInformation.
33    pub const ACCESS: u8 = 0x08;
34    /// FileNameInformation.
35    pub const NAME: u8 = 0x09;
36    /// FileModeInformation.
37    pub const MODE: u8 = 0x10;
38    /// FileAlignmentInformation.
39    pub const ALIGNMENT: u8 = 0x11;
40    /// FileAllInformation.
41    pub const ALL: u8 = 0x12;
42    /// FileStreamInformation ([MS-FSCC] §2.4.40).
43    pub const STREAM: u8 = 0x16;
44    /// FilePositionInformation.
45    pub const POSITION: u8 = 0x0E;
46    /// FileNetworkOpenInformation.
47    pub const NETWORK_OPEN: u8 = 0x22;
48    /// FileAttributeTagInformation.
49    pub const ATTRIBUTE_TAG: u8 = 0x23;
50    /// FileNormalizedNameInformation ([MS-FSCC] §2.4.NormalizedName).
51    pub const NORMALIZED_NAME: u8 = 0x30;
52}
53
54/// FsInformationClass values ([MS-FSCC] §2.5).
55pub mod fs_class {
56    /// FileFsVolumeInformation.
57    pub const VOLUME: u8 = 0x01;
58    /// FileFsSizeInformation.
59    pub const SIZE: u8 = 0x03;
60    /// FileFsDeviceInformation.
61    pub const DEVICE: u8 = 0x04;
62    /// FileFsAttributeInformation.
63    pub const ATTRIBUTE: u8 = 0x05;
64    /// FileFsFullSizeInformation.
65    pub const FULL_SIZE: u8 = 0x07;
66}
67
68/// Directory enumeration classes beyond [MS-FSCC] §2.4 basics
69/// ([MS-SMB2] §2.2.33.2 / Windows FILE_INFO_FROM_CLASS numbers).
70pub mod find_class {
71    /// FILE_NAMES_INFORMATION.
72    pub const NAMES: u8 = 0x0C;
73    /// FILE_ID_BOTH_DIRECTORY_INFORMATION.
74    pub const ID_BOTH_DIRECTORY: u8 = 37;
75    /// FILE_ID_FULL_DIRECTORY_INFORMATION.
76    pub const ID_FULL_DIRECTORY: u8 = 38;
77}
78
79/// Neutral metadata snapshot fed to the encoders below.
80#[derive(Debug, Clone, Default)]
81pub struct QueryMeta {
82    /// Creation / last-access / last-write / change FILETIMEs (raw NT).
83    pub times: [u64; 4],
84    /// Attribute flags.
85    pub attrs: u32,
86    /// End of file in bytes.
87    pub eof: u64,
88    /// Allocation size in bytes.
89    pub alloc: u64,
90    /// True when the object is a directory.
91    pub is_dir: bool,
92}
93
94impl QueryMeta {
95    /// Snapshot from a VFS metadata struct.
96    pub fn from_vfs(m: &smb_server_vfs::FileMeta) -> QueryMeta {
97        QueryMeta {
98            times: [m.times[0].0, m.times[1].0, m.times[2].0, m.times[3].0],
99            attrs: m.attrs.0,
100            eof: if m.is_dir { 0 } else { m.eof },
101            alloc: m.alloc,
102            is_dir: m.is_dir,
103        }
104    }
105}
106
107/// Access mask reported by ACCESS/ALL levels for handles opened on this
108/// server (read+write generic set).
109pub const GENERIC_RW_ACCESS: u32 = 0x0012_0089 | 0x0000_0016;
110
111fn push_utf16_len(d: &mut Writer, s: &str) {
112    let units: Vec<u16> = s.encode_utf16().collect();
113    d.push_u32((units.len() * 2) as u32);
114    for u in units {
115        d.push_u16(u);
116    }
117}
118
119fn push_times(d: &mut Writer, times: &[u64; 4]) {
120    for t in times {
121        d.push_u64(*t);
122    }
123}
124
125/// Encode a QUERY_INFO file payload for `class` from neutral metadata.
126///
127/// `name` is the final path component (NAME level). Returns `None` for
128/// classes this server does not implement; callers map that to
129/// `STATUS_INVALID_PARAMETER` or `NOT_SUPPORTED`.
130pub fn encode_file_info(class: u8, m: &QueryMeta, name: &str) -> Option<Vec<u8>> {
131    let mut d = Writer::new(0);
132    match class {
133        file_class::BASIC => {
134            push_times(&mut d, &m.times);
135            d.raw(&m.attrs.to_le_bytes());
136            d.push_u32(0); // Reserved: FileBasicInformation is 40 bytes ([MS-FSCC] §2.4.7)
137        }
138        file_class::STANDARD => {
139            d.push_u64(m.alloc);
140            d.push_u64(m.eof);
141            d.push_u32(1); // NumberOfLinks
142            d.push(0); // DeletePending
143            d.push(m.is_dir as u8);
144            d.push_u16(0); // Reserved
145        }
146        file_class::INTERNAL => {
147            d.push_u64(0); // IndexNumber placeholder
148        }
149        file_class::EA => {
150            d.push_u32(0); // EaSize
151        }
152        file_class::ACCESS => {
153            d.raw(&GENERIC_RW_ACCESS.to_le_bytes());
154        }
155        file_class::NAME => push_utf16_len(&mut d, name),
156        file_class::NORMALIZED_NAME => push_utf16_len(&mut d, name),
157        file_class::MODE => {
158            d.raw(&0u32.to_le_bytes());
159        }
160        file_class::ALIGNMENT => {
161            d.raw(&0u32.to_le_bytes());
162        }
163        file_class::POSITION => {
164            d.push_u64(0); // CurrentByteOffset
165        }
166        file_class::ATTRIBUTE_TAG => {
167            d.raw(&m.attrs.to_le_bytes());
168            d.push_u32(0); // ReparseTag
169        }
170        file_class::NETWORK_OPEN => {
171            push_times(&mut d, &m.times);
172            d.push_u64(m.alloc);
173            d.push_u64(m.eof);
174            d.raw(&m.attrs.to_le_bytes());
175        }
176        file_class::ALL => {
177            // Embedded FSCC structures are 8-byte aligned within the
178            // composite ([MS-FSCC] §2.4.1):
179            // Basic(40) Standard(24) Internal(8) Ea(4) Access(4)
180            // Position(8) Mode(4) Alignment(4) FileName(variable).
181            push_times(&mut d, &m.times); // 0..32
182            d.raw(&m.attrs.to_le_bytes()); // 32..36
183            d.push_u32(0); // pad to Basic's 40-byte footprint
184            d.push_u64(m.alloc); // 40
185            d.push_u64(m.eof); // 48
186            d.push_u32(1); // links 56
187            d.push(0); // delete pending 60
188            d.push(m.is_dir as u8); // 61
189            d.push_u16(0); // reserved 62
190            d.push_u64(0); // index number 64
191            d.push_u32(0); // ea size 72
192            d.raw(&GENERIC_RW_ACCESS.to_le_bytes()); // access 76
193            d.push_u64(0); // current byte offset 80
194            d.push_u32(0); // mode 88
195            d.push_u32(0); // alignment requirement 92
196            // FileNameInformation: Windows returns FileNameLength 0 here
197            // ([MS-SMB2] §3.3.5.20.1 / MS-FSCC §2.4.7 via a handle query).
198            d.push_u32(0); // FileNameLength 96
199        }
200        _ => return None,
201    }
202    Some(d.into_inner())
203}
204
205/// Encode a FILE_STREAM_INFORMATION chain ([MS-FSCC] §2.4.40) from a list of
206/// `(stream_name, size)` pairs. Names are the full SMB stream syntax, e.g.
207/// `::$DATA` for the default data stream or `:Zone.Identifier:$DATA` for an
208/// alternate data stream. An empty list yields an empty buffer (no streams).
209pub fn encode_stream_info(streams: &[(String, u64)]) -> Vec<u8> {
210    let mut d = Writer::new(0);
211    for (i, (name, size)) in streams.iter().enumerate() {
212        let units: Vec<u16> = name.encode_utf16().collect();
213        let name_bytes = units.len() * 2;
214        // Entry = 24-byte fixed part + name, padded to an 8-byte boundary.
215        let entry_len = 24 + name_bytes;
216        let padded = entry_len.next_multiple_of(8);
217        let next = if i + 1 == streams.len() { 0 } else { padded };
218        d.push_u32(next as u32); // NextEntryOffset
219        d.push_u32(name_bytes as u32); // StreamNameLength
220        d.push_u64(*size); // StreamSize
221        d.push_u64(*size); // StreamAllocationSize
222        for u in units {
223            d.push_u16(u);
224        }
225        d.raw(&vec![0u8; padded - entry_len]);
226    }
227    d.into_inner()
228}
229
230/// Encode a QUERY_INFO filesystem payload for `class`.
231pub fn encode_fs_info(class: u8) -> Option<Vec<u8>> {
232    let mut d = Writer::new(0);
233    match class {
234        fs_class::VOLUME => {
235            d.push_u64(smb_server_proto::types::FileTime::now().0); // CreationTime
236            d.push_u64(0x2023_0405_0607_0809); // VolumeSerialNumber
237            push_utf16_len(&mut d, "RUSTSMB"); // VolumeLabelLength + label
238        }
239        fs_class::SIZE | fs_class::FULL_SIZE => {
240            d.push_u64(34_464); // TotalAllocationUnits
241            if class == fs_class::FULL_SIZE {
242                d.push_u64(17_232); // AvailableAllocationUnits (caller)
243            }
244            d.push_u64(17_232); // AvailableAllocationUnits
245            d.push_u32(50); // SectorsPerAllocationUnit
246            d.push_u32(1000); // BytesPerSector
247        }
248        fs_class::DEVICE => {
249            d.push_u32(7); // DeviceType: FILE_DEVICE_DISK
250            d.push_u32(0x0002_0020); // Characteristics: mounted+removable-off
251        }
252        fs_class::ATTRIBUTE => {
253            d.push_u32(0x0007_0007); // FileSystemAttributes:
254                                     // case-preserved|case-sensitive|persistent-acls|unicode
255            d.push_u32(255); // MaximumComponentNameLength
256            push_utf16_len(&mut d, "NTFS");
257        }
258        _ => return None,
259    }
260    Some(d.into_inner())
261}
262
263// ---------------- SET_INFO decoding ----------------
264
265/// Marker error for [`decode_set_file_op`]: the class/buffer was malformed or
266/// unsupported. Carries no detail -- callers uniformly map it to
267/// `STATUS_INVALID_PARAMETER`.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct DecodeSetInfoError;
270
271/// Decode SET_INFO `buffer` into a neutral [`smb_server_vfs::SetOp`] where the
272/// operation maps onto one; `Ok(None)` means accepted-but-ignored (e.g.
273/// position), and errors mean an unsupported/malformed class.
274pub fn decode_set_file_op(class: u8, buf: &[u8]) -> Result<Option<smb_server_vfs::SetOp>, DecodeSetInfoError> {
275    let r64 = |o: usize| -> u64 {
276        buf.get(o..o + 8)
277            .map(|s| u64::from_le_bytes(s.try_into().unwrap()))
278            .unwrap_or(0)
279    };
280    match class {
281        file_class::BASIC => {
282            // FileBasicInformation ([MS-FSCC] §2.4.7): CreationTime(0),
283            // LastAccessTime(8), LastWriteTime(16), ChangeTime(24).
284            let ft = |v: u64| (v != 0 && v != u64::MAX).then_some(smb_server_proto::types::FileTime(v));
285            Ok(Some(smb_server_vfs::SetOp::Basic {
286                access: ft(r64(8)),
287                write: ft(r64(16)),
288            }))
289        }
290        file_class::END_OF_FILE => Ok(Some(smb_server_vfs::SetOp::EndOfFile(r64(0)))),
291        file_class::ALLOCATION => Ok(Some(smb_server_vfs::SetOp::Allocation(r64(0)))),
292        file_class::DISPOSITION => Ok(Some(smb_server_vfs::SetOp::Disposition {
293            delete: buf.first().copied().unwrap_or(0) != 0,
294        })),
295        file_class::RENAME => {
296            // ReplaceIfExists(1)+Reserved(3) RootDirectory(8) NameLen(4) then
297            // UTF-16LE name ([MS-FSCC] §2.4.34.1).
298            if buf.len() < 20 {
299                return Err(DecodeSetInfoError);
300            }
301            let replace = buf.first().copied().unwrap_or(0) != 0;
302            let len =
303                u32::from_le_bytes(buf[16..20].try_into().map_err(|_| DecodeSetInfoError)?) as usize;
304            let raw = buf.get(20..(20 + len).min(buf.len())).ok_or(DecodeSetInfoError)?;
305            let units: Vec<u16> = raw
306                .chunks_exact(2)
307                .map(|c| c[0] as u16 | ((c[1] as u16) << 8))
308                .collect();
309            let name = String::from_utf16_lossy(&units).replace('/', "\\");
310            Ok(Some(smb_server_vfs::SetOp::Rename { replace_if_exists: replace, name }))
311        }
312        file_class::POSITION => Ok(None), // advisory only
313        file_class::FULL_EA => {
314            // FileFullEaInformation ([MS-FSCC] §2.4.15): NextEntryOffset(4)
315            // Flags(1) EaNameLength(1) EaValueLength(2) then the ASCII name
316            // (null-terminated) and the value bytes.
317            if buf.len() < 8 {
318                return Err(DecodeSetInfoError);
319            }
320            let name_len = buf[5] as usize;
321            let val_len = u16::from_le_bytes([buf[6], buf[7]]) as usize;
322            let name_start = 8;
323            let val_start = name_start + name_len + 1; // skip the null terminator
324            let val_end = val_start + val_len;
325            if buf.len() < val_end {
326                return Err(DecodeSetInfoError);
327            }
328            let name = String::from_utf8_lossy(&buf[name_start..name_start + name_len]).into_owned();
329            let value = buf[val_start..val_end].to_vec();
330            Ok(Some(smb_server_vfs::SetOp::Ea { name, value }))
331        }
332        _ => Err(DecodeSetInfoError),
333    }
334}
335
336// ---------------- Directory enumeration encoding ----------------
337
338/// One directory entry to encode.
339#[derive(Debug, Clone)]
340pub struct FindEntry {
341    /// Final path component.
342    pub name: String,
343    /// Metadata snapshot.
344    pub meta: QueryMeta,
345}
346
347/// Write the fixed fields of one entry after the NextEntryOffset slot.
348fn push_find_fixed(buf: &mut Vec<u8>, class: u8, m: &QueryMeta, name: &str, name_len: usize) {
349    if class == find_class::NAMES {
350        // FILE_NAMES_INFORMATION: Next(4) FileIndex(4) NameLen(4).
351        buf.extend_from_slice(&0u32.to_le_bytes()); // FileIndex
352        buf.extend_from_slice(&(name_len as u32).to_le_bytes());
353        return;
354    }
355    buf.extend_from_slice(&0u32.to_le_bytes()); // FileIndex
356    for t in &m.times {
357        buf.extend_from_slice(&t.to_le_bytes());
358    }
359    buf.extend_from_slice(&m.eof.to_le_bytes());
360    buf.extend_from_slice(&m.alloc.to_le_bytes());
361    buf.extend_from_slice(&m.attrs.to_le_bytes());
362    buf.extend_from_slice(&(name_len as u32).to_le_bytes());
363    match class {
364        file_class::FULL_DIRECTORY => {
365            buf.extend_from_slice(&0u32.to_le_bytes()); // EaSize
366        }
367        file_class::BOTH_DIRECTORY => {
368            buf.extend_from_slice(&0u32.to_le_bytes()); // EaSize
369            push_short_name(buf, name);
370        }
371        find_class::ID_FULL_DIRECTORY => {
372            buf.extend_from_slice(&0u32.to_le_bytes()); // Reserved
373            buf.extend_from_slice(&0u64.to_le_bytes()); // FileId
374        }
375        find_class::ID_BOTH_DIRECTORY => {
376            buf.extend_from_slice(&0u32.to_le_bytes()); // EaSize
377            push_short_name(buf, name);
378            buf.extend_from_slice(&0u16.to_le_bytes()); // Reserved2
379            buf.extend_from_slice(&0u64.to_le_bytes()); // FileId
380        }
381        _ => {}
382    }
383}
384
385/// Encode the 8.3 ShortName field (ShortNameLength(1) Reserved(1) ShortName(24))
386/// of a BOTH_DIRECTORY entry, generating a short name for non-8.3 long names.
387fn push_short_name(buf: &mut Vec<u8>, name: &str) {
388    let short = short_name_8_3(name);
389    let units: Vec<u16> = short.encode_utf16().collect();
390    buf.push((units.len() * 2) as u8); // ShortNameLength (bytes)
391    buf.push(0); // Reserved
392    let mut field = [0u8; 24];
393    for (i, u) in units.iter().take(12).enumerate() {
394        field[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes());
395    }
396    buf.extend_from_slice(&field);
397}
398
399/// Characters permitted (besides A-Z 0-9) in a DOS 8.3 name.
400const SHORT_NAME_SYMBOLS: &str = "!#$%&'()-@^_`{}~";
401
402/// Generate a DOS 8.3 short name for a long name ([MS-FSCC] §2.1.5.2.1 style):
403/// uppercased, invalid characters dropped, base truncated to 6 plus `~1`, and a
404/// 3-character extension. Returns `""` when `name` is already a valid 8.3 name
405/// (Windows then reports no short name).
406pub fn short_name_8_3(name: &str) -> String {
407    if is_valid_8_3(name) {
408        return String::new();
409    }
410    let (base, ext) = match name.rsplit_once('.') {
411        Some((b, e)) if !b.is_empty() => (b, e),
412        _ => (name, ""),
413    };
414    let clean = |s: &str| -> String {
415        s.chars()
416            .filter(|c| c.is_ascii_alphanumeric() || SHORT_NAME_SYMBOLS.contains(*c))
417            .map(|c| c.to_ascii_uppercase())
418            .collect()
419    };
420    let b: String = clean(base).chars().take(6).collect();
421    let e: String = clean(ext).chars().take(3).collect();
422    let b = if b.is_empty() { "_".to_string() } else { b };
423    if e.is_empty() {
424        format!("{b}~1")
425    } else {
426        format!("{b}~1.{e}")
427    }
428}
429
430/// Whether `name` already fits the 8.3 form (case-insensitively), in which case
431/// no short name is generated for it.
432fn is_valid_8_3(name: &str) -> bool {
433    let (base, ext) = match name.rsplit_once('.') {
434        Some((b, e)) => (b, e),
435        None => (name, ""),
436    };
437    let ok = |s: &str, max: usize| -> bool {
438        s.len() <= max
439            && s.chars()
440                .all(|c| c.is_ascii_alphanumeric() || SHORT_NAME_SYMBOLS.contains(c))
441    };
442    !base.is_empty() && ok(base, 8) && ok(ext, 3)
443}
444
445/// Encode directory entries into the NextEntryOffset-chained form used by
446/// QUERY_DIRECTORY responses. The final entry terminates with offset 0 and
447/// intermediate strides are 8-byte aligned ([MS-FSCC] §2.4 requires 8-byte
448/// alignment for SMB2 clients).
449pub fn encode_find_entries(entries: &[FindEntry], class: u8) -> Vec<u8> {
450    let mut buf: Vec<u8> = Vec::new();
451    for (i, e) in entries.iter().enumerate() {
452        let start = buf.len();
453        let mut name_bytes = Vec::with_capacity(e.name.len() * 2);
454        for u in e.name.encode_utf16() {
455            name_bytes.extend_from_slice(&u.to_le_bytes());
456        }
457
458        let next_pos = buf.len();
459        buf.extend_from_slice(&0u32.to_le_bytes()); // NextEntryOffset slot
460
461        push_find_fixed(&mut buf, class, &e.meta, &e.name, name_bytes.len());
462        buf.extend_from_slice(&name_bytes);
463
464        let stride = buf.len() - start;
465        if i + 1 < entries.len() {
466            let aligned = (stride + 7) & !7usize;
467            while buf.len() < start + aligned {
468                buf.push(0);
469            }
470            let next = (buf.len() - start) as u32;
471            buf[next_pos..next_pos + 4].copy_from_slice(&next.to_le_bytes());
472        }
473    }
474    buf
475}
476
477#[cfg(test)]
478mod stream_info_tests {
479    use super::*;
480
481    #[test]
482    fn encodes_default_plus_ads_chain() {
483        let streams = vec![
484            ("::$DATA".to_string(), 9u64),
485            (":Zone.Identifier:$DATA".to_string(), 26u64),
486        ];
487        let buf = encode_stream_info(&streams);
488        // First entry: NextEntryOffset != 0, name "::$DATA" (7 UTF-16 units).
489        let next0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
490        assert_ne!(next0, 0, "first entry links to the second");
491        assert_eq!(u32::from_le_bytes(buf[4..8].try_into().unwrap()), 14, "::$DATA name bytes");
492        assert_eq!(u64::from_le_bytes(buf[8..16].try_into().unwrap()), 9, "default stream size");
493        assert_eq!(next0 % 8, 0, "entries are 8-byte aligned");
494        // Last entry's NextEntryOffset is zero.
495        let last = next0 as usize;
496        assert_eq!(u32::from_le_bytes(buf[last..last + 4].try_into().unwrap()), 0, "chain terminates");
497    }
498
499    #[test]
500    fn empty_list_is_empty_buffer() {
501        assert!(encode_stream_info(&[]).is_empty());
502    }
503}
504
505#[cfg(test)]
506mod short_name_tests {
507    use super::*;
508
509    #[test]
510    fn generates_8_3_for_long_names() {
511        assert_eq!(short_name_8_3("LongFileName.txtx"), "LONGFI~1.TXT");
512        assert_eq!(short_name_8_3("noext_longname"), "NOEXT_~1");
513        // Already a valid 8.3 name yields no short name.
514        assert_eq!(short_name_8_3("READ.ME"), "");
515        assert_eq!(short_name_8_3("A.B"), "");
516    }
517}