use crate::sftp::{
DirEntry, FileAttr, ReconnectingSftp, SftpError, SftpSession, SSH_FXF_CREAT, SSH_FXF_READ,
SSH_FXF_TRUNC, SSH_FXF_WRITE,
};
use crate::smb2::*;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub fn smb_pattern_match(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') && !pattern.contains('?') {
return pattern.eq_ignore_ascii_case(name);
}
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
wildcard_match(&p, &n, 0, 0)
}
fn wildcard_match(p: &[char], n: &[char], pi: usize, ni: usize) -> bool {
if pi == p.len() {
return ni == n.len();
}
if p[pi] == '*' {
for skip in 0..=n.len().saturating_sub(ni) {
if wildcard_match(p, n, pi + 1, ni + skip) {
return true;
}
}
false
} else if ni < n.len()
&& (p[pi] == '?' || p[pi].to_ascii_lowercase() == n[ni].to_ascii_lowercase())
{
wildcard_match(p, n, pi + 1, ni + 1)
} else {
false
}
}
pub fn is_apple_metadata(name: &str) -> bool {
name == ".DS_Store"
|| name == ".localized"
|| name == ".hidden"
|| name.starts_with("._")
|| name == "Icon\r"
|| name == ".Spotlight-V100"
|| name == ".Trashes"
|| name == ".fseventsd"
|| name == ".TemporaryItems"
|| name == ".com.apple.timemachine.donotpresent"
}
const CACHE_TTL_SECS: u64 = 30;
const NEG_CACHE_TTL_SECS: u64 = 60;
pub struct CachedAttr {
attr: FileAttr,
is_dir: bool,
expires: Instant,
}
pub struct AttrCache {
positive: HashMap<String, CachedAttr>,
negative: HashMap<String, Instant>,
}
impl AttrCache {
pub fn new() -> Self {
AttrCache {
positive: HashMap::new(),
negative: HashMap::new(),
}
}
pub fn get(&self, path: &str) -> Option<(&FileAttr, bool)> {
self.positive.get(path).and_then(|c| {
if c.expires > Instant::now() {
Some((&c.attr, c.is_dir))
} else {
None
}
})
}
pub fn is_negative(&self, path: &str) -> bool {
self.negative
.get(path)
.map(|exp| *exp > Instant::now())
.unwrap_or(false)
}
pub fn insert(&mut self, path: String, attr: FileAttr, is_dir: bool) {
self.negative.remove(&path);
self.positive.insert(
path,
CachedAttr {
attr,
is_dir,
expires: Instant::now() + std::time::Duration::from_secs(CACHE_TTL_SECS),
},
);
}
pub fn insert_negative(&mut self, path: String) {
let ttl = if is_apple_metadata(path.rsplit('/').next().unwrap_or("")) {
NEG_CACHE_TTL_SECS
} else {
CACHE_TTL_SECS / 2
};
self.negative
.insert(path, Instant::now() + std::time::Duration::from_secs(ttl));
}
pub fn invalidate(&mut self, path: &str) {
self.positive.remove(path);
self.negative.remove(path);
}
pub fn evict_expired(&mut self) {
let now = Instant::now();
self.positive.retain(|_, c| c.expires > now);
self.negative.retain(|_, exp| *exp > now);
}
pub fn insert_dir_entries(&mut self, parent: &str, entries: &[DirEntry]) {
for e in entries {
let child = format!("{parent}/{}", e.name);
let is_dir = e.attrs.perm & 0o40000 != 0;
self.insert(child, e.attrs.clone(), is_dir);
}
}
}
const DIR_CACHE_TTL_SECS: u64 = 15;
pub struct CachedDir {
entries: Arc<Vec<DirEntry>>,
expires: Instant,
}
pub struct DirCache {
dirs: HashMap<String, CachedDir>,
}
impl DirCache {
pub fn new() -> Self {
DirCache {
dirs: HashMap::new(),
}
}
pub fn get(&self, path: &str) -> Option<Arc<Vec<DirEntry>>> {
self.dirs.get(path).and_then(|c| {
if c.expires > Instant::now() {
Some(Arc::clone(&c.entries))
} else {
None
}
})
}
pub fn insert(&mut self, path: String, entries: Vec<DirEntry>) {
self.dirs.insert(
path,
CachedDir {
entries: Arc::new(entries),
expires: Instant::now() + Duration::from_secs(DIR_CACHE_TTL_SECS),
},
);
}
pub fn invalidate(&mut self, path: &str) {
self.dirs.remove(path);
}
pub fn evict_expired(&mut self) {
let now = Instant::now();
self.dirs.retain(|_, c| c.expires > now);
}
}
struct ReadAhead {
data: Vec<u8>,
offset: u64, }
struct OpenHandle {
sftp_handle: Option<Vec<u8>>, path: String,
is_dir: bool,
dir_entries: Option<Arc<Vec<DirEntry>>>, dir_offset: usize,
readahead: Option<ReadAhead>,
}
pub struct SmbSession {
sftp: Arc<ReconnectingSftp>,
root_path: String,
share_name: String,
session_id: u64,
tree_id: u32,
handles: HashMap<u64, OpenHandle>,
next_handle: u64,
cache: AttrCache,
dir_cache: DirCache,
auth_phase: u8,
last_create_handle: u64,
msg_count: u64,
}
impl SmbSession {
pub fn new(sftp: Arc<ReconnectingSftp>, root_path: String, share_name: String) -> Self {
SmbSession {
sftp,
root_path,
share_name,
session_id: 0x0000_0001_0000_0001,
tree_id: 1,
handles: HashMap::new(),
next_handle: 1,
cache: AttrCache::new(),
dir_cache: DirCache::new(),
auth_phase: 0,
last_create_handle: 0,
msg_count: 0,
}
}
fn resolve_fid(&self, fid: u64) -> u64 {
if fid == 0xFFFF_FFFF_FFFF_FFFF {
self.last_create_handle
} else {
fid
}
}
fn invalidate_path(&mut self, path: &str) {
self.cache.invalidate(path);
if let Some((parent, _)) = path.rsplit_once('/') {
self.dir_cache.invalidate(parent);
}
}
fn on_reconnect(&mut self) {
log::info!("Flushing caches and handles after reconnect");
for (_id, handle) in self.handles.iter_mut() {
handle.sftp_handle = None;
handle.readahead = None;
}
self.cache = AttrCache::new();
self.dir_cache = DirCache::new();
}
fn full_path(&self, rel: &str) -> String {
if rel.is_empty() || rel == "\\" || rel == "/" {
self.root_path.clone()
} else {
let normalized = rel.replace('\\', "/");
let trimmed = normalized.trim_start_matches('/');
format!("{}/{}", self.root_path, trimmed)
}
}
fn alloc_handle(&mut self) -> u64 {
let h = self.next_handle;
self.next_handle += 1;
h
}
fn stat_cached(&mut self, path: &str) -> Result<(FileAttr, bool), u32> {
if let Some((attr, is_dir)) = self.cache.get(path) {
return Ok((attr.clone(), is_dir));
}
if self.cache.is_negative(path) {
return Err(STATUS_OBJECT_NAME_NOT_FOUND);
}
let basename = path.rsplit('/').next().unwrap_or("");
if is_apple_metadata(basename) {
self.cache.insert_negative(path.to_string());
return Err(STATUS_OBJECT_NAME_NOT_FOUND);
}
match self.sftp.lstat(path) {
Ok(attr) => {
let is_dir = attr.perm & 0o40000 != 0;
self.cache.insert(path.to_string(), attr.clone(), is_dir);
Ok((attr, is_dir))
}
Err(SftpError::Status(2, _)) => {
self.cache.insert_negative(path.to_string());
Err(STATUS_OBJECT_NAME_NOT_FOUND)
}
Err(_) => Err(STATUS_ACCESS_DENIED),
}
}
pub fn handle_message(&mut self, msg: &[u8]) -> Vec<u8> {
self.msg_count += 1;
if self.msg_count % 256 == 0 {
self.cache.evict_expired();
self.dir_cache.evict_expired();
}
let hdr = match Smb2Header::parse(msg) {
Some(h) => h,
None => return Vec::new(),
};
let body = &msg[SMB2_HEADER_SIZE..];
let mut response = Vec::new();
match hdr.command {
SMB2_NEGOTIATE => self.handle_negotiate(&hdr, body, &mut response),
SMB2_SESSION_SETUP => self.handle_session_setup(&hdr, body, &mut response),
SMB2_LOGOFF => self.handle_logoff(&hdr, &mut response),
SMB2_TREE_CONNECT => self.handle_tree_connect(&hdr, body, &mut response),
SMB2_TREE_DISCONNECT => self.handle_tree_disconnect(&hdr, &mut response),
SMB2_CREATE => self.handle_create(&hdr, body, &mut response),
SMB2_CLOSE => self.handle_close(&hdr, body, &mut response),
SMB2_READ => self.handle_read(&hdr, body, &mut response),
SMB2_WRITE => self.handle_write(&hdr, body, &mut response),
SMB2_QUERY_DIRECTORY => self.handle_query_directory(&hdr, body, &mut response),
SMB2_QUERY_INFO => self.handle_query_info(&hdr, body, &mut response),
SMB2_SET_INFO => self.handle_set_info(&hdr, body, &mut response),
SMB2_FLUSH => self.handle_flush(&hdr, &mut response),
SMB2_IOCTL => self.handle_ioctl(&hdr, &mut response),
_ => {
log::warn!("Unsupported SMB2 command: 0x{:04x}", hdr.command);
self.error_response(&hdr, STATUS_NOT_SUPPORTED, &mut response);
}
}
response
}
fn error_response(&self, hdr: &Smb2Header, status: u32, out: &mut Vec<u8>) {
let mut body = Vec::with_capacity(9);
body.extend_from_slice(&9u16.to_le_bytes()); body.push(0); body.push(0); body.extend_from_slice(&0u32.to_le_bytes()); body.push(0); hdr.write_response(status, &body, out);
}
fn handle_negotiate(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
let dialect_count = if body.len() >= 4 {
read_u16_le(body, 2) as usize
} else {
0
};
let _ = dialect_count;
let best_dialect = SMB2_DIALECT_202;
log::info!("Negotiated dialect: 0x{:04x}", best_dialect);
let spnego = build_spnego_negotiate_token();
let mut resp = Vec::with_capacity(128 + spnego.len());
resp.extend_from_slice(&65u16.to_le_bytes()); resp.extend_from_slice(&1u16.to_le_bytes()); resp.extend_from_slice(&best_dialect.to_le_bytes()); resp.extend_from_slice(&0u16.to_le_bytes());
resp.extend_from_slice(&[
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10,
]);
resp.extend_from_slice(&7u32.to_le_bytes()); resp.extend_from_slice(&(8 * 1024 * 1024u32).to_le_bytes()); resp.extend_from_slice(&(8 * 1024 * 1024u32).to_le_bytes()); resp.extend_from_slice(&(8 * 1024 * 1024u32).to_le_bytes());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
resp.extend_from_slice(&unix_to_filetime(now).to_le_bytes()); resp.extend_from_slice(&unix_to_filetime(now).to_le_bytes());
resp.extend_from_slice(&128u16.to_le_bytes()); resp.extend_from_slice(&(spnego.len() as u16).to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&spnego);
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_session_setup(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
self.auth_phase += 1;
log::info!("SESSION_SETUP phase {}", self.auth_phase);
let sec_offset = if body.len() >= 14 {
read_u16_le(body, 12) as usize
} else {
0
};
let sec_length = if body.len() >= 16 {
read_u16_le(body, 14) as usize
} else {
0
};
let sec_start = sec_offset.saturating_sub(SMB2_HEADER_SIZE);
let sec_data = if sec_start + sec_length <= body.len() {
&body[sec_start..sec_start + sec_length]
} else {
&[]
};
let ntlmssp_type = sec_data
.windows(12)
.find(|w| w.starts_with(b"NTLMSSP\0"))
.map(|w| u32::from_le_bytes([w[8], w[9], w[10], w[11]]));
log::info!(
"SESSION_SETUP: sec_offset={sec_offset} sec_length={sec_length} ntlmssp_type={:?}",
ntlmssp_type
);
if ntlmssp_type == Some(1) {
let client_flags = sec_data
.windows(12)
.find(|w| w.starts_with(b"NTLMSSP\0"))
.and_then(|w| {
if w.len() >= 16 {
Some(u32::from_le_bytes([w[12], w[13], w[14], w[15]]))
} else {
None
}
})
.unwrap_or(0xe2088233);
let server_flags = (client_flags & !0x02000000) | 0x00020000 | 0x00800000;
let target_name_utf16 = to_utf16le("SSHFS");
let mut target_info = Vec::with_capacity(64);
target_info.extend_from_slice(&2u16.to_le_bytes());
target_info.extend_from_slice(&(target_name_utf16.len() as u16).to_le_bytes());
target_info.extend_from_slice(&target_name_utf16);
target_info.extend_from_slice(&1u16.to_le_bytes());
target_info.extend_from_slice(&(target_name_utf16.len() as u16).to_le_bytes());
target_info.extend_from_slice(&target_name_utf16);
target_info.extend_from_slice(&4u16.to_le_bytes());
target_info.extend_from_slice(&0u16.to_le_bytes());
let dns_name = to_utf16le("sshfs");
target_info.extend_from_slice(&3u16.to_le_bytes());
target_info.extend_from_slice(&(dns_name.len() as u16).to_le_bytes());
target_info.extend_from_slice(&dns_name);
let now_ft = unix_to_filetime(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
);
target_info.extend_from_slice(&7u16.to_le_bytes());
target_info.extend_from_slice(&8u16.to_le_bytes());
target_info.extend_from_slice(&now_ft.to_le_bytes());
target_info.extend_from_slice(&0u16.to_le_bytes());
target_info.extend_from_slice(&0u16.to_le_bytes());
let target_name_offset = 48u32;
let target_info_offset = target_name_offset + target_name_utf16.len() as u32;
let mut challenge =
Vec::with_capacity(48 + target_name_utf16.len() + target_info.len());
challenge.extend_from_slice(b"NTLMSSP\0"); challenge.extend_from_slice(&2u32.to_le_bytes()); challenge.extend_from_slice(&(target_name_utf16.len() as u16).to_le_bytes()); challenge.extend_from_slice(&(target_name_utf16.len() as u16).to_le_bytes()); challenge.extend_from_slice(&target_name_offset.to_le_bytes()); challenge.extend_from_slice(&server_flags.to_le_bytes()); challenge.extend_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]); challenge.extend_from_slice(&[0u8; 8]); challenge.extend_from_slice(&(target_info.len() as u16).to_le_bytes()); challenge.extend_from_slice(&(target_info.len() as u16).to_le_bytes()); challenge.extend_from_slice(&target_info_offset.to_le_bytes()); challenge.extend_from_slice(&target_name_utf16);
challenge.extend_from_slice(&target_info);
log::info!("NTLMSSP challenge: client_flags=0x{client_flags:08x} server_flags=0x{server_flags:08x}");
let spnego = wrap_ntlmssp_in_spnego(&challenge);
let mut resp = Vec::with_capacity(16 + spnego.len());
resp.extend_from_slice(&9u16.to_le_bytes());
resp.extend_from_slice(&0u16.to_le_bytes()); let sec_off = (SMB2_HEADER_SIZE + 8) as u16;
resp.extend_from_slice(&sec_off.to_le_bytes());
resp.extend_from_slice(&(spnego.len() as u16).to_le_bytes());
resp.extend_from_slice(&spnego);
let mut full_hdr = hdr.clone();
full_hdr.session_id = self.session_id;
full_hdr.write_response(STATUS_MORE_PROCESSING, &resp, out);
log::info!("Sent NTLMSSP challenge in SPNEGO ({} bytes)", spnego.len());
log::debug!("SPNEGO challenge hex: {}", hex_dump(&spnego, 128));
} else {
let accept = spnego_accept_complete();
let mut resp = Vec::with_capacity(16 + accept.len());
resp.extend_from_slice(&9u16.to_le_bytes());
resp.extend_from_slice(&1u16.to_le_bytes()); let sec_off = (SMB2_HEADER_SIZE + 8) as u16;
resp.extend_from_slice(&sec_off.to_le_bytes());
resp.extend_from_slice(&(accept.len() as u16).to_le_bytes());
resp.extend_from_slice(&accept);
let mut full_hdr = hdr.clone();
full_hdr.session_id = self.session_id;
full_hdr.write_response(STATUS_SUCCESS, &resp, out);
log::info!("Session accepted as guest (phase {})", self.auth_phase);
self.auth_phase = 0;
}
}
fn handle_logoff(&mut self, hdr: &Smb2Header, out: &mut Vec<u8>) {
let mut resp = Vec::with_capacity(4);
resp.extend_from_slice(&4u16.to_le_bytes()); resp.extend_from_slice(&0u16.to_le_bytes()); hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_tree_connect(&mut self, hdr: &Smb2Header, _body: &[u8], out: &mut Vec<u8>) {
let mut resp = Vec::with_capacity(16);
resp.extend_from_slice(&16u16.to_le_bytes()); resp.push(0x01); resp.push(0); resp.extend_from_slice(&0x0000_0030u32.to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&0x001F01FFu32.to_le_bytes());
let mut full_hdr = hdr.clone();
full_hdr.tree_id = self.tree_id;
full_hdr.write_response(STATUS_SUCCESS, &resp, out);
log::info!("Tree connected: share={}", self.share_name);
}
fn handle_tree_disconnect(&mut self, hdr: &Smb2Header, out: &mut Vec<u8>) {
let mut resp = Vec::with_capacity(4);
resp.extend_from_slice(&4u16.to_le_bytes());
resp.extend_from_slice(&0u16.to_le_bytes());
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_create(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
if body.len() < 48 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let _desired_access = read_u32_le(body, 24);
let _file_attributes = read_u32_le(body, 28);
let _share_access = read_u32_le(body, 32);
let create_disposition = read_u32_le(body, 36);
let create_options = read_u32_le(body, 40);
let name_offset = read_u16_le(body, 44) as usize;
let name_length = read_u16_le(body, 46) as usize;
let name_start = name_offset.saturating_sub(SMB2_HEADER_SIZE);
let rel_name = if name_length > 0 && name_start + name_length <= body.len() {
from_utf16le(&body[name_start..name_start + name_length])
} else {
String::new()
};
let path = self.full_path(&rel_name);
let want_dir = create_options & FILE_DIRECTORY_FILE != 0;
log::debug!("CREATE: path={path} disposition={create_disposition} dir={want_dir}");
match create_disposition {
FILE_SUPERSEDE | FILE_OPEN | FILE_OPEN_IF => {
match self.stat_cached(&path) {
Ok((attr, is_dir)) => {
self.respond_create_success(hdr, &path, &attr, is_dir, out);
}
Err(_) if create_disposition == FILE_OPEN_IF => {
if want_dir {
if let Err(e) = self.sftp.mkdir(&path, 0o755) {
log::warn!("mkdir failed: {e}");
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
self.invalidate_path(&path);
match self.stat_cached(&path) {
Ok((attr, is_dir)) => {
self.respond_create_success(hdr, &path, &attr, is_dir, out);
}
Err(s) => self.error_response(hdr, s, out),
}
} else {
match self.sftp.open(
&path,
SSH_FXF_CREAT | SSH_FXF_READ | SSH_FXF_WRITE,
0o644,
) {
Ok(sftp_handle) => {
let _ = self.sftp.close(&sftp_handle);
self.invalidate_path(&path);
match self.stat_cached(&path) {
Ok((attr, is_dir)) => {
self.respond_create_success(
hdr, &path, &attr, is_dir, out,
);
}
Err(s) => self.error_response(hdr, s, out),
}
}
Err(_) => self.error_response(hdr, STATUS_ACCESS_DENIED, out),
}
}
}
Err(s) => self.error_response(hdr, s, out),
}
}
FILE_CREATE => {
if self.stat_cached(&path).is_ok() {
self.error_response(hdr, STATUS_OBJECT_NAME_COLLISION, out);
return;
}
if want_dir {
if let Err(_) = self.sftp.mkdir(&path, 0o755) {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
} else {
match self.sftp.open(
&path,
SSH_FXF_CREAT | SSH_FXF_WRITE | SSH_FXF_TRUNC,
0o644,
) {
Ok(h) => {
let _ = self.sftp.close(&h);
}
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
}
self.invalidate_path(&path);
match self.stat_cached(&path) {
Ok((attr, is_dir)) => {
self.respond_create_success(hdr, &path, &attr, is_dir, out);
}
Err(s) => self.error_response(hdr, s, out),
}
}
FILE_OVERWRITE | FILE_OVERWRITE_IF => {
match self
.sftp
.open(&path, SSH_FXF_CREAT | SSH_FXF_TRUNC | SSH_FXF_WRITE, 0o644)
{
Ok(h) => {
let _ = self.sftp.close(&h);
}
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
self.invalidate_path(&path);
match self.stat_cached(&path) {
Ok((attr, is_dir)) => {
self.respond_create_success(hdr, &path, &attr, is_dir, out);
}
Err(s) => self.error_response(hdr, s, out),
}
}
_ => self.error_response(hdr, STATUS_INVALID_PARAMETER, out),
}
}
fn respond_create_success(
&mut self,
hdr: &Smb2Header,
path: &str,
attr: &FileAttr,
is_dir: bool,
out: &mut Vec<u8>,
) {
let handle_id = self.alloc_handle();
self.last_create_handle = handle_id;
self.handles.insert(
handle_id,
OpenHandle {
sftp_handle: None, path: path.to_string(),
is_dir,
dir_entries: None,
dir_offset: 0,
readahead: None,
},
);
let ft_create = unix_to_filetime(attr.mtime as u64);
let ft_access = unix_to_filetime(attr.atime as u64);
let ft_write = unix_to_filetime(attr.mtime as u64);
let ft_change = unix_to_filetime(attr.mtime as u64);
let file_attrs = if is_dir {
FILE_ATTRIBUTE_DIRECTORY
} else {
FILE_ATTRIBUTE_ARCHIVE
};
let mut resp = Vec::with_capacity(96);
resp.extend_from_slice(&89u16.to_le_bytes()); resp.push(0); resp.push(0); resp.extend_from_slice(&1u32.to_le_bytes()); resp.extend_from_slice(&ft_create.to_le_bytes()); resp.extend_from_slice(&ft_access.to_le_bytes()); resp.extend_from_slice(&ft_write.to_le_bytes()); resp.extend_from_slice(&ft_change.to_le_bytes()); resp.extend_from_slice(&attr.size.to_le_bytes()); resp.extend_from_slice(&attr.size.to_le_bytes()); resp.extend_from_slice(&file_attrs.to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&handle_id.to_le_bytes()); resp.extend_from_slice(&handle_id.to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.push(0);
log::debug!("CREATE OK: path={path} is_dir={is_dir} file_attrs=0x{file_attrs:08x} size={} handle={handle_id}", attr.size);
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_close(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
let fid = if body.len() >= 24 {
self.resolve_fid(read_u64_le(body, 8)) } else {
0
};
if let Some(handle) = self.handles.remove(&fid) {
if let Some(ref sftp_h) = handle.sftp_handle {
let _ = self.sftp.close(sftp_h);
}
}
let mut resp = Vec::with_capacity(60);
resp.extend_from_slice(&60u16.to_le_bytes()); resp.extend_from_slice(&0u16.to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&[0u8; 48]);
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_read(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
if body.len() < 32 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let length = read_u32_le(body, 4) as u64;
let offset = read_u64_le(body, 8);
let fid = self.resolve_fid(read_u64_le(body, 16));
let handle = match self.handles.get_mut(&fid) {
Some(h) => h,
None => {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
};
if handle.sftp_handle.is_none() {
match self.sftp.open(&handle.path, SSH_FXF_READ, 0) {
Ok(h) => handle.sftp_handle = Some(h),
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
}
if let Some(ref ra) = handle.readahead {
if offset >= ra.offset && offset + length <= ra.offset + ra.data.len() as u64 {
let start = (offset - ra.offset) as usize;
let end = start + length as usize;
let data = &ra.data[start..end];
Self::write_read_response(hdr, data, out);
return;
}
}
let sftp_h = handle.sftp_handle.as_ref().map(|h| h.clone());
let path = handle.path.clone();
match sftp_h {
Some(ref h) => match self.sftp.read(h, offset, length as u32) {
Ok(data) if data.is_empty() => {
self.error_response(hdr, STATUS_END_OF_FILE, out);
}
Ok(data) => {
let respond_len = (length as usize).min(data.len());
Self::write_read_response(hdr, &data[..respond_len], out);
if let Some(h) = self.handles.get_mut(&fid) {
h.readahead = Some(ReadAhead { data, offset });
}
}
Err(SftpError::Disconnected) => {
self.on_reconnect();
match self.sftp.open(&path, SSH_FXF_READ, 0) {
Ok(new_h) => match self.sftp.read(&new_h, offset, length as u32) {
Ok(data) if data.is_empty() => {
self.error_response(hdr, STATUS_END_OF_FILE, out);
}
Ok(data) => {
let respond_len = (length as usize).min(data.len());
Self::write_read_response(hdr, &data[..respond_len], out);
if let Some(h) = self.handles.get_mut(&fid) {
h.sftp_handle = Some(new_h);
h.readahead = Some(ReadAhead { data, offset });
}
}
Err(_) => self.error_response(hdr, STATUS_ACCESS_DENIED, out),
},
Err(_) => self.error_response(hdr, STATUS_ACCESS_DENIED, out),
}
}
Err(_) => self.error_response(hdr, STATUS_ACCESS_DENIED, out),
},
None => self.error_response(hdr, STATUS_INVALID_PARAMETER, out),
}
}
fn write_read_response(hdr: &Smb2Header, data: &[u8], out: &mut Vec<u8>) {
let data_offset = SMB2_HEADER_SIZE as u16 + 16;
let mut resp = Vec::with_capacity(16 + data.len());
resp.extend_from_slice(&17u16.to_le_bytes()); resp.extend_from_slice(&data_offset.to_le_bytes()); resp.extend_from_slice(&(data.len() as u32).to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.push(0); resp.extend_from_slice(data);
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_write(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
if body.len() < 32 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let data_offset = read_u16_le(body, 2) as usize;
let length = read_u32_le(body, 4) as usize;
let offset = read_u64_le(body, 8);
let fid = self.resolve_fid(read_u64_le(body, 16));
let data_start = data_offset.saturating_sub(SMB2_HEADER_SIZE);
if data_start + length > body.len() {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let data = &body[data_start..data_start + length];
let handle = match self.handles.get_mut(&fid) {
Some(h) => h,
None => {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
};
if handle.sftp_handle.is_none() {
match self.sftp.open(&handle.path, SSH_FXF_WRITE, 0) {
Ok(h) => handle.sftp_handle = Some(h),
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
}
let sftp_h = handle.sftp_handle.as_ref().map(|h| h.clone());
let write_path = handle.path.clone();
handle.readahead = None; let write_result = match sftp_h {
Some(ref h) => match self.sftp.write(h, offset, data) {
Err(SftpError::Disconnected) => {
self.on_reconnect();
match self.sftp.open(&write_path, SSH_FXF_WRITE, 0) {
Ok(new_h) => {
let r = self.sftp.write(&new_h, offset, data);
if let Some(h) = self.handles.get_mut(&fid) {
h.sftp_handle = Some(new_h);
}
r
}
Err(e) => Err(e),
}
}
other => other,
},
None => Err(SftpError::Disconnected),
};
match write_result {
Ok(()) => {
self.invalidate_path(&write_path);
let mut resp = Vec::with_capacity(16);
resp.extend_from_slice(&17u16.to_le_bytes()); resp.extend_from_slice(&0u16.to_le_bytes()); resp.extend_from_slice(&(length as u32).to_le_bytes()); resp.extend_from_slice(&0u32.to_le_bytes()); resp.extend_from_slice(&0u16.to_le_bytes()); resp.extend_from_slice(&0u16.to_le_bytes()); resp.push(0); hdr.write_response(STATUS_SUCCESS, &resp, out);
}
Err(_) => self.error_response(hdr, STATUS_ACCESS_DENIED, out),
}
}
fn handle_query_directory(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
if body.len() < 24 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let info_level = body[2];
let flags = body[3];
let fid = self.resolve_fid(read_u64_le(body, 8));
let restart = flags & 0x01 != 0;
let name_offset = if body.len() >= 26 {
read_u16_le(body, 24) as usize
} else {
0
};
let name_length = if body.len() >= 28 {
read_u16_le(body, 26) as usize
} else {
0
};
let pattern = if name_length > 0 {
let name_start = name_offset.saturating_sub(SMB2_HEADER_SIZE);
if name_start + name_length <= body.len() {
from_utf16le(&body[name_start..name_start + name_length])
} else {
"*".to_string()
}
} else {
"*".to_string()
};
log::info!("QUERY_DIRECTORY: info_level={info_level} flags=0x{flags:02x} fid={fid} restart={restart} pattern=\"{pattern}\"");
let handle = match self.handles.get_mut(&fid) {
Some(h) if h.is_dir => h,
_ => {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
};
if handle.dir_entries.is_none() || restart {
let dir_path = handle.path.clone();
if let Some(cached) = self.dir_cache.get(&dir_path) {
log::debug!("QUERY_DIRECTORY: dir cache hit for {dir_path}");
handle.dir_entries = Some(cached);
if restart {
handle.dir_offset = 0;
}
} else {
match self.sftp.readdir(&dir_path) {
Ok(entries) => {
self.cache.insert_dir_entries(&dir_path, &entries);
self.dir_cache.insert(dir_path.clone(), entries);
handle.dir_entries = self.dir_cache.get(&dir_path);
handle.dir_offset = 0;
}
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
}
}
let entries = match &handle.dir_entries {
Some(e) => e,
None => {
self.error_response(hdr, STATUS_NO_MORE_FILES, out);
return;
}
};
let is_wildcard = pattern == "*";
let filtered: Vec<&DirEntry> = if is_wildcard {
entries.iter().skip(handle.dir_offset).collect()
} else {
entries
.iter()
.filter(|e| smb_pattern_match(&pattern, &e.name))
.collect()
};
if filtered.is_empty() {
self.error_response(hdr, STATUS_NO_MORE_FILES, out);
return;
}
let single_entry = flags & 0x02 != 0; let mut dir_data = Vec::with_capacity(if single_entry {
256
} else {
filtered.len() * 128
});
let max_entries = if single_entry { 1 } else { usize::MAX };
let mut count = 0;
let mut entry_starts: Vec<usize> = Vec::new();
for entry in &filtered {
if count >= max_entries {
break;
}
if is_wildcard {
handle.dir_offset += 1;
}
count += 1;
let name_bytes = to_utf16le(&entry.name);
let is_dir = entry.attrs.perm & 0o40000 != 0;
let ft_create = unix_to_filetime(entry.attrs.mtime as u64);
let ft_access = unix_to_filetime(entry.attrs.atime as u64);
let ft_write = unix_to_filetime(entry.attrs.mtime as u64);
let file_attrs = if is_dir {
FILE_ATTRIBUTE_DIRECTORY
} else {
FILE_ATTRIBUTE_ARCHIVE
};
if !entry_starts.is_empty() {
while dir_data.len() % 8 != 0 {
dir_data.push(0);
}
}
let entry_start = dir_data.len();
entry_starts.push(entry_start);
dir_data.extend_from_slice(&0u32.to_le_bytes()); dir_data.extend_from_slice(&0u32.to_le_bytes()); dir_data.extend_from_slice(&ft_create.to_le_bytes()); dir_data.extend_from_slice(&ft_access.to_le_bytes()); dir_data.extend_from_slice(&ft_write.to_le_bytes()); dir_data.extend_from_slice(&ft_write.to_le_bytes()); dir_data.extend_from_slice(&entry.attrs.size.to_le_bytes()); dir_data.extend_from_slice(&entry.attrs.size.to_le_bytes()); dir_data.extend_from_slice(&file_attrs.to_le_bytes()); dir_data.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes()); dir_data.extend_from_slice(&0u32.to_le_bytes()); dir_data.push(0); dir_data.push(0); dir_data.extend_from_slice(&[0u8; 24]); dir_data.extend_from_slice(&0u16.to_le_bytes()); dir_data.extend_from_slice(&(count as u64).to_le_bytes()); dir_data.extend_from_slice(&name_bytes); }
for i in 0..entry_starts.len().saturating_sub(1) {
let this_start = entry_starts[i];
let next_start = entry_starts[i + 1];
let offset = (next_start - this_start) as u32;
dir_data[this_start..this_start + 4].copy_from_slice(&offset.to_le_bytes());
}
if dir_data.is_empty() {
self.error_response(hdr, STATUS_NO_MORE_FILES, out);
return;
}
let data_offset = (SMB2_HEADER_SIZE + 8) as u16;
let mut resp = Vec::with_capacity(8 + dir_data.len());
resp.extend_from_slice(&9u16.to_le_bytes()); resp.extend_from_slice(&data_offset.to_le_bytes()); resp.extend_from_slice(&(dir_data.len() as u32).to_le_bytes()); resp.extend_from_slice(&dir_data);
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_query_info(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
if body.len() < 32 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let info_type = body[2];
let file_info_class = body[3];
let fid = self.resolve_fid(read_u64_le(body, 24));
log::info!("QUERY_INFO: type={info_type} class={file_info_class} fid={fid}");
let handle = match self.handles.get(&fid) {
Some(h) => h,
None => {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
};
let path = handle.path.clone();
let is_dir = handle.is_dir;
let (attr, _) = match self.stat_cached(&path) {
Ok(v) => v,
Err(s) => {
self.error_response(hdr, s, out);
return;
}
};
let ft = unix_to_filetime(attr.mtime as u64);
let ft_access = unix_to_filetime(attr.atime as u64);
let file_attrs = if is_dir {
FILE_ATTRIBUTE_DIRECTORY
} else {
FILE_ATTRIBUTE_ARCHIVE
};
let mut info_data = Vec::with_capacity(128);
match (info_type, file_info_class) {
(SMB2_0_INFO_FILE, FILE_BASIC_INFORMATION) => {
info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&ft_access.to_le_bytes()); info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&file_attrs.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); }
(SMB2_0_INFO_FILE, FILE_STANDARD_INFORMATION) => {
info_data.extend_from_slice(&attr.size.to_le_bytes()); info_data.extend_from_slice(&attr.size.to_le_bytes()); info_data.extend_from_slice(&1u32.to_le_bytes()); info_data.push(0); info_data.push(if is_dir { 1 } else { 0 }); info_data.extend_from_slice(&0u16.to_le_bytes()); }
(SMB2_0_INFO_FILE, FILE_INTERNAL_INFORMATION) => {
info_data.extend_from_slice(&0u64.to_le_bytes()); }
(SMB2_0_INFO_FILE, FILE_EA_INFORMATION) => {
info_data.extend_from_slice(&0u32.to_le_bytes()); }
(SMB2_0_INFO_FILE, FILE_NETWORK_OPEN_INFORMATION) => {
info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&ft_access.to_le_bytes()); info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&attr.size.to_le_bytes()); info_data.extend_from_slice(&attr.size.to_le_bytes()); info_data.extend_from_slice(&file_attrs.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); }
(SMB2_0_INFO_FILE, FILE_ATTRIBUTE_TAG_INFORMATION) => {
info_data.extend_from_slice(&file_attrs.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); }
(SMB2_0_INFO_FILE, FILE_STREAM_INFORMATION) => {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
(SMB2_0_INFO_FILE, FILE_ALL_INFORMATION) => {
info_data.extend_from_slice(&ft.to_le_bytes());
info_data.extend_from_slice(&ft_access.to_le_bytes());
info_data.extend_from_slice(&ft.to_le_bytes());
info_data.extend_from_slice(&ft.to_le_bytes());
info_data.extend_from_slice(&file_attrs.to_le_bytes());
info_data.extend_from_slice(&0u32.to_le_bytes()); info_data.extend_from_slice(&attr.size.to_le_bytes());
info_data.extend_from_slice(&attr.size.to_le_bytes());
info_data.extend_from_slice(&1u32.to_le_bytes());
info_data.push(0);
info_data.push(if is_dir { 1 } else { 0 });
info_data.extend_from_slice(&0u16.to_le_bytes());
info_data.extend_from_slice(&0u64.to_le_bytes());
info_data.extend_from_slice(&0u32.to_le_bytes());
info_data.extend_from_slice(&MAXIMUM_ALLOWED.to_le_bytes());
info_data.extend_from_slice(&0u64.to_le_bytes());
info_data.extend_from_slice(&0u32.to_le_bytes());
info_data.extend_from_slice(&0u32.to_le_bytes());
let name_bytes = to_utf16le(path.rsplit('/').next().unwrap_or(""));
info_data.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
info_data.extend_from_slice(&name_bytes);
}
(SMB2_0_INFO_FILE, FILE_POSITION_INFORMATION) => {
info_data.extend_from_slice(&0u64.to_le_bytes());
}
(SMB2_0_INFO_FILESYSTEM, FS_SIZE_INFORMATION | FS_FULL_SIZE_INFORMATION) => {
info_data.extend_from_slice(&(1024u64 * 1024 * 1024).to_le_bytes()); info_data.extend_from_slice(&(512u64 * 1024 * 1024).to_le_bytes()); if file_info_class == FS_FULL_SIZE_INFORMATION {
info_data.extend_from_slice(&(512u64 * 1024 * 1024).to_le_bytes());
}
info_data.extend_from_slice(&1u32.to_le_bytes()); info_data.extend_from_slice(&4096u32.to_le_bytes()); }
(SMB2_0_INFO_FILESYSTEM, FS_ATTRIBUTE_INFORMATION) => {
info_data.extend_from_slice(&0x0000_0003u32.to_le_bytes()); info_data.extend_from_slice(&255u32.to_le_bytes()); let label = to_utf16le("SSHFS");
info_data.extend_from_slice(&(label.len() as u32).to_le_bytes());
info_data.extend_from_slice(&label);
}
(SMB2_0_INFO_FILESYSTEM, FS_VOLUME_INFORMATION) => {
info_data.extend_from_slice(&ft.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); let label = to_utf16le("sshfs");
info_data.extend_from_slice(&(label.len() as u32).to_le_bytes());
info_data.push(0); info_data.push(0); info_data.extend_from_slice(&label);
}
(SMB2_0_INFO_FILESYSTEM, FS_SECTOR_SIZE_INFORMATION) => {
info_data.extend_from_slice(&4096u32.to_le_bytes()); info_data.extend_from_slice(&4096u32.to_le_bytes()); info_data.extend_from_slice(&4096u32.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); info_data.extend_from_slice(&0u32.to_le_bytes()); }
(SMB2_0_INFO_SECURITY, _) => {
info_data.extend_from_slice(&[0u8; 20]); }
_ => {
log::debug!("QUERY_INFO: unsupported type={info_type} class={file_info_class}");
self.error_response(hdr, STATUS_NOT_SUPPORTED, out);
return;
}
}
let data_offset = (SMB2_HEADER_SIZE + 8) as u16;
let mut resp = Vec::with_capacity(8 + info_data.len());
resp.extend_from_slice(&9u16.to_le_bytes()); resp.extend_from_slice(&data_offset.to_le_bytes()); resp.extend_from_slice(&(info_data.len() as u32).to_le_bytes()); resp.extend_from_slice(&info_data);
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_set_info(&mut self, hdr: &Smb2Header, body: &[u8], out: &mut Vec<u8>) {
if body.len() < 24 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let info_type = body[2];
let file_info_class = body[3];
let buf_length = read_u32_le(body, 4) as usize;
let buf_offset = read_u16_le(body, 8) as usize;
let fid = self.resolve_fid(read_u64_le(body, 16));
let handle = match self.handles.get(&fid) {
Some(h) => h,
None => {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
};
let path = handle.path.clone();
let data_start = buf_offset.saturating_sub(SMB2_HEADER_SIZE);
let info_data = if data_start + buf_length <= body.len() {
&body[data_start..data_start + buf_length]
} else {
&[]
};
match (info_type, file_info_class) {
(SMB2_0_INFO_FILE, FILE_RENAME_INFORMATION) => {
if info_data.len() < 24 {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let name_len = read_u32_le(info_data, 16) as usize;
if 20 + name_len > info_data.len() {
self.error_response(hdr, STATUS_INVALID_PARAMETER, out);
return;
}
let new_name = from_utf16le(&info_data[20..20 + name_len]);
let new_path = self.full_path(&new_name);
match self.sftp.rename(&path, &new_path) {
Ok(()) => {
self.invalidate_path(&path);
self.invalidate_path(&new_path);
if let Some(h) = self.handles.get_mut(&fid) {
h.path = new_path;
}
}
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
}
(SMB2_0_INFO_FILE, FILE_DISPOSITION_INFORMATION) => {
let delete = info_data.first().copied().unwrap_or(0) != 0;
if delete {
let is_dir = handle.is_dir;
let result = if is_dir {
self.sftp.rmdir(&path)
} else {
self.sftp.remove(&path)
};
match result {
Ok(()) => self.invalidate_path(&path),
Err(_) => {
self.error_response(hdr, STATUS_ACCESS_DENIED, out);
return;
}
}
}
}
(SMB2_0_INFO_FILE, FILE_BASIC_INFORMATION) => {
if info_data.len() >= 36 {
if let Ok((mut attr, _)) = self.stat_cached(&path) {
let new_atime = read_u64_le(info_data, 8);
let new_mtime = read_u64_le(info_data, 16);
if new_atime != 0 {
attr.atime = filetime_to_unix(new_atime) as u32;
}
if new_mtime != 0 {
attr.mtime = filetime_to_unix(new_mtime) as u32;
}
let _ = self.sftp.setstat(&path, &attr);
self.invalidate_path(&path);
}
}
}
_ => {
log::debug!("SET_INFO: unsupported type={info_type} class={file_info_class}");
}
}
let mut resp = Vec::with_capacity(2);
resp.extend_from_slice(&2u16.to_le_bytes()); hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_flush(&mut self, hdr: &Smb2Header, out: &mut Vec<u8>) {
let mut resp = Vec::with_capacity(4);
resp.extend_from_slice(&4u16.to_le_bytes());
resp.extend_from_slice(&0u16.to_le_bytes());
hdr.write_response(STATUS_SUCCESS, &resp, out);
}
fn handle_ioctl(&mut self, hdr: &Smb2Header, out: &mut Vec<u8>) {
self.error_response(hdr, STATUS_INVALID_DEVICE_REQUEST, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pattern_wildcard_matches_everything() {
assert!(smb_pattern_match("*", "anything"));
assert!(smb_pattern_match("*", ""));
assert!(smb_pattern_match("*", ".DS_Store"));
}
#[test]
fn pattern_exact_case_insensitive() {
assert!(smb_pattern_match("hello.txt", "hello.txt"));
assert!(smb_pattern_match("Hello.TXT", "hello.txt"));
assert!(smb_pattern_match("hello.txt", "HELLO.TXT"));
assert!(!smb_pattern_match("hello.txt", "hello.tx"));
assert!(!smb_pattern_match("hello.txt", "hello.txtt"));
}
#[test]
fn pattern_question_mark() {
assert!(smb_pattern_match("?.txt", "a.txt"));
assert!(!smb_pattern_match("?.txt", "ab.txt"));
assert!(smb_pattern_match("he??o", "hello"));
assert!(!smb_pattern_match("he??o", "helo"));
}
#[test]
fn pattern_star_prefix_suffix() {
assert!(smb_pattern_match("*.txt", "readme.txt"));
assert!(smb_pattern_match("*.txt", ".txt"));
assert!(!smb_pattern_match("*.txt", "readme.md"));
assert!(smb_pattern_match("readme.*", "readme.txt"));
assert!(smb_pattern_match("readme.*", "readme."));
}
#[test]
fn pattern_star_middle() {
assert!(smb_pattern_match("a*z", "az"));
assert!(smb_pattern_match("a*z", "abcz"));
assert!(!smb_pattern_match("a*z", "abcx"));
}
#[test]
fn pattern_empty_inputs() {
assert!(smb_pattern_match("*", ""));
assert!(!smb_pattern_match("a", ""));
assert!(!smb_pattern_match("", "a"));
assert!(smb_pattern_match("", ""));
}
#[test]
fn pattern_no_panic_on_long_star() {
assert!(smb_pattern_match("*", "x"));
assert!(!smb_pattern_match("a*b*c", "ac"));
assert!(smb_pattern_match("a*b*c", "abc"));
assert!(smb_pattern_match("a*b*c", "aXXbYYc"));
}
#[test]
fn apple_metadata_detected() {
assert!(is_apple_metadata(".DS_Store"));
assert!(is_apple_metadata("._somefile"));
assert!(is_apple_metadata(".Spotlight-V100"));
assert!(is_apple_metadata(".Trashes"));
assert!(is_apple_metadata(".fseventsd"));
assert!(is_apple_metadata("Icon\r"));
}
#[test]
fn non_apple_metadata() {
assert!(!is_apple_metadata("readme.md"));
assert!(!is_apple_metadata(".gitignore"));
assert!(!is_apple_metadata(".bashrc"));
assert!(!is_apple_metadata("DS_Store")); }
fn test_attr(size: u64, perm: u32) -> FileAttr {
FileAttr {
size,
uid: 1000,
gid: 1000,
perm,
atime: 1000,
mtime: 2000,
}
}
#[test]
fn attr_cache_insert_and_get() {
let mut c = AttrCache::new();
c.insert("/a/b".into(), test_attr(100, 0o100644), false);
let (attr, is_dir) = c.get("/a/b").unwrap();
assert_eq!(attr.size, 100);
assert!(!is_dir);
}
#[test]
fn attr_cache_miss() {
let c = AttrCache::new();
assert!(c.get("/nonexistent").is_none());
}
#[test]
fn attr_cache_negative() {
let mut c = AttrCache::new();
assert!(!c.is_negative("/a"));
c.insert_negative("/a".into());
assert!(c.is_negative("/a"));
}
#[test]
fn attr_cache_insert_clears_negative() {
let mut c = AttrCache::new();
c.insert_negative("/a".into());
assert!(c.is_negative("/a"));
c.insert("/a".into(), test_attr(10, 0o100644), false);
assert!(!c.is_negative("/a"));
assert!(c.get("/a").is_some());
}
#[test]
fn attr_cache_invalidate() {
let mut c = AttrCache::new();
c.insert("/a".into(), test_attr(10, 0o100644), false);
c.insert_negative("/b".into());
c.invalidate("/a");
c.invalidate("/b");
assert!(c.get("/a").is_none());
assert!(!c.is_negative("/b"));
}
#[test]
fn attr_cache_insert_dir_entries() {
let mut c = AttrCache::new();
let entries = vec![
DirEntry {
name: "file.txt".into(),
attrs: test_attr(500, 0o100644),
},
DirEntry {
name: "subdir".into(),
attrs: test_attr(4096, 0o40755),
},
];
c.insert_dir_entries("/home", &entries);
let (a, d) = c.get("/home/file.txt").unwrap();
assert_eq!(a.size, 500);
assert!(!d);
let (a, d) = c.get("/home/subdir").unwrap();
assert_eq!(a.size, 4096);
assert!(d);
}
#[test]
fn attr_cache_evict_expired() {
let mut c = AttrCache::new();
c.positive.insert(
"/stale".into(),
CachedAttr {
attr: test_attr(1, 0o100644),
is_dir: false,
expires: Instant::now() - Duration::from_secs(1),
},
);
c.negative
.insert("/gone".into(), Instant::now() - Duration::from_secs(1));
c.insert("/fresh".into(), test_attr(2, 0o100644), false);
assert_eq!(c.positive.len(), 2);
assert_eq!(c.negative.len(), 1);
c.evict_expired();
assert_eq!(c.positive.len(), 1); assert_eq!(c.negative.len(), 0);
assert!(c.get("/fresh").is_some());
}
#[test]
fn dir_cache_insert_and_get() {
let mut c = DirCache::new();
let entries = vec![DirEntry {
name: "a.txt".into(),
attrs: test_attr(10, 0o100644),
}];
c.insert("/dir".into(), entries);
let got = c.get("/dir").unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "a.txt");
}
#[test]
fn dir_cache_miss() {
let c = DirCache::new();
assert!(c.get("/nope").is_none());
}
#[test]
fn dir_cache_expired_is_miss() {
let mut c = DirCache::new();
c.dirs.insert(
"/old".into(),
CachedDir {
entries: Arc::new(vec![]),
expires: Instant::now() - Duration::from_secs(1),
},
);
assert!(c.get("/old").is_none());
}
#[test]
fn dir_cache_invalidate() {
let mut c = DirCache::new();
c.insert("/dir".into(), vec![]);
assert!(c.get("/dir").is_some());
c.invalidate("/dir");
assert!(c.get("/dir").is_none());
}
#[test]
fn dir_cache_arc_sharing() {
let mut c = DirCache::new();
let entries = vec![DirEntry {
name: "x".into(),
attrs: test_attr(1, 0o100644),
}];
c.insert("/d".into(), entries);
let a1 = c.get("/d").unwrap();
let a2 = c.get("/d").unwrap();
assert!(Arc::ptr_eq(&a1, &a2));
}
#[test]
fn dir_cache_evict_expired() {
let mut c = DirCache::new();
c.dirs.insert(
"/stale".into(),
CachedDir {
entries: Arc::new(vec![]),
expires: Instant::now() - Duration::from_secs(1),
},
);
c.insert("/fresh".into(), vec![]);
assert_eq!(c.dirs.len(), 2);
c.evict_expired();
assert_eq!(c.dirs.len(), 1);
assert!(c.get("/fresh").is_some());
}
#[test]
fn readahead_hit_within_buffer() {
let ra = ReadAhead {
data: vec![0u8; 512 * 1024], offset: 1000,
};
let off = 2000u64;
let len = 100u64;
assert!(off >= ra.offset && off + len <= ra.offset + ra.data.len() as u64);
let start = (off - ra.offset) as usize;
assert_eq!(start, 1000);
}
#[test]
fn readahead_miss_before_buffer() {
let ra = ReadAhead {
data: vec![0u8; 1024],
offset: 5000,
};
let off = 4000u64;
let len = 100u64;
assert!(!(off >= ra.offset && off + len <= ra.offset + ra.data.len() as u64));
}
#[test]
fn readahead_miss_past_buffer() {
let ra = ReadAhead {
data: vec![0u8; 1024],
offset: 0,
};
let off = 500u64;
let len = 1000u64;
assert!(!(off >= ra.offset && off + len <= ra.offset + ra.data.len() as u64));
}
#[test]
fn full_path_empty_returns_root() {
let sess = make_test_session();
assert_eq!(sess.full_path(""), "/home/user");
assert_eq!(sess.full_path("\\"), "/home/user");
assert_eq!(sess.full_path("/"), "/home/user");
}
#[test]
fn full_path_relative() {
let sess = make_test_session();
assert_eq!(
sess.full_path("docs/readme.md"),
"/home/user/docs/readme.md"
);
}
#[test]
fn full_path_backslash_normalized() {
let sess = make_test_session();
assert_eq!(
sess.full_path("docs\\sub\\file.txt"),
"/home/user/docs/sub/file.txt"
);
}
#[test]
fn full_path_leading_slash_stripped() {
let sess = make_test_session();
assert_eq!(sess.full_path("/docs"), "/home/user/docs");
}
#[test]
fn invalidate_path_clears_both_caches() {
let mut sess = make_test_session();
sess.cache.insert(
"/home/user/dir/file.txt".into(),
test_attr(10, 0o100644),
false,
);
sess.dir_cache.insert(
"/home/user/dir".into(),
vec![DirEntry {
name: "file.txt".into(),
attrs: test_attr(10, 0o100644),
}],
);
assert!(sess.cache.get("/home/user/dir/file.txt").is_some());
assert!(sess.dir_cache.get("/home/user/dir").is_some());
sess.invalidate_path("/home/user/dir/file.txt");
assert!(sess.cache.get("/home/user/dir/file.txt").is_none());
assert!(sess.dir_cache.get("/home/user/dir").is_none());
}
fn make_test_session() -> SmbSession {
let sftp = Arc::new(ReconnectingSftp::dummy());
SmbSession::new(sftp, "/home/user".into(), "test".into())
}
}