Skip to main content

smb_server_backend_posix/
lib.rs

1//! Default storage backend mapping the [`Vfs`] interface onto POSIX file
2//! semantics.
3//!
4//! The data path (open / read / write / fsync) and mutating directory ops
5//! (mkdir / rmdir / rename / unlink) run on **io_uring** via `tokio_uring::fs`
6//! — non-blocking, zero-copy, completion-based. Read-only metadata and
7//! directory *enumeration* use `std::fs` because Linux has no async / io_uring
8//! `getdents` op; those are page-cache-backed syscalls that complete in
9//! microseconds.
10//!
11//! All client paths are resolved inside the configured share root; `..`
12//! traversal and absolute components are refused and existing entries are
13//! matched case-insensitively to emulate Windows semantics.
14
15#![forbid(unsafe_code)]
16#![deny(missing_docs)]
17#![warn(missing_debug_implementations)]
18
19use async_trait::async_trait;
20use smb_server_proto::types::{AttrFlags, Disposition, FileTime};
21use smb_server_vfs::{Entry, FileMeta, OpenFile, SetOp, Vfs, VfsError, VfsResult, map_io};
22use tokio_uring::fs::{File, OpenOptions};
23
24/// POSIX backend rooted at a single directory.
25#[derive(Debug)]
26pub struct PosixVfs {
27    root: std::path::PathBuf,
28    /// In-memory NT-ACL fallback for filesystems without user xattr support,
29    /// keyed by resolved absolute path. xattr storage is attempted first.
30    sd_cache: std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, Vec<u8>>>,
31}
32
33/// Backend-private state attached to every open handle.
34///
35/// Holds an io_uring [`File`], which is `!Send`; the handle therefore lives
36/// entirely on its connection's runtime and is never sent across threads.
37pub struct PosixInner {
38    /// Open io_uring file (None for directory and alternate-data-stream handles).
39    pub file: Option<File>,
40    /// Tracked cursor for SEEK support.
41    pub pos: u64,
42    /// Alternate data stream name when this handle targets an ADS (stored in an
43    /// extended attribute on the base file), else `None` for the data stream.
44    pub stream: Option<String>,
45}
46
47impl std::fmt::Debug for PosixInner {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("PosixInner")
50            .field("open", &self.file.is_some())
51            .field("pos", &self.pos)
52            .field("stream", &self.stream)
53            .finish()
54    }
55}
56
57impl PosixVfs {
58    /// Create a backend serving `root`.
59    pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
60        let root = root.into();
61        // Canonicalize to an absolute root so paths stored on open handles are
62        // absolute and re-resolve cleanly. With a relative root (e.g.
63        // `./share`) the stored `./share/foo` would otherwise be treated as
64        // share-relative on stat/set_info and double-prefix the root.
65        let root = std::fs::canonicalize(&root).unwrap_or(root);
66        PosixVfs {
67            root,
68            sd_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
69        }
70    }
71
72    fn resolve(&self, path: &str) -> std::path::PathBuf {
73        // If already absolute and under our root (stored by create/open),
74        // return as-is; otherwise treat as share-relative.
75        let abs = std::path::PathBuf::from(path);
76        if abs.is_absolute() && abs.starts_with(&self.root) {
77            return abs;
78        }
79        resolve_under(&self.root, path)
80    }
81
82    /// Open or create an alternate data stream, backed by an extended attribute
83    /// on the base file. The base file's own data stream is never truncated
84    /// here; create/overwrite dispositions apply to the stream contents only.
85    #[cfg_attr(dylint_lib = "no_magic_numbers", allow(no_magic_numbers))]
86    async fn create_stream(
87        &self,
88        base_rel: &str,
89        sname: &str,
90        access: u32,
91        disposition: u32,
92        options: u32,
93    ) -> VfsResult<(Box<OpenFile>, FileMeta, u32)> {
94        const OPT_DELETE_ON_CLOSE: u32 = 0x1000;
95        let path = self.resolve(base_rel);
96
97        // Ensure the base file exists (streams cannot exist without it), never
98        // truncating its data stream.
99        let base_is_file = std::fs::symlink_metadata(&path)
100            .map(|m| !m.is_dir())
101            .unwrap_or(false);
102        if !base_is_file {
103            match Disposition::from_u32(disposition) {
104                Some(Disposition::Open) | Some(Disposition::Overwrite) => {
105                    return Err(VfsError::NotFound);
106                }
107                _ => {
108                    if let Some(parent) = path.parent() {
109                        let _ = tokio_uring::fs::create_dir_all(parent).await;
110                    }
111                    let f = OpenOptions::new()
112                        .write(true)
113                        .create(true)
114                        .open(&path)
115                        .await
116                        .map_err(map_io)?;
117                    let _ = f.close().await;
118                }
119            }
120        }
121
122        let xname = stream_xattr(sname);
123        let stream_exists = xattr::get(&path, &xname).ok().flatten().is_some();
124        let action = if stream_exists {
125            match Disposition::from_u32(disposition) {
126                Some(Disposition::Supersede) => {
127                    xattr::set(&path, &xname, b"").map_err(map_io)?;
128                    0
129                }
130                Some(Disposition::Overwrite) | Some(Disposition::OverwriteIf) => {
131                    xattr::set(&path, &xname, b"").map_err(map_io)?;
132                    3
133                }
134                Some(Disposition::Create) => return Err(VfsError::AlreadyExists),
135                _ => 1,
136            }
137        } else {
138            match Disposition::from_u32(disposition) {
139                Some(Disposition::Open) | Some(Disposition::Overwrite) => {
140                    return Err(VfsError::NotFound);
141                }
142                _ => {
143                    xattr::set(&path, &xname, b"").map_err(map_io)?;
144                    2
145                }
146            }
147        };
148
149        let size = xattr::get(&path, &xname)
150            .ok()
151            .flatten()
152            .map(|v| v.len() as u64)
153            .unwrap_or(0);
154        let (read_access, write_access) = access_flags(access);
155        let md = std::fs::metadata(&path).map_err(map_io)?;
156        let mut m = meta_of(&md);
157        m.eof = size;
158        m.alloc = size;
159        let open = Box::new(OpenFile {
160            path: path.to_string_lossy().into_owned(),
161            rel: base_rel.to_string(),
162            is_dir: false,
163            can_read: read_access,
164            can_write: write_access,
165            delete_on_close: options & OPT_DELETE_ON_CLOSE != 0,
166            delete_pending: false,
167            inner: Box::new(PosixInner {
168                file: None,
169                pos: 0,
170                stream: Some(sname.to_string()),
171            }),
172        });
173        Ok((open, m, action))
174    }
175}
176
177/// Split an SMB path into `(base, Some(stream))` when it names an alternate
178/// data stream (`file:stream[:$DATA]`); the default data stream (`file` or
179/// `file::$DATA`) yields `(base, None)`. Only a colon in the final path
180/// component is considered.
181fn split_stream(rel: &str) -> (String, Option<String>) {
182    let (dir, last) = match rel.rfind(['\\', '/']) {
183        Some(i) => (&rel[..=i], &rel[i + 1..]),
184        None => ("", rel),
185    };
186    match last.find(':') {
187        None => (rel.to_string(), None),
188        Some(ci) => {
189            let base = &last[..ci];
190            let sname = last[ci + 1..].split(':').next().unwrap_or("");
191            let base_full = format!("{dir}{base}");
192            // An empty name or the bare "$DATA" type keyword denotes the
193            // file's default (unnamed) data stream, not an alternate one.
194            if sname.is_empty() || sname.eq_ignore_ascii_case("$DATA") {
195                (base_full, None)
196            } else {
197                (base_full, Some(sname.to_string()))
198            }
199        }
200    }
201}
202
203/// Extended-attribute name backing an alternate data stream.
204fn stream_xattr(name: &str) -> String {
205    format!("{STREAM_XATTR_PREFIX}{name}")
206}
207
208/// Decode an NT desired-access mask into `(read, write)` capability flags.
209#[cfg_attr(dylint_lib = "no_magic_numbers", allow(no_magic_numbers))] // Windows ACCESS_MASK bits
210fn access_flags(access: u32) -> (bool, bool) {
211    let read = access & (0x8000_0000 | 0x0000_0001 | 0x0000_0008 | 0x0000_0080 | 0x1000_0000) != 0;
212    let write = access
213        & (0x4000_0000 | 0x0000_0002 | 0x0000_0004 | 0x0001_0000 | 0x2000_0000 | 0x1000_0000)
214        != 0;
215    (read, write)
216}
217
218/// If any component of `rel` under `root` is a symbolic link, return the symlink
219/// target, the UTF-16 byte length of the request path following the symlink, and
220/// whether the target is relative — the data for a Symbolic Link Error Response
221/// ([MS-SMB2] §2.2.2.2.1). A create traversing a symlink must stop here.
222fn symlink_stop(root: &std::path::Path, rel: &str) -> Option<(String, u16, bool)> {
223    let bytes = rel.as_bytes();
224    let mut cur = root.to_path_buf();
225    let mut i = 0;
226    while i < rel.len() {
227        while i < rel.len() && (bytes[i] == b'\\' || bytes[i] == b'/') {
228            i += 1;
229        }
230        let start = i;
231        while i < rel.len() && bytes[i] != b'\\' && bytes[i] != b'/' {
232            i += 1;
233        }
234        let comp = &rel[start..i];
235        if comp.is_empty() || comp == "." || comp == ".." || comp.contains(':') {
236            continue;
237        }
238        cur.push(comp);
239        let Ok(m) = std::fs::symlink_metadata(&cur) else {
240            continue;
241        };
242        if !m.file_type().is_symlink() {
243            continue;
244        }
245        let target = std::fs::read_link(&cur)
246            .map(|p| p.to_string_lossy().into_owned())
247            .unwrap_or_default();
248        // The unparsed portion is the request path after the symlink component.
249        let unparsed_len = (rel[i..].encode_utf16().count() * size_of::<u16>()) as u16;
250        let relative = !std::path::Path::new(&target).is_absolute();
251        return Some((target, unparsed_len, relative));
252    }
253    None
254}
255
256fn resolve_under(root: &std::path::Path, rel: &str) -> std::path::PathBuf {
257    let mut cur = root.to_path_buf();
258    for comp in rel.split(['\\', '/']) {
259        if comp.is_empty() || comp == "." || comp == ".." || comp.contains(':') {
260            continue;
261        }
262        cur.push(comp);
263    }
264    // Case-insensitive fix-up of components that already exist.
265    let mut fixed = root.to_path_buf();
266    let suffix = cur.strip_prefix(root).unwrap_or(std::path::Path::new(""));
267    for comp in suffix.components() {
268        let candidate = fixed.join(comp.as_os_str());
269        if candidate.symlink_metadata().is_ok() {
270            fixed.push(comp.as_os_str());
271            continue;
272        }
273        match find_case_insensitive(&fixed, &comp.as_os_str().to_string_lossy()) {
274            Some(actual) => fixed.push(actual),
275            None => fixed.push(comp.as_os_str()),
276        }
277    }
278    fixed
279}
280
281fn find_case_insensitive(dir: &std::path::Path, name: &str) -> Option<String> {
282    let rd = std::fs::read_dir(dir).ok()?;
283    let lower = name.to_lowercase();
284    rd.flatten()
285        .find(|e| e.file_name().to_string_lossy().to_lowercase() == lower)
286        .map(|e| e.file_name().to_string_lossy().into_owned())
287}
288
289#[cfg_attr(dylint_lib = "no_magic_numbers", allow(no_magic_numbers))] // POSIX mode/stat + block-size math
290fn meta_of(md: &std::fs::Metadata) -> FileMeta {
291    use std::os::unix::fs::MetadataExt;
292    let attrs = AttrFlags::new(if md.is_dir() {
293        AttrFlags::DIRECTORY
294    } else if md.mode() & 0o222 == 0 {
295        AttrFlags::ARCHIVE | AttrFlags::READONLY
296    } else {
297        AttrFlags::ARCHIVE
298    });
299    let eof = if md.is_dir() { 0 } else { md.len() };
300    FileMeta {
301        times: [
302            FileTime::from_unix(md.ctime(), md.ctime_nsec() as u32),
303            FileTime::from_unix(md.atime(), md.atime_nsec() as u32),
304            FileTime::from_unix(md.mtime(), md.mtime_nsec() as u32),
305            FileTime::from_unix(md.ctime(), md.ctime_nsec() as u32),
306        ],
307        alloc: eof.div_ceil(4096) * 4096,
308        eof,
309        attrs,
310        is_dir: md.is_dir(),
311    }
312}
313
314fn set_file_times(path: &std::path::Path, atime: Option<u64>, mtime: Option<u64>) -> VfsResult<()> {
315    use std::fs::FileTimes;
316    let f = std::fs::OpenOptions::new()
317        .write(true)
318        .open(path)
319        .map_err(map_io)?;
320    let to_sys = |ft: u64| {
321        let (secs, nanos) = FileTime(ft).to_unix();
322        std::time::SystemTime::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos)
323    };
324    let mut times = FileTimes::new();
325    if let Some(a) = atime {
326        times = times.set_accessed(to_sys(a));
327    }
328    if let Some(m) = mtime {
329        times = times.set_modified(to_sys(m));
330    }
331    f.set_times(times).map_err(map_io)?;
332    Ok(())
333}
334
335/// DOS wildcard match supporting `*` and `?` (case-insensitive callers).
336pub fn wildcard_match(name: &str, pattern: &str) -> bool {
337    fn rec(s: &[char], p: &[char]) -> bool {
338        if p.is_empty() {
339            return s.is_empty();
340        }
341        match p[0] {
342            '*' => (0..=s.len()).any(|i| rec(&s[i..], &p[1..])),
343            '?' => !s.is_empty() && rec(&s[1..], &p[1..]),
344            c => !s.is_empty() && s[0] == c && rec(&s[1..], &p[1..]),
345        }
346    }
347    rec(
348        &name.chars().collect::<Vec<_>>(),
349        &pattern.chars().collect::<Vec<_>>(),
350    )
351}
352
353#[async_trait(?Send)]
354impl Vfs for PosixVfs {
355    #[cfg_attr(dylint_lib = "no_magic_numbers", allow(no_magic_numbers))] // SMB CreateAction/disposition codes
356    async fn create(
357        &self,
358        rel: &str,
359        is_dir: bool,
360        access: u32,
361        disposition: u32,
362        options: u32,
363        _attrs: u32,
364    ) -> VfsResult<(Box<OpenFile>, FileMeta, u32)> {
365        const OPT_DIRECTORY_FILE: u32 = 0x1;
366        const OPT_DELETE_ON_CLOSE: u32 = 0x1000;
367
368        // Alternate data streams (`file:stream:$DATA`) live in extended
369        // attributes on the base file; route them to a separate open path.
370        let (base_rel, stream) = split_stream(rel);
371        if let Some(sname) = stream {
372            return self
373                .create_stream(&base_rel, &sname, access, disposition, options)
374                .await;
375        }
376        let rel = base_rel.as_str();
377        let path = self.resolve(rel);
378        // A create that traverses a symbolic link is rejected with
379        // STATUS_STOPPED_ON_SYMLINK unless FILE_OPEN_REPARSE_POINT is set
380        // ([MS-SMB2] §3.3.5.9).
381        const OPT_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
382        if options & OPT_OPEN_REPARSE_POINT == 0
383            && let Some((target, unparsed_len, relative)) = symlink_stop(&self.root, rel) {
384                return Err(VfsError::StoppedOnSymlink {
385                    target,
386                    unparsed_len,
387                    relative,
388                });
389            }
390        let want_dir = is_dir || options & OPT_DIRECTORY_FILE != 0;
391        let existing = std::fs::symlink_metadata(&path).ok();
392        let exists = existing.is_some();
393        let existing_is_dir = existing.as_ref().map(|m| m.is_dir()).unwrap_or(false);
394
395        // Directory handle: no io_uring file object, create the directory tree
396        // if needed ([MS-SMB2] FILE_DIRECTORY_FILE).
397        if want_dir || existing_is_dir {
398            let action = if exists {
399                1
400            } else {
401                match Disposition::from_u32(disposition) {
402                    Some(Disposition::Open) | Some(Disposition::Overwrite) => {
403                        return Err(VfsError::NotFound);
404                    }
405                    _ => {}
406                }
407                tokio_uring::fs::create_dir_all(&path)
408                    .await
409                    .map_err(map_io)?;
410                2
411            };
412            let md = std::fs::metadata(&path).map_err(map_io)?;
413            let (read_access, write_access) = access_flags(access);
414            let open = Box::new(OpenFile {
415                path: path.to_string_lossy().into_owned(),
416                rel: rel.to_string(),
417                is_dir: true,
418                can_read: read_access,
419                can_write: write_access,
420                delete_on_close: options & OPT_DELETE_ON_CLOSE != 0,
421                delete_pending: false,
422                inner: Box::new(PosixInner {
423                    file: None,
424                    pos: 0,
425                    stream: None,
426                }),
427            });
428            return Ok((open, meta_of(&md), action));
429        }
430
431        // Regular file: derive create/truncate semantics from the disposition.
432        let (action, truncate, create) = if exists {
433            match Disposition::from_u32(disposition) {
434                Some(Disposition::Supersede) => (0u32, true, false),
435                Some(Disposition::Overwrite) | Some(Disposition::OverwriteIf) => (3, true, false),
436                _ => (1, false, false),
437            }
438        } else {
439            match Disposition::from_u32(disposition) {
440                Some(Disposition::Open) | Some(Disposition::Overwrite) => {
441                    return Err(VfsError::NotFound);
442                }
443                _ => {}
444            }
445            if let Some(parent) = path.parent() {
446                let _ = tokio_uring::fs::create_dir_all(parent).await;
447            }
448            (2, false, true)
449        };
450
451        let (read_access, write_access) = access_flags(access);
452
453        let mut oo = OpenOptions::new();
454        oo.read(read_access || !write_access);
455        oo.write(write_access || truncate || create);
456        if create {
457            oo.create(true);
458        }
459        if truncate {
460            oo.truncate(true);
461        }
462        let file = oo.open(&path).await.map_err(map_io)?;
463        let md = std::fs::metadata(&path).map_err(map_io)?;
464
465        let open = Box::new(OpenFile {
466            path: path.to_string_lossy().into_owned(),
467            rel: rel.to_string(),
468            is_dir: false,
469            can_read: read_access,
470            can_write: write_access,
471            delete_on_close: options & OPT_DELETE_ON_CLOSE != 0,
472            delete_pending: false,
473            inner: Box::new(PosixInner {
474                file: Some(file),
475                pos: 0,
476                stream: None,
477            }),
478        });
479        Ok((open, meta_of(&md), action))
480    }
481
482    async fn read(&self, open: &mut OpenFile, offset: u64, len: usize) -> VfsResult<Vec<u8>> {
483        let inner = open
484            .inner_as_mut::<PosixInner>()
485            .ok_or(VfsError::NotSupported)?;
486        if let Some(sname) = inner.stream.clone() {
487            let blob = xattr::get(self.resolve(&open.path), stream_xattr(&sname))
488                .ok()
489                .flatten()
490                .unwrap_or_default();
491            let start = (offset as usize).min(blob.len());
492            let end = (start + len).min(blob.len());
493            return Ok(blob[start..end].to_vec());
494        }
495        let f = inner.file.as_ref().ok_or(VfsError::AccessDenied)?;
496        // io_uring positional read; the buffer round-trips through the kernel.
497        let (res, mut buf) = f.read_at(vec![0u8; len], offset).await;
498        let n = res.map_err(map_io)?;
499        buf.truncate(n);
500        Ok(buf)
501    }
502
503    async fn write(
504        &self,
505        open: &mut OpenFile,
506        offset: u64,
507        data: &[u8],
508        write_through: bool,
509    ) -> VfsResult<u64> {
510        let inner = open
511            .inner_as_mut::<PosixInner>()
512            .ok_or(VfsError::NotSupported)?;
513        if let Some(sname) = inner.stream.clone() {
514            // Whole-value xattr: read-modify-write the stream blob at `offset`.
515            let p = self.resolve(&open.path);
516            let xname = stream_xattr(&sname);
517            let mut blob = xattr::get(&p, &xname).ok().flatten().unwrap_or_default();
518            let start = offset as usize;
519            if blob.len() < start + data.len() {
520                blob.resize(start + data.len(), 0);
521            }
522            blob[start..start + data.len()].copy_from_slice(data);
523            xattr::set(&p, &xname, &blob).map_err(map_io)?;
524            return Ok(data.len() as u64);
525        }
526        let f = inner.file.as_ref().ok_or(VfsError::AccessDenied)?;
527        let total = data.len();
528        let (res, _buf) = f.write_all_at(data.to_vec(), offset).await;
529        res.map_err(map_io)?;
530        if write_through {
531            f.sync_all().await.map_err(map_io)?;
532        }
533        Ok(total as u64)
534    }
535
536    #[cfg_attr(dylint_lib = "no_magic_numbers", allow(no_magic_numbers))] // SMB seek-mode codes
537    async fn seek(&self, open: &mut OpenFile, mode: u16, offset: i64) -> VfsResult<u64> {
538        // Positional I/O needs no lseek; track the cursor for SMB1 SEEK.
539        let size = std::fs::metadata(&open.path).map(|m| m.len()).unwrap_or(0);
540        let inner = open
541            .inner_as_mut::<PosixInner>()
542            .ok_or(VfsError::NotSupported)?;
543        let base = match mode & 3 {
544            0 => 0i64,
545            1 => inner.pos as i64,
546            _ => size as i64,
547        };
548        let np = base.saturating_add(offset).max(0) as u64;
549        inner.pos = np;
550        Ok(np)
551    }
552
553    async fn flush(&self, open: &mut OpenFile) -> VfsResult<()> {
554        let inner = open
555            .inner_as_ref::<PosixInner>()
556            .ok_or(VfsError::NotSupported)?;
557        if let Some(f) = inner.file.as_ref() {
558            f.sync_all().await.map_err(map_io)?;
559        }
560        Ok(())
561    }
562
563    async fn flush_all(&self) -> VfsResult<()> {
564        Ok(())
565    }
566
567    async fn close(&self, mut open: Box<OpenFile>) -> VfsResult<()> {
568        // Stream handles hold no io_uring file; delete-on-close removes the
569        // backing extended attribute, never the base file.
570        if let Some(sname) = open
571            .inner_as_ref::<PosixInner>()
572            .and_then(|i| i.stream.clone())
573        {
574            if open.delete_on_close || open.delete_pending {
575                let _ = xattr::remove(self.resolve(&open.path), stream_xattr(&sname));
576            }
577            return Ok(());
578        }
579        // Close the io_uring file explicitly (async close), then honor
580        // delete-on-close.
581        if let Some(inner) = open.inner_as_mut::<PosixInner>()
582            && let Some(f) = inner.file.take() {
583                let _ = f.close().await;
584            }
585        if open.delete_on_close || open.delete_pending {
586            if open.is_dir {
587                tokio_uring::fs::remove_dir(&open.path)
588                    .await
589                    .map_err(map_io)?;
590            } else {
591                tokio_uring::fs::remove_file(&open.path)
592                    .await
593                    .map_err(map_io)?;
594            }
595        }
596        Ok(())
597    }
598
599    async fn mkdir(&self, rel: &str) -> VfsResult<()> {
600        let p = self.resolve(rel);
601        tokio_uring::fs::create_dir(&p)
602            .await
603            .map_err(|e| match e.kind() {
604                std::io::ErrorKind::AlreadyExists => VfsError::AlreadyExists,
605                std::io::ErrorKind::NotFound => VfsError::NotFound,
606                _ => VfsError::AccessDenied,
607            })
608    }
609
610    async fn rmdir(&self, rel: &str) -> VfsResult<()> {
611        let p = self.resolve(rel);
612        tokio_uring::fs::remove_dir(&p)
613            .await
614            .map_err(|e| match e.kind() {
615                std::io::ErrorKind::DirectoryNotEmpty => VfsError::DirectoryNotEmpty,
616                std::io::ErrorKind::NotFound => VfsError::NotFound,
617                _ => VfsError::AccessDenied,
618            })
619    }
620
621    async fn check_dir(&self, rel: &str) -> VfsResult<()> {
622        let p = self.resolve(rel);
623        if p.is_dir() {
624            Ok(())
625        } else {
626            Err(VfsError::NotFound)
627        }
628    }
629
630    async fn unlink(&self, rel: &str) -> VfsResult<()> {
631        let p = self.resolve(rel);
632        let md = p.symlink_metadata().map_err(|_| VfsError::NotFound)?;
633        if md.is_dir() {
634            return Err(VfsError::InvalidArgument);
635        }
636        tokio_uring::fs::remove_file(&p).await.map_err(map_io)
637    }
638
639    async fn delete_pattern(&self, dir_rel: &str, pattern: &str) -> VfsResult<bool> {
640        let dir_abs = self.resolve(dir_rel);
641        let names = std::fs::read_dir(&dir_abs).map_err(|_| VfsError::NotFound)?;
642        let mut deleted_any = false;
643        for entry in names.flatten() {
644            let n = entry.file_name().to_string_lossy().into_owned();
645            if wildcard_match(&n.to_lowercase(), &pattern.to_lowercase()) && entry.path().is_file()
646            {
647                tokio_uring::fs::remove_file(entry.path())
648                    .await
649                    .map_err(map_io)?;
650                deleted_any = true;
651            }
652        }
653        Ok(deleted_any)
654    }
655
656    async fn rename(&self, old_rel: &str, new_rel: &str) -> VfsResult<()> {
657        let src = self.resolve(old_rel);
658        let dst = self.resolve(new_rel);
659        if !src.exists() {
660            return Err(VfsError::NotFound);
661        }
662        tokio_uring::fs::rename(&src, &dst).await.map_err(map_io)
663    }
664
665    async fn list(&self, dir_rel: &str) -> VfsResult<Vec<Entry>> {
666        let dir_abs = self.resolve(dir_rel);
667        let rd = std::fs::read_dir(&dir_abs).map_err(|_| VfsError::NotFound)?;
668        let mut out = Vec::new();
669        for e in rd.flatten() {
670            let name = e.file_name().to_string_lossy().into_owned();
671            let md = e.metadata().map_err(map_io)?;
672            out.push(Entry {
673                name,
674                meta: meta_of(&md),
675            });
676        }
677        out.sort_by(|a, b| a.name.cmp(&b.name));
678        Ok(out)
679    }
680
681    async fn stat(&self, rel: &str) -> VfsResult<FileMeta> {
682        let p = self.resolve(rel);
683        let md = p.symlink_metadata().map_err(|_| VfsError::NotFound)?;
684        Ok(meta_of(&md))
685    }
686
687    async fn set_info_open(&self, open: &mut OpenFile, op: &SetOp) -> VfsResult<()> {
688        match op {
689            SetOp::Disposition { delete } => open.delete_pending = *delete,
690            SetOp::Rename { name, .. } => {
691                let target = open.path.clone();
692                self.set_info_path(&target, op).await?;
693                // The open now refers to the file at its new location; keep the
694                // handle's paths in sync so later ops (notably delete-on-close)
695                // act on the renamed file rather than the vanished old path.
696                open.path = self.resolve(name).to_string_lossy().into_owned();
697                open.rel = name.clone();
698            }
699            other => {
700                let target = open.path.clone();
701                self.set_info_path(&target, other).await?;
702            }
703        }
704        Ok(())
705    }
706
707    async fn set_info_path(&self, rel: &str, op: &SetOp) -> VfsResult<()> {
708        let p = self.resolve(rel);
709        match op {
710            SetOp::Disposition { delete } => {
711                if *delete {
712                    if p.is_dir() {
713                        tokio_uring::fs::remove_dir(&p).await.map_err(map_io)?;
714                    } else {
715                        tokio_uring::fs::remove_file(&p).await.map_err(map_io)?;
716                    }
717                }
718            }
719            SetOp::Allocation(size) => {
720                if *size == 0 {
721                    std::fs::OpenOptions::new()
722                        .write(true)
723                        .open(&p)
724                        .map_err(map_io)?
725                        .set_len(0)
726                        .map_err(map_io)?;
727                }
728            }
729            SetOp::EndOfFile(len) => {
730                std::fs::OpenOptions::new()
731                    .write(true)
732                    .open(&p)
733                    .map_err(map_io)?
734                    .set_len(*len)
735                    .map_err(map_io)?;
736            }
737            SetOp::Basic { access, write } => {
738                let valid = |ft: &FileTime| ft.0 != 0 && ft.0 != u64::MAX;
739                let a = access.filter(valid).map(|t| t.0);
740                let w = write.filter(valid).map(|t| t.0);
741                if a.is_some() || w.is_some() {
742                    set_file_times(&p, a, w)?;
743                }
744            }
745            SetOp::Rename {
746                replace_if_exists: _,
747                name,
748            } => {
749                let dest = self.resolve(name);
750                tokio_uring::fs::rename(&p, &dest).await.map_err(map_io)?;
751            }
752            SetOp::Ea { name, value } => {
753                // Persist the EA as an extended attribute; the write also
754                // raises an IN_ATTRIB inotify event for CHANGE_NOTIFY.
755                xattr::set(&p, format!("user.smbea.{name}"), value).map_err(map_io)?;
756            }
757        }
758        Ok(())
759    }
760
761    #[cfg_attr(dylint_lib = "no_magic_numbers", allow(no_magic_numbers))] // reported statfs figures
762    async fn query_disk(&self) -> VfsResult<(u32, u32, u16, u16)> {
763        // Static values keep clients happy; real statvfs wiring can come later.
764        Ok((100_000, 50_000, 512, 64))
765    }
766
767    async fn get_security(&self, rel: &str) -> VfsResult<Option<Vec<u8>>> {
768        let p = self.resolve(rel);
769        if let Ok(Some(bytes)) = xattr::get(&p, NTACL_XATTR) {
770            return Ok(Some(bytes));
771        }
772        Ok(self.sd_cache.lock().unwrap().get(&p).cloned())
773    }
774
775    async fn set_security(&self, rel: &str, descriptor: &[u8]) -> VfsResult<()> {
776        let p = self.resolve(rel);
777        // Persist to an extended attribute when the filesystem supports it;
778        // otherwise fall back to the in-memory cache so the round-trip holds.
779        if xattr::set(&p, NTACL_XATTR, descriptor).is_err() {
780            self.sd_cache.lock().unwrap().insert(p, descriptor.to_vec());
781        }
782        Ok(())
783    }
784
785    async fn list_streams(&self, rel: &str) -> VfsResult<Vec<(String, u64)>> {
786        let p = self.resolve(rel);
787        let mut out = Vec::new();
788        if let Ok(names) = xattr::list(&p) {
789            for n in names {
790                let n = n.to_string_lossy();
791                if let Some(sname) = n.strip_prefix(STREAM_XATTR_PREFIX) {
792                    let size = xattr::get(&p, n.as_ref())
793                        .ok()
794                        .flatten()
795                        .map(|v| v.len() as u64)
796                        .unwrap_or(0);
797                    out.push((format!(":{sname}:$DATA"), size));
798                }
799            }
800        }
801        Ok(out)
802    }
803}
804
805/// Extended-attribute name under which the raw NT security descriptor is kept
806/// (mirrors Samba's `security.NTACL`, but in the unprivileged `user.` namespace).
807const NTACL_XATTR: &str = "user.rustsmb.ntacl";
808
809/// Extended-attribute name prefix backing alternate data streams (mirrors
810/// Samba's `streams_xattr` module, in the unprivileged `user.` namespace).
811const STREAM_XATTR_PREFIX: &str = "user.rustsmb.stream.";
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use smb_server_vfs::Vfs;
817
818    /// A relative share root historically double-prefixed on stat-by-stored
819    /// handle path (the getattrib step of `smbclient get`), returning
820    /// STATUS_OBJECT_PATH_NOT_FOUND. Canonicalizing the root to absolute must
821    /// make the stored handle path re-resolve cleanly.
822    #[test]
823    fn stat_by_stored_path_survives_relative_root() {
824        tokio_uring::start(async {
825            let dir = format!("target/it_share_{}", std::process::id());
826            std::fs::create_dir_all(&dir).unwrap();
827            std::fs::write(format!("{dir}/hello.txt"), b"hello!").unwrap();
828
829            let vfs = PosixVfs::new(&dir);
830            // FILE_OPEN (disposition 1), GENERIC_READ access.
831            let (open, _meta, _action) = vfs
832                .create("hello.txt", false, 0x8000_0000, 1, 0, 0)
833                .await
834                .expect("open existing file");
835            // The exact call that used to fail during getattrib.
836            let meta = vfs
837                .stat(&open.path)
838                .await
839                .expect("stat by stored handle path");
840            assert_eq!(meta.eof, 6, "reported size matches file contents");
841
842            std::fs::remove_dir_all(&dir).ok();
843        });
844    }
845
846    /// An alternate data stream (`file:stream:$DATA`) is created in an xattr,
847    /// round-trips read/write, is enumerated, and leaves the base data intact.
848    #[test]
849    fn ads_stream_round_trip() {
850        tokio_uring::start(async {
851            let dir = format!("target/it_ads_{}", std::process::id());
852            std::fs::create_dir_all(&dir).unwrap();
853            std::fs::write(format!("{dir}/host.txt"), b"base-data").unwrap();
854            let vfs = PosixVfs::new(&dir);
855
856            let payload = b"[ZoneTransfer]\r\nZoneId=3\r\n";
857            // OVERWRITE_IF (5), GENERIC_WRITE.
858            let (mut s, _m, _a) = vfs
859                .create(
860                    "host.txt:Zone.Identifier:$DATA",
861                    false,
862                    0x4000_0000,
863                    5,
864                    0,
865                    0,
866                )
867                .await
868                .expect("create stream");
869            vfs.write(&mut s, 0, payload, false)
870                .await
871                .expect("write stream");
872            vfs.close(s).await.unwrap();
873
874            // FILE_OPEN (1), GENERIC_READ.
875            let (mut s2, m2, _a) = vfs
876                .create(
877                    "host.txt:Zone.Identifier:$DATA",
878                    false,
879                    0x8000_0000,
880                    1,
881                    0,
882                    0,
883                )
884                .await
885                .expect("open stream");
886            assert_eq!(m2.eof, payload.len() as u64, "stream size reported");
887            let got = vfs.read(&mut s2, 0, 4096).await.expect("read stream");
888            assert_eq!(got, payload, "stream round-trips");
889            vfs.close(s2).await.unwrap();
890
891            let streams = vfs.list_streams("host.txt").await.expect("list");
892            assert!(
893                streams
894                    .iter()
895                    .any(|(n, sz)| n == ":Zone.Identifier:$DATA" && *sz == payload.len() as u64),
896                "ADS enumerated: {streams:?}"
897            );
898            assert_eq!(
899                std::fs::read(format!("{dir}/host.txt")).unwrap(),
900                b"base-data",
901                "base data stream untouched",
902            );
903            std::fs::remove_dir_all(&dir).ok();
904        });
905    }
906}