SMB Server Phase 2: VFS backend build fix + integration test
Some checks failed
Test / build (push) Has been cancelled
Test / test (push) Has been cancelled

- Add VfsFile: Send supertrait for Mutex compatibility
- Fix SmbServerCommand: struct → Subcommand enum with Start variant
- Fix tracing_subscriber::init() → try_init() to avoid panic when
  logger already initialized
- Fix CLI subcommand name: smb-server → smb-start (flatten naming)
- Add #[command(name = "smb-start")] for CLI disambiguation
- Fix unused variable warnings (smb_fs.rs, smb_server_backend.rs)
- Remove unused VfsFile imports (webdav.rs, scp_handler.rs)
- Integration test: Docker smbclient verified (list, upload, read)
This commit is contained in:
Warren
2026-06-20 19:42:29 +08:00
parent 45d050c0b3
commit 7eb528d35f
167 changed files with 59897 additions and 12 deletions

View File

@@ -0,0 +1,173 @@
use std::sync::Arc;
use super::memfs::MemFsBackend;
use crate::conn::state::{Connection, Session, TreeConnect};
use crate::server::ConfigError;
use crate::{Access, Identity, Share, ShareMode, SmbServer};
fn test_server() -> SmbServer {
SmbServer::builder()
.listen("127.0.0.1:0".parse().unwrap())
.user("alice", "password")
.share(
Share::new("home", MemFsBackend::new().with_file("seed.txt", b""))
.user("alice", Access::ReadWrite),
)
.build()
.expect("build")
}
fn public_server() -> SmbServer {
SmbServer::builder()
.listen("127.0.0.1:0".parse().unwrap())
.share(Share::new("public", MemFsBackend::new()).public())
.build()
.expect("build")
}
async fn register_session(
server: &SmbServer,
identity: Identity,
share_name: &str,
) -> Arc<Connection> {
let state = server.state();
let conn = Arc::new(Connection::new(
state.config.server_guid,
state.config.max_read_size,
state.config.max_write_size,
));
state.active_connections.register(&conn).await;
let session = Session::new(1, identity, [0; 16], [0; 16], false, None);
let session = Arc::new(tokio::sync::RwLock::new(session));
let share = state.find_share(share_name).await.expect("share");
let tree = Arc::new(tokio::sync::RwLock::new(TreeConnect::new(
1,
share,
Access::ReadWrite,
)));
{
let sess = session.read().await;
sess.trees.write().await.insert(1, tree);
}
conn.sessions.write().await.insert(1, session);
conn
}
async fn register_alice_session(server: &SmbServer) -> Arc<Connection> {
register_session(
server,
Identity::User {
user: "alice".to_string(),
domain: String::new(),
},
"home",
)
.await
}
#[tokio::test]
async fn config_handle_adds_users_and_shares() {
let server = SmbServer::builder()
.listen("127.0.0.1:0".parse().unwrap())
.build()
.expect("build");
let config = server.config_handle();
config.add_user("bob", "password").await.expect("add user");
config
.add_share(Share::new("media", MemFsBackend::new()).user("bob", Access::Read))
.await
.expect("add share");
let state = server.state();
assert!(state.lookup_user("bob").await.is_some());
assert!(state.find_share("media").await.is_some());
}
#[tokio::test]
async fn removing_user_revokes_active_sessions() {
let server = test_server();
let conn = register_alice_session(&server).await;
server
.config_handle()
.remove_user("alice")
.await
.expect("remove user");
assert!(server.state().lookup_user("alice").await.is_none());
assert!(conn.sessions.read().await.is_empty());
}
#[tokio::test]
async fn removing_share_revokes_active_trees() {
let server = test_server();
let conn = register_alice_session(&server).await;
server
.config_handle()
.remove_share("home")
.await
.expect("remove share");
assert!(server.state().find_share("home").await.is_none());
let sessions = conn.sessions.read().await;
let session = sessions.get(&1).expect("session remains").read().await;
assert!(session.trees.read().await.is_empty());
}
#[tokio::test]
async fn revoking_user_from_share_revokes_only_that_tree() {
let server = test_server();
let conn = register_alice_session(&server).await;
server
.config_handle()
.revoke_share_user("home", "alice")
.await
.expect("revoke user share");
assert!(conn.sessions.read().await.contains_key(&1));
let sessions = conn.sessions.read().await;
let session = sessions.get(&1).expect("session remains").read().await;
assert!(session.trees.read().await.is_empty());
}
#[tokio::test]
async fn changing_share_mode_revokes_active_trees() {
let server = public_server();
let conn = register_session(&server, Identity::Anonymous, "public").await;
server
.config_handle()
.set_share_mode("public", ShareMode::PublicReadOnly)
.await
.expect("set mode");
let sessions = conn.sessions.read().await;
let session = sessions.get(&1).expect("session remains").read().await;
assert!(session.trees.read().await.is_empty());
}
#[tokio::test]
async fn public_share_cannot_mix_explicit_users() {
let server = SmbServer::builder()
.listen("127.0.0.1:0".parse().unwrap())
.share(Share::new("public", MemFsBackend::new()).public())
.build()
.expect("build");
let config = server.config_handle();
config
.add_user("alice", "password")
.await
.expect("add user");
let err = config
.grant_share_user("public", "alice", Access::Read)
.await
.expect_err("grant should fail");
assert_eq!(err, ConfigError::PublicMixedWithUsers("public".to_string()));
}

300
vendor/smb-server/src/tests/memfs.rs vendored Normal file
View File

@@ -0,0 +1,300 @@
use std::collections::HashMap;
use std::sync::Mutex;
use crate::backend::{
BackendCapabilities, DirEntry, FileInfo, FileTimes, Handle, OpenIntent, OpenOptions,
ShareBackend,
};
use crate::error::{SmbError, SmbResult};
use crate::path::SmbPath;
use async_trait::async_trait;
use bytes::Bytes;
/// Minimal in-memory FS used by integration tests. Files are byte vectors,
/// directories are sets of names. Not threadsafe across workers — only used
/// within one test.
pub struct MemFsBackend {
inner: std::sync::Arc<Mutex<MemInner>>,
}
#[derive(Default)]
struct MemInner {
files: HashMap<String, Vec<u8>>,
/// All directories present (always includes "" for the root). Each
/// directory is keyed by canonical path string.
dirs: HashMap<String, ()>,
}
impl Default for MemFsBackend {
fn default() -> Self {
Self::new()
}
}
impl MemFsBackend {
pub fn new() -> Self {
let mut inner = MemInner::default();
inner.dirs.insert(String::new(), ());
Self {
inner: std::sync::Arc::new(Mutex::new(inner)),
}
}
pub fn with_file(self, path: &str, contents: &[u8]) -> Self {
{
let mut g = self.inner.lock().unwrap();
g.files.insert(path.to_string(), contents.to_vec());
}
self
}
}
fn key(path: &SmbPath) -> String {
path.display_backslash()
}
#[async_trait]
impl ShareBackend for MemFsBackend {
async fn open(&self, path: &SmbPath, opts: OpenOptions) -> SmbResult<Box<dyn Handle>> {
let k = key(path);
let mut g = self.inner.lock().unwrap();
let exists_file = g.files.contains_key(&k);
let exists_dir = g.dirs.contains_key(&k);
if opts.directory {
if exists_file {
return Err(SmbError::NotADirectory);
}
if !exists_dir {
if matches!(opts.intent, OpenIntent::Create | OpenIntent::OpenOrCreate) {
g.dirs.insert(k.clone(), ());
} else {
return Err(SmbError::NotFound);
}
}
return Ok(Box::new(MemHandle::dir(self.inner.clone(), k)));
}
if exists_dir {
return Err(SmbError::IsDirectory);
}
match opts.intent {
OpenIntent::Open => {
if !exists_file {
return Err(SmbError::NotFound);
}
}
OpenIntent::Create => {
if exists_file {
return Err(SmbError::Exists);
}
g.files.insert(k.clone(), Vec::new());
}
OpenIntent::OpenOrCreate => {
g.files.entry(k.clone()).or_default();
}
OpenIntent::Truncate => {
if !exists_file {
return Err(SmbError::NotFound);
}
g.files.insert(k.clone(), Vec::new());
}
OpenIntent::OverwriteOrCreate => {
g.files.insert(k.clone(), Vec::new());
}
}
Ok(Box::new(MemHandle::file(self.inner.clone(), k)))
}
async fn unlink(&self, path: &SmbPath) -> SmbResult<()> {
let k = key(path);
let mut g = self.inner.lock().unwrap();
if g.files.remove(&k).is_some() {
return Ok(());
}
if g.dirs.remove(&k).is_some() {
return Ok(());
}
Err(SmbError::NotFound)
}
async fn rename(&self, from: &SmbPath, to: &SmbPath) -> SmbResult<()> {
let kf = key(from);
let kt = key(to);
let mut g = self.inner.lock().unwrap();
if g.files.contains_key(&kt) || g.dirs.contains_key(&kt) {
return Err(SmbError::Exists);
}
if let Some(data) = g.files.remove(&kf) {
g.files.insert(kt, data);
return Ok(());
}
if g.dirs.remove(&kf).is_some() {
g.dirs.insert(kt, ());
return Ok(());
}
Err(SmbError::NotFound)
}
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities {
is_read_only: false,
case_sensitive: false,
}
}
}
pub struct MemHandle {
inner: std::sync::Arc<Mutex<MemInner>>,
key: String,
is_dir: bool,
}
impl MemHandle {
fn file(inner: std::sync::Arc<Mutex<MemInner>>, key: String) -> Self {
Self {
inner,
key,
is_dir: false,
}
}
fn dir(inner: std::sync::Arc<Mutex<MemInner>>, key: String) -> Self {
Self {
inner,
key,
is_dir: true,
}
}
}
#[async_trait]
impl Handle for MemHandle {
async fn read(&self, offset: u64, len: u32) -> SmbResult<Bytes> {
if self.is_dir {
return Err(SmbError::IsDirectory);
}
let g = self.inner.lock().unwrap();
let data = g.files.get(&self.key).ok_or(SmbError::NotFound)?;
let start = offset as usize;
if start >= data.len() {
return Ok(Bytes::new());
}
let end = (start + len as usize).min(data.len());
Ok(Bytes::copy_from_slice(&data[start..end]))
}
async fn write(&self, offset: u64, data: &[u8]) -> SmbResult<u32> {
if self.is_dir {
return Err(SmbError::IsDirectory);
}
let mut g = self.inner.lock().unwrap();
let buf = g.files.get_mut(&self.key).ok_or(SmbError::NotFound)?;
let needed = (offset as usize) + data.len();
if buf.len() < needed {
buf.resize(needed, 0);
}
buf[offset as usize..offset as usize + data.len()].copy_from_slice(data);
Ok(data.len() as u32)
}
async fn flush(&self) -> SmbResult<()> {
Ok(())
}
async fn stat(&self) -> SmbResult<FileInfo> {
let g = self.inner.lock().unwrap();
let size = if self.is_dir {
0
} else {
g.files.get(&self.key).ok_or(SmbError::NotFound)?.len() as u64
};
let name = self
.key
.rsplit_once('\\')
.map(|(_, n)| n.to_string())
.unwrap_or_else(|| self.key.clone());
Ok(FileInfo {
name,
end_of_file: size,
allocation_size: size,
creation_time: 0x01D9_0000_0000_0000,
last_access_time: 0x01D9_0000_0000_0000,
last_write_time: 0x01D9_0000_0000_0000,
change_time: 0x01D9_0000_0000_0000,
is_directory: self.is_dir,
file_index: 0,
})
}
async fn set_times(&self, _times: FileTimes) -> SmbResult<()> {
Ok(())
}
async fn truncate(&self, len: u64) -> SmbResult<()> {
if self.is_dir {
return Err(SmbError::IsDirectory);
}
let mut g = self.inner.lock().unwrap();
let buf = g.files.get_mut(&self.key).ok_or(SmbError::NotFound)?;
buf.resize(len as usize, 0);
Ok(())
}
async fn list_dir(&self, _pattern: Option<&str>) -> SmbResult<Vec<DirEntry>> {
if !self.is_dir {
return Err(SmbError::NotADirectory);
}
let g = self.inner.lock().unwrap();
let prefix = if self.key.is_empty() {
String::new()
} else {
format!("{}\\", self.key)
};
let mut entries = Vec::new();
for (k, v) in g.files.iter() {
if let Some(rest) = k.strip_prefix(&prefix)
&& !rest.contains('\\')
{
entries.push(DirEntry {
info: FileInfo {
name: rest.to_string(),
end_of_file: v.len() as u64,
allocation_size: v.len() as u64,
creation_time: 0x01D9_0000_0000_0000,
last_access_time: 0x01D9_0000_0000_0000,
last_write_time: 0x01D9_0000_0000_0000,
change_time: 0x01D9_0000_0000_0000,
is_directory: false,
file_index: 0,
},
});
}
}
for k in g.dirs.keys() {
if let Some(rest) = k.strip_prefix(&prefix)
&& !rest.is_empty()
&& !rest.contains('\\')
{
entries.push(DirEntry {
info: FileInfo {
name: rest.to_string(),
end_of_file: 0,
allocation_size: 0,
creation_time: 0x01D9_0000_0000_0000,
last_access_time: 0x01D9_0000_0000_0000,
last_write_time: 0x01D9_0000_0000_0000,
change_time: 0x01D9_0000_0000_0000,
is_directory: true,
file_index: 0,
},
});
}
}
Ok(entries)
}
async fn close(self: Box<Self>) -> SmbResult<()> {
Ok(())
}
}