Files
markbase/vendor/smb2/src/testing/mod.rs
T
Warren 7eb528d35f
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled
SMB Server Phase 2: VFS backend build fix + integration test
- 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)
2026-06-20 19:42:29 +08:00

1276 lines
44 KiB
Rust

//! Docker-based SMB test servers for integration testing.
//!
//! Provides [`TestServers`] for starting Samba containers on demand,
//! with factory methods that return connected [`SmbClient`] instances.
//! Enable the `testing` feature flag to use this module.
//!
//! # Three-layer testing model
//!
//! **Layer 1: Rust integration tests** -- Use [`TestServers`] to get
//! pre-connected clients in `#[tokio::test]` functions.
//!
//! **Layer 2: E2E tests** -- Use [`write_compose_files`] to extract
//! embedded Docker infrastructure, then run `docker compose up` from
//! your test framework (Playwright, Cypress, etc.).
//!
//! **Layer 3: Manual QA** -- Extract compose files once, run containers
//! manually, browse virtual servers in your app during development.
//!
//! # Example
//!
//! ```rust,no_run
//! use std::sync::LazyLock;
//! use smb2::testing::TestServers;
//!
//! static SERVERS: LazyLock<TestServers> = LazyLock::new(|| {
//! TestServers::start_blocking().unwrap()
//! });
//!
//! # async fn example() {
//! let mut guest = SERVERS.guest_client().await.unwrap();
//! let shares = guest.list_shares().await.unwrap();
//! # }
//! ```
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use log::{debug, info};
use crate::client::{ClientConfig, SmbClient};
// ── Error type ──────────────────────────────────────────────────────────
/// Errors from the test infrastructure (Docker, process, health checks).
///
/// Separate from [`crate::Error`] because these are test-setup failures,
/// not protocol errors.
#[derive(Debug)]
pub enum Error {
/// Docker compose command failed.
Docker(std::io::Error),
/// Container didn't pass health check in time.
HealthCheckTimeout {
/// Name of the container that timed out.
container: String,
},
/// Requested a client for a container that isn't running.
ContainerNotStarted {
/// Name of the container that was requested.
container: String,
/// Suggestion for how to fix this.
hint: String,
},
/// SMB connection or operation failed.
Smb(crate::Error),
/// Failed to write embedded files to disk.
Io(std::io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Docker(e) => write!(f, "docker command failed: {e}"),
Error::HealthCheckTimeout { container } => {
write!(f, "health check timed out for container: {container}")
}
Error::ContainerNotStarted { container, hint } => {
write!(f, "container not started: {container} ({hint})")
}
Error::Smb(e) => write!(f, "smb connection failed: {e}"),
Error::Io(e) => write!(f, "failed to write compose files: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Docker(e) | Error::Io(e) => Some(e),
Error::Smb(e) => Some(e),
_ => None,
}
}
}
/// Result type for test infrastructure operations.
pub type Result<T> = std::result::Result<T, Error>;
// ── Port constants ──────────────────────────────────────────────────────
const DEFAULT_GUEST_PORT: u16 = 10480;
const DEFAULT_AUTH_PORT: u16 = 10481;
const DEFAULT_BOTH_PORT: u16 = 10482;
const DEFAULT_50SHARES_PORT: u16 = 10483;
const DEFAULT_UNICODE_PORT: u16 = 10484;
const DEFAULT_LONGNAMES_PORT: u16 = 10485;
const DEFAULT_DEEPNEST_PORT: u16 = 10486;
const DEFAULT_MANYFILES_PORT: u16 = 10487;
const DEFAULT_READONLY_PORT: u16 = 10488;
const DEFAULT_WINDOWS_PORT: u16 = 10489;
const DEFAULT_SYNOLOGY_PORT: u16 = 10490;
const DEFAULT_LINUX_PORT: u16 = 10491;
const DEFAULT_FLAKY_PORT: u16 = 10492;
const DEFAULT_SLOW_PORT: u16 = 10493;
const DEFAULT_MAXREADSIZE_PORT: u16 = 10494;
/// Resolve a port from an environment variable, falling back to a default.
fn port(env_var: &str, default: u16) -> u16 {
std::env::var(env_var)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
/// Port for the guest-access container.
pub fn guest_port() -> u16 {
port("SMB_CONSUMER_GUEST_PORT", DEFAULT_GUEST_PORT)
}
/// Port for the auth-required container.
pub fn auth_port() -> u16 {
port("SMB_CONSUMER_AUTH_PORT", DEFAULT_AUTH_PORT)
}
/// Port for the mixed auth container.
pub fn both_port() -> u16 {
port("SMB_CONSUMER_BOTH_PORT", DEFAULT_BOTH_PORT)
}
/// Port for the 50-shares container.
pub fn many_shares_port() -> u16 {
port("SMB_CONSUMER_50SHARES_PORT", DEFAULT_50SHARES_PORT)
}
/// Port for the unicode container.
pub fn unicode_port() -> u16 {
port("SMB_CONSUMER_UNICODE_PORT", DEFAULT_UNICODE_PORT)
}
/// Port for the long-names container.
pub fn longnames_port() -> u16 {
port("SMB_CONSUMER_LONGNAMES_PORT", DEFAULT_LONGNAMES_PORT)
}
/// Port for the deep-nesting container.
pub fn deepnest_port() -> u16 {
port("SMB_CONSUMER_DEEPNEST_PORT", DEFAULT_DEEPNEST_PORT)
}
/// Port for the many-files container.
pub fn manyfiles_port() -> u16 {
port("SMB_CONSUMER_MANYFILES_PORT", DEFAULT_MANYFILES_PORT)
}
/// Port for the read-only container.
pub fn readonly_port() -> u16 {
port("SMB_CONSUMER_READONLY_PORT", DEFAULT_READONLY_PORT)
}
/// Port for the Windows-like container.
pub fn windows_port() -> u16 {
port("SMB_CONSUMER_WINDOWS_PORT", DEFAULT_WINDOWS_PORT)
}
/// Port for the Synology-like container.
pub fn synology_port() -> u16 {
port("SMB_CONSUMER_SYNOLOGY_PORT", DEFAULT_SYNOLOGY_PORT)
}
/// Port for the Linux container.
pub fn linux_port() -> u16 {
port("SMB_CONSUMER_LINUX_PORT", DEFAULT_LINUX_PORT)
}
/// Port for the flaky container.
pub fn flaky_port() -> u16 {
port("SMB_CONSUMER_FLAKY_PORT", DEFAULT_FLAKY_PORT)
}
/// Port for the slow container.
pub fn slow_port() -> u16 {
port("SMB_CONSUMER_SLOW_PORT", DEFAULT_SLOW_PORT)
}
/// Port for the max-read-size container.
///
/// The server enforces `smb2 max read = 65536` and `smb2 max write = 65536`,
/// so every transfer larger than 64 KB is chunked. Consumers can target this
/// fixture to exercise the streaming write/read fallback paths without
/// needing the internal-fixture `smb-maxreadsize` container.
pub fn maxreadsize_port() -> u16 {
port("SMB_CONSUMER_MAXREADSIZE_PORT", DEFAULT_MAXREADSIZE_PORT)
}
// ── Embedded files ──────────────────────────────────────────────────────
// docker-compose.yml
const COMPOSE_YML: &str = include_str!("../../tests/docker/consumer/docker-compose.yml");
// smb-consumer-guest
const GUEST_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-guest/Dockerfile");
const GUEST_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-guest/smb.conf");
// smb-consumer-auth
const AUTH_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-auth/Dockerfile");
const AUTH_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-auth/smb.conf");
// smb-consumer-both
const BOTH_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-both/Dockerfile");
const BOTH_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-both/smb.conf");
// smb-consumer-50shares
const SHARES50_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-50shares/Dockerfile");
const SHARES50_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-50shares/smb.conf");
const SHARES50_GENERATE_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-50shares/generate-conf.sh");
// smb-consumer-unicode
const UNICODE_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-unicode/Dockerfile");
const UNICODE_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-unicode/smb.conf");
const UNICODE_POPULATE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-unicode/populate.sh");
// smb-consumer-longnames
const LONGNAMES_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-longnames/Dockerfile");
const LONGNAMES_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-longnames/smb.conf");
const LONGNAMES_POPULATE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-longnames/populate.sh");
// smb-consumer-deepnest
const DEEPNEST_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-deepnest/Dockerfile");
const DEEPNEST_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-deepnest/smb.conf");
const DEEPNEST_POPULATE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-deepnest/populate.sh");
// smb-consumer-manyfiles
const MANYFILES_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-manyfiles/Dockerfile");
const MANYFILES_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-manyfiles/smb.conf");
// smb-consumer-readonly
const READONLY_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-readonly/Dockerfile");
const READONLY_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-readonly/smb.conf");
// smb-consumer-windows
const WINDOWS_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-windows/Dockerfile");
const WINDOWS_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-windows/smb.conf");
// smb-consumer-synology
const SYNOLOGY_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-synology/Dockerfile");
const SYNOLOGY_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-synology/smb.conf");
// smb-consumer-linux
const LINUX_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-linux/Dockerfile");
const LINUX_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-linux/smb.conf");
// smb-consumer-flaky
const FLAKY_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-flaky/Dockerfile");
const FLAKY_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-flaky/smb.conf");
const FLAKY_CYCLE: &str = include_str!("../../tests/docker/consumer/smb-consumer-flaky/cycle.sh");
// smb-consumer-slow
const SLOW_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-slow/Dockerfile");
const SLOW_SMB_CONF: &str = include_str!("../../tests/docker/consumer/smb-consumer-slow/smb.conf");
const SLOW_ENTRYPOINT: &str =
include_str!("../../tests/docker/consumer/smb-consumer-slow/entrypoint.sh");
// smb-consumer-maxreadsize
const MAXREADSIZE_DOCKERFILE: &str =
include_str!("../../tests/docker/consumer/smb-consumer-maxreadsize/Dockerfile");
const MAXREADSIZE_SMB_CONF: &str =
include_str!("../../tests/docker/consumer/smb-consumer-maxreadsize/smb.conf");
// ── Embedded file manifest ──────────────────────────────────────────────
/// A file to write into the compose directory.
struct EmbeddedFile {
/// Path relative to the compose directory root.
relative_path: &'static str,
/// File contents.
contents: &'static str,
/// Whether the file should be executable (shell scripts).
executable: bool,
}
/// All files needed to reproduce the consumer Docker infrastructure.
fn embedded_files() -> Vec<EmbeddedFile> {
vec![
EmbeddedFile {
relative_path: "docker-compose.yml",
contents: COMPOSE_YML,
executable: false,
},
// guest
EmbeddedFile {
relative_path: "smb-consumer-guest/Dockerfile",
contents: GUEST_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-guest/smb.conf",
contents: GUEST_SMB_CONF,
executable: false,
},
// auth
EmbeddedFile {
relative_path: "smb-consumer-auth/Dockerfile",
contents: AUTH_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-auth/smb.conf",
contents: AUTH_SMB_CONF,
executable: false,
},
// both
EmbeddedFile {
relative_path: "smb-consumer-both/Dockerfile",
contents: BOTH_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-both/smb.conf",
contents: BOTH_SMB_CONF,
executable: false,
},
// 50shares
EmbeddedFile {
relative_path: "smb-consumer-50shares/Dockerfile",
contents: SHARES50_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-50shares/smb.conf",
contents: SHARES50_SMB_CONF,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-50shares/generate-conf.sh",
contents: SHARES50_GENERATE_CONF,
executable: true,
},
// unicode
EmbeddedFile {
relative_path: "smb-consumer-unicode/Dockerfile",
contents: UNICODE_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-unicode/smb.conf",
contents: UNICODE_SMB_CONF,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-unicode/populate.sh",
contents: UNICODE_POPULATE,
executable: true,
},
// longnames
EmbeddedFile {
relative_path: "smb-consumer-longnames/Dockerfile",
contents: LONGNAMES_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-longnames/smb.conf",
contents: LONGNAMES_SMB_CONF,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-longnames/populate.sh",
contents: LONGNAMES_POPULATE,
executable: true,
},
// deepnest
EmbeddedFile {
relative_path: "smb-consumer-deepnest/Dockerfile",
contents: DEEPNEST_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-deepnest/smb.conf",
contents: DEEPNEST_SMB_CONF,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-deepnest/populate.sh",
contents: DEEPNEST_POPULATE,
executable: true,
},
// manyfiles
EmbeddedFile {
relative_path: "smb-consumer-manyfiles/Dockerfile",
contents: MANYFILES_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-manyfiles/smb.conf",
contents: MANYFILES_SMB_CONF,
executable: false,
},
// readonly
EmbeddedFile {
relative_path: "smb-consumer-readonly/Dockerfile",
contents: READONLY_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-readonly/smb.conf",
contents: READONLY_SMB_CONF,
executable: false,
},
// windows
EmbeddedFile {
relative_path: "smb-consumer-windows/Dockerfile",
contents: WINDOWS_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-windows/smb.conf",
contents: WINDOWS_SMB_CONF,
executable: false,
},
// synology
EmbeddedFile {
relative_path: "smb-consumer-synology/Dockerfile",
contents: SYNOLOGY_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-synology/smb.conf",
contents: SYNOLOGY_SMB_CONF,
executable: false,
},
// linux
EmbeddedFile {
relative_path: "smb-consumer-linux/Dockerfile",
contents: LINUX_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-linux/smb.conf",
contents: LINUX_SMB_CONF,
executable: false,
},
// flaky
EmbeddedFile {
relative_path: "smb-consumer-flaky/Dockerfile",
contents: FLAKY_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-flaky/smb.conf",
contents: FLAKY_SMB_CONF,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-flaky/cycle.sh",
contents: FLAKY_CYCLE,
executable: true,
},
// slow
EmbeddedFile {
relative_path: "smb-consumer-slow/Dockerfile",
contents: SLOW_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-slow/smb.conf",
contents: SLOW_SMB_CONF,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-slow/entrypoint.sh",
contents: SLOW_ENTRYPOINT,
executable: true,
},
// maxreadsize
EmbeddedFile {
relative_path: "smb-consumer-maxreadsize/Dockerfile",
contents: MAXREADSIZE_DOCKERFILE,
executable: false,
},
EmbeddedFile {
relative_path: "smb-consumer-maxreadsize/smb.conf",
contents: MAXREADSIZE_SMB_CONF,
executable: false,
},
]
}
// ── File writing ────────────────────────────────────────────────────────
/// Write all embedded Docker files to the given directory.
///
/// Creates the directory structure Docker Compose expects:
///
/// ```text
/// <dir>/
/// docker-compose.yml
/// smb-consumer-guest/
/// Dockerfile
/// smb.conf
/// smb-consumer-auth/
/// Dockerfile
/// smb.conf
/// ...
/// ```
///
/// Use this for Layer 2 (E2E tests) or Layer 3 (manual QA) where you
/// run `docker compose up` outside of Rust.
pub fn write_compose_files(dir: &Path) -> Result<()> {
let files = embedded_files();
for file in &files {
let path = dir.join(file.relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(Error::Io)?;
}
fs::write(&path, file.contents).map_err(Error::Io)?;
#[cfg(unix)]
if file.executable {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
fs::set_permissions(&path, perms).map_err(Error::Io)?;
}
}
debug!("wrote {} embedded files to {}", files.len(), dir.display());
Ok(())
}
// ── Profile ─────────────────────────────────────────────────────────────
/// Which containers to start.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Profile {
/// Guest + auth only (fast startup).
Minimal,
/// All 14 containers.
All,
}
impl Profile {
/// Service names for `docker compose up`.
fn services(self) -> &'static [&'static str] {
match self {
Profile::Minimal => &["smb-consumer-guest", "smb-consumer-auth"],
Profile::All => &[
"smb-consumer-guest",
"smb-consumer-auth",
"smb-consumer-both",
"smb-consumer-50shares",
"smb-consumer-unicode",
"smb-consumer-longnames",
"smb-consumer-deepnest",
"smb-consumer-manyfiles",
"smb-consumer-readonly",
"smb-consumer-windows",
"smb-consumer-synology",
"smb-consumer-linux",
"smb-consumer-flaky",
"smb-consumer-slow",
],
}
}
}
// ── TestServers ─────────────────────────────────────────────────────────
/// Docker-based SMB test servers for integration testing.
///
/// Starts Samba containers on construction, stops on drop. Each server
/// type has a factory method returning a connected [`SmbClient`].
///
/// Consumers can also skip `TestServers` entirely and use the compose
/// files directly for E2E or manual testing via [`write_compose_files`].
pub struct TestServers {
compose_dir: PathBuf,
profile: Profile,
}
impl TestServers {
/// Start the minimal set: guest + auth containers.
///
/// This is the fastest option (~2 seconds). Use [`start_all`](Self::start_all)
/// if you need all 14 containers.
pub async fn start() -> Result<Self> {
let servers = Self::prepare(Profile::Minimal)?;
servers.compose_up()?;
servers.wait_healthy()?;
Ok(servers)
}
/// Start all 14 consumer containers.
pub async fn start_all() -> Result<Self> {
let servers = Self::prepare(Profile::All)?;
servers.compose_up()?;
servers.wait_healthy()?;
Ok(servers)
}
/// Blocking version of [`start_all`](Self::start_all) for use in
/// [`LazyLock`](std::sync::LazyLock) statics.
///
/// # Example
///
/// ```rust,no_run
/// use std::sync::LazyLock;
/// use smb2::testing::TestServers;
///
/// static SERVERS: LazyLock<TestServers> = LazyLock::new(|| {
/// TestServers::start_blocking().unwrap()
/// });
/// ```
pub fn start_blocking() -> Result<Self> {
let servers = Self::prepare(Profile::All)?;
servers.compose_up()?;
servers.wait_healthy()?;
Ok(servers)
}
/// Guest-access server. No credentials needed.
pub async fn guest_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-guest")?;
let addr = format!("127.0.0.1:{}", guest_port());
connect_guest(&addr).await
}
/// Auth-required server. Needs username and password.
pub async fn auth_client(&self, user: &str, pass: &str) -> Result<SmbClient> {
self.require_service("smb-consumer-auth")?;
let addr = format!("127.0.0.1:{}", auth_port());
connect_auth(&addr, user, pass).await
}
/// Mixed server, guest connection. Can access the "public" share only.
pub async fn both_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-both")?;
let addr = format!("127.0.0.1:{}", both_port());
connect_guest(&addr).await
}
/// Mixed server, authenticated connection. Can access both "public"
/// and "private" shares.
pub async fn both_client_auth(&self, user: &str, pass: &str) -> Result<SmbClient> {
self.require_service("smb-consumer-both")?;
let addr = format!("127.0.0.1:{}", both_port());
connect_auth(&addr, user, pass).await
}
/// Read-only server. Writes return errors.
pub async fn readonly_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-readonly")?;
let addr = format!("127.0.0.1:{}", readonly_port());
connect_guest(&addr).await
}
/// Server with 50 shares for testing share enumeration at scale.
pub async fn many_shares_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-50shares")?;
let addr = format!("127.0.0.1:{}", many_shares_port());
connect_guest(&addr).await
}
/// Server with unicode share and file names (CJK, emoji, accented characters).
pub async fn unicode_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-unicode")?;
let addr = format!("127.0.0.1:{}", unicode_port());
connect_guest(&addr).await
}
/// Server with 200+ character filenames. Tests path truncation.
pub async fn longnames_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-longnames")?;
let addr = format!("127.0.0.1:{}", longnames_port());
connect_guest(&addr).await
}
/// Server with 50-level deep directory tree. Tests navigation overflow.
pub async fn deepnest_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-deepnest")?;
let addr = format!("127.0.0.1:{}", deepnest_port());
connect_guest(&addr).await
}
/// Server with 10,000+ files in one directory.
pub async fn many_files_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-manyfiles")?;
let addr = format!("127.0.0.1:{}", manyfiles_port());
connect_guest(&addr).await
}
/// Windows-like server (server string in smb.conf). Tests OS detection.
pub async fn windows_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-windows")?;
let addr = format!("127.0.0.1:{}", windows_port());
connect_guest(&addr).await
}
/// Synology-like server (server string in smb.conf). Tests NAS-specific UI.
pub async fn synology_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-synology")?;
let addr = format!("127.0.0.1:{}", synology_port());
connect_guest(&addr).await
}
/// Generic Linux Samba server. Most common real-world server type.
pub async fn linux_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-linux")?;
let addr = format!("127.0.0.1:{}", linux_port());
connect_guest(&addr).await
}
/// Flaky server (5 seconds up, 5 seconds down). Tests error recovery UI.
pub async fn flaky_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-flaky")?;
let addr = format!("127.0.0.1:{}", flaky_port());
connect_guest(&addr).await
}
/// Slow server (200ms latency). Tests loading states and timeouts.
pub async fn slow_client(&self) -> Result<SmbClient> {
self.require_service("smb-consumer-slow")?;
let addr = format!("127.0.0.1:{}", slow_port());
connect_guest(&addr).await
}
// ── Internal helpers ────────────────────────────────────────────
/// Create a temp directory, write embedded files, return the struct.
fn prepare(profile: Profile) -> Result<Self> {
let compose_dir = std::env::temp_dir().join(format!("smb2-testing-{}", std::process::id()));
write_compose_files(&compose_dir)?;
info!("prepared compose files in {}", compose_dir.display());
Ok(Self {
compose_dir,
profile,
})
}
/// Run `docker compose up` for the selected profile.
fn compose_up(&self) -> Result<()> {
let services = self.profile.services();
info!("starting {} container(s)", services.len());
let mut cmd = Command::new("docker");
cmd.arg("compose")
.arg("-f")
.arg(self.compose_dir.join("docker-compose.yml"))
.arg("up")
.arg("-d")
.arg("--build");
for svc in services {
cmd.arg(svc);
}
debug!("running: {:?}", cmd);
let output = cmd.output().map_err(Error::Docker)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("docker compose up stderr: {stderr}");
return Err(Error::Docker(std::io::Error::other(format!(
"docker compose up failed: {stderr}"
))));
}
Ok(())
}
/// Wait for all started containers to pass Docker health checks.
fn wait_healthy(&self) -> Result<()> {
let services = self.profile.services();
let timeout = Duration::from_secs(30);
let poll_interval = Duration::from_millis(500);
let start = std::time::Instant::now();
for service in services {
// Skip health check for flaky container (it intentionally cycles).
if *service == "smb-consumer-flaky" {
debug!("skipping health check for {service} (intentionally flaky)");
continue;
}
loop {
if start.elapsed() > timeout {
return Err(Error::HealthCheckTimeout {
container: service.to_string(),
});
}
let output = Command::new("docker")
.arg("compose")
.arg("-f")
.arg(self.compose_dir.join("docker-compose.yml"))
.arg("ps")
.arg("--format")
.arg("{{.Health}}")
.arg(service)
.output()
.map_err(Error::Docker)?;
let status = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
if status.contains("healthy") {
debug!("{service} is healthy");
break;
}
debug!("{service} health: {status:?}, waiting...");
std::thread::sleep(poll_interval);
}
}
info!("all containers healthy");
Ok(())
}
/// Check that a service is part of the current profile.
fn require_service(&self, service: &str) -> Result<()> {
if self.profile.services().contains(&service) {
Ok(())
} else {
Err(Error::ContainerNotStarted {
container: service.to_string(),
hint: "call start_all() to start all containers".to_string(),
})
}
}
/// Run `docker compose down` (best-effort).
fn compose_down(&self) {
debug!("stopping containers in {}", self.compose_dir.display());
let result = Command::new("docker")
.arg("compose")
.arg("-f")
.arg(self.compose_dir.join("docker-compose.yml"))
.arg("down")
.arg("--timeout")
.arg("5")
.output();
match result {
Ok(output) if output.status.success() => {
info!("containers stopped");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("docker compose down stderr: {stderr}");
}
Err(e) => {
debug!("failed to run docker compose down: {e}");
}
}
}
/// Clean up the temp directory (best-effort).
fn cleanup_dir(&self) {
if self.compose_dir.exists() {
if let Err(e) = fs::remove_dir_all(&self.compose_dir) {
debug!("failed to clean up {}: {e}", self.compose_dir.display());
}
}
}
}
impl Drop for TestServers {
fn drop(&mut self) {
self.compose_down();
self.cleanup_dir();
}
}
// ── Connection helpers ──────────────────────────────────────────────────
async fn connect_guest(addr: &str) -> Result<SmbClient> {
SmbClient::connect(ClientConfig {
addr: addr.to_string(),
timeout: Duration::from_secs(10),
username: String::new(),
password: String::new(),
domain: String::new(),
auto_reconnect: false,
compression: true,
dfs_enabled: false,
dfs_target_overrides: std::collections::HashMap::new(),
})
.await
.map_err(Error::Smb)
}
async fn connect_auth(addr: &str, user: &str, pass: &str) -> Result<SmbClient> {
SmbClient::connect(ClientConfig {
addr: addr.to_string(),
timeout: Duration::from_secs(10),
username: user.to_string(),
password: pass.to_string(),
domain: String::new(),
auto_reconnect: false,
compression: true,
dfs_enabled: false,
dfs_target_overrides: std::collections::HashMap::new(),
})
.await
.map_err(Error::Smb)
}
// ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── Port resolution ─────────────────────────────────────────────
#[test]
fn port_returns_default_when_env_unset() {
// Use a unique env var name that won't collide with real env.
let val = port("SMB2_TEST_NONEXISTENT_PORT_12345", 9999);
assert_eq!(val, 9999);
}
#[test]
fn port_returns_env_value_when_set() {
let key = "SMB2_TEST_PORT_OVERRIDE_CHECK";
std::env::set_var(key, "12345");
let val = port(key, 9999);
std::env::remove_var(key);
assert_eq!(val, 12345);
}
#[test]
fn port_returns_default_for_non_numeric_env() {
let key = "SMB2_TEST_PORT_BAD_VALUE";
std::env::set_var(key, "not_a_number");
let val = port(key, 7777);
std::env::remove_var(key);
assert_eq!(val, 7777);
}
#[test]
fn port_returns_default_for_empty_env() {
let key = "SMB2_TEST_PORT_EMPTY";
std::env::set_var(key, "");
let val = port(key, 5555);
std::env::remove_var(key);
assert_eq!(val, 5555);
}
// ── Default port values ─────────────────────────────────────────
#[test]
fn default_ports_are_in_consumer_range() {
let ports = [
DEFAULT_GUEST_PORT,
DEFAULT_AUTH_PORT,
DEFAULT_BOTH_PORT,
DEFAULT_50SHARES_PORT,
DEFAULT_UNICODE_PORT,
DEFAULT_LONGNAMES_PORT,
DEFAULT_DEEPNEST_PORT,
DEFAULT_MANYFILES_PORT,
DEFAULT_READONLY_PORT,
DEFAULT_WINDOWS_PORT,
DEFAULT_SYNOLOGY_PORT,
DEFAULT_LINUX_PORT,
DEFAULT_FLAKY_PORT,
DEFAULT_SLOW_PORT,
];
for p in ports {
assert!(
(10480..=10493).contains(&p),
"port {p} outside expected range 10480-10493"
);
}
}
#[test]
fn default_ports_are_unique() {
let ports = [
DEFAULT_GUEST_PORT,
DEFAULT_AUTH_PORT,
DEFAULT_BOTH_PORT,
DEFAULT_50SHARES_PORT,
DEFAULT_UNICODE_PORT,
DEFAULT_LONGNAMES_PORT,
DEFAULT_DEEPNEST_PORT,
DEFAULT_MANYFILES_PORT,
DEFAULT_READONLY_PORT,
DEFAULT_WINDOWS_PORT,
DEFAULT_SYNOLOGY_PORT,
DEFAULT_LINUX_PORT,
DEFAULT_FLAKY_PORT,
DEFAULT_SLOW_PORT,
];
let mut seen = std::collections::HashSet::new();
for p in ports {
assert!(seen.insert(p), "duplicate port: {p}");
}
}
// ── Error formatting ────────────────────────────────────────────
#[test]
fn error_display_docker() {
let err = Error::Docker(std::io::Error::new(
std::io::ErrorKind::NotFound,
"docker not found",
));
let msg = err.to_string();
assert!(msg.contains("docker command failed"), "got: {msg}");
assert!(msg.contains("docker not found"), "got: {msg}");
}
#[test]
fn error_display_health_check_timeout() {
let err = Error::HealthCheckTimeout {
container: "smb-consumer-guest".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("health check timed out"), "got: {msg}");
assert!(msg.contains("smb-consumer-guest"), "got: {msg}");
}
#[test]
fn error_display_container_not_started() {
let err = Error::ContainerNotStarted {
container: "smb-consumer-unicode".to_string(),
hint: "call start_all()".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("container not started"), "got: {msg}");
assert!(msg.contains("smb-consumer-unicode"), "got: {msg}");
assert!(msg.contains("start_all()"), "got: {msg}");
}
#[test]
fn error_display_io() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"permission denied",
));
let msg = err.to_string();
assert!(msg.contains("write compose files"), "got: {msg}");
}
#[test]
fn error_debug_is_implemented() {
let err = Error::HealthCheckTimeout {
container: "test".to_string(),
};
// Just verify Debug doesn't panic.
let _ = format!("{err:?}");
}
// ── write_compose_files ─────────────────────────────────────────
#[test]
fn write_compose_files_creates_expected_structure() {
let dir = std::env::temp_dir().join(format!("smb2-test-write-{}", std::process::id()));
// Clean up from any previous run.
let _ = fs::remove_dir_all(&dir);
write_compose_files(&dir).unwrap();
// Verify top-level compose file.
assert!(dir.join("docker-compose.yml").exists());
// Verify all 15 container directories exist with Dockerfiles.
let containers = [
"smb-consumer-guest",
"smb-consumer-auth",
"smb-consumer-both",
"smb-consumer-50shares",
"smb-consumer-unicode",
"smb-consumer-longnames",
"smb-consumer-deepnest",
"smb-consumer-manyfiles",
"smb-consumer-readonly",
"smb-consumer-windows",
"smb-consumer-synology",
"smb-consumer-linux",
"smb-consumer-flaky",
"smb-consumer-slow",
"smb-consumer-maxreadsize",
];
for name in containers {
let dockerfile = dir.join(name).join("Dockerfile");
assert!(dockerfile.exists(), "missing Dockerfile for {name}");
let smb_conf = dir.join(name).join("smb.conf");
assert!(smb_conf.exists(), "missing smb.conf for {name}");
}
// Verify extra scripts exist.
assert!(dir.join("smb-consumer-50shares/generate-conf.sh").exists());
assert!(dir.join("smb-consumer-unicode/populate.sh").exists());
assert!(dir.join("smb-consumer-longnames/populate.sh").exists());
assert!(dir.join("smb-consumer-deepnest/populate.sh").exists());
assert!(dir.join("smb-consumer-flaky/cycle.sh").exists());
assert!(dir.join("smb-consumer-slow/entrypoint.sh").exists());
// Clean up.
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_compose_files_content_matches_embedded() {
let dir = std::env::temp_dir().join(format!("smb2-test-content-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
write_compose_files(&dir).unwrap();
let compose = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
compose.contains("smb-consumer-guest"),
"compose file should reference guest service"
);
assert!(
compose.contains("10480"),
"compose file should contain default guest port"
);
let guest_conf = fs::read_to_string(dir.join("smb-consumer-guest/smb.conf")).unwrap();
assert!(
guest_conf.contains("[public]"),
"guest smb.conf should have [public] share"
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_compose_files_scripts_are_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("smb2-test-exec-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
write_compose_files(&dir).unwrap();
let scripts = [
"smb-consumer-50shares/generate-conf.sh",
"smb-consumer-unicode/populate.sh",
"smb-consumer-longnames/populate.sh",
"smb-consumer-deepnest/populate.sh",
"smb-consumer-flaky/cycle.sh",
"smb-consumer-slow/entrypoint.sh",
];
for script in scripts {
let path = dir.join(script);
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert!(
mode & 0o111 != 0,
"{script} should be executable (mode: {mode:#o})"
);
}
let _ = fs::remove_dir_all(&dir);
}
// ── Profile / require_service ───────────────────────────────────
#[test]
fn minimal_profile_includes_guest_and_auth() {
let services = Profile::Minimal.services();
assert!(services.contains(&"smb-consumer-guest"));
assert!(services.contains(&"smb-consumer-auth"));
assert_eq!(services.len(), 2);
}
#[test]
fn all_profile_includes_14_services() {
let services = Profile::All.services();
assert_eq!(services.len(), 14);
}
#[test]
fn require_service_ok_for_minimal_profile() {
let servers = TestServers {
compose_dir: PathBuf::from("/tmp/fake"),
profile: Profile::Minimal,
};
assert!(servers.require_service("smb-consumer-guest").is_ok());
assert!(servers.require_service("smb-consumer-auth").is_ok());
}
#[test]
fn require_service_fails_for_non_minimal_container() {
let servers = TestServers {
compose_dir: PathBuf::from("/tmp/fake"),
profile: Profile::Minimal,
};
let err = servers.require_service("smb-consumer-unicode").unwrap_err();
match err {
Error::ContainerNotStarted { container, hint } => {
assert_eq!(container, "smb-consumer-unicode");
assert!(hint.contains("start_all()"));
}
other => panic!("expected ContainerNotStarted, got: {other:?}"),
}
}
#[test]
fn require_service_ok_for_all_profile() {
let servers = TestServers {
compose_dir: PathBuf::from("/tmp/fake"),
profile: Profile::All,
};
// Should succeed for every container.
for svc in Profile::All.services() {
assert!(
servers.require_service(svc).is_ok(),
"require_service failed for {svc}"
);
}
}
// ── Embedded file count ─────────────────────────────────────────
#[test]
fn embedded_files_count() {
let files = embedded_files();
// 1 compose + 14 containers * (Dockerfile + smb.conf) = 29
// + 6 extra scripts = 35
assert_eq!(files.len(), 35, "expected 35 embedded files");
}
#[test]
fn embedded_files_no_empty_contents() {
for file in embedded_files() {
assert!(
!file.contents.is_empty(),
"embedded file {} has empty contents",
file.relative_path
);
}
}
}