Skip to main content

smb_server_testsuite/
lib.rs

1//! Conformance/interoperability test harness for the smb-server-rs server.
2//!
3//! The test *plan* mirrors Microsoft's WindowsProtocolTestSuites FileServer
4//! family (MS-SMB2, MS-FSCC, MS-FSA, MS-DFSC, MS-SWN) at the category level.
5//! Assertions live here in Rust; the actual SMB traffic is generated by a
6//! small Python `smbprotocol` driver (`test/driver/smb_driver.py`) invoked per
7//! call. This keeps the tests in idiomatic Rust while reusing a mature client.
8//!
9//! Two entry points share one case registry:
10//! - `cargo test -p smb-server-testsuite` — interactive, via [`tests/conformance.rs`].
11//! - `smb-testrunner` binary — automated, records results under `test/data/`.
12
13use std::collections::BTreeMap;
14use std::io::Write;
15use std::process::{Command, Stdio};
16use std::time::Instant;
17
18use serde::{Deserialize, Serialize};
19
20pub mod cases;
21pub mod recorder;
22
23/// Default SMB TCP port used when neither env nor CLI supplies one.
24pub const DEFAULT_PORT: u16 = 445;
25
26/// Metrics a case may emit (e.g. throughput MB/s), keyed by name.
27pub type Metrics = BTreeMap<String, f64>;
28
29/// The SMB server under test.
30#[derive(Clone, Debug)]
31pub struct Endpoint {
32    /// Server hostname or IP.
33    pub host: String,
34    /// TCP port (445 by default).
35    pub port: u16,
36    /// Username for authentication.
37    pub user: String,
38    /// Password for authentication.
39    pub pass: String,
40    /// Share name to connect to.
41    pub share: String,
42}
43
44impl Endpoint {
45    /// Read the endpoint from `SMB_TEST_HOST/PORT/USER/PASS/SHARE`. Returns
46    /// `None` when unset so `cargo test` can skip cleanly off a live server.
47    pub fn from_env() -> Option<Endpoint> {
48        let host = non_empty(std::env::var("SMB_TEST_HOST").ok())?;
49        Some(Endpoint {
50            host,
51            port: std::env::var("SMB_TEST_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT),
52            user: std::env::var("SMB_TEST_USER").unwrap_or_default(),
53            pass: std::env::var("SMB_TEST_PASS").unwrap_or_default(),
54            share: std::env::var("SMB_TEST_SHARE").unwrap_or_else(|_| "public".into()),
55        })
56    }
57}
58
59fn non_empty(v: Option<String>) -> Option<String> {
60    v.filter(|s| !s.is_empty())
61}
62
63/// Per-call connection options (dialect pin, signing, encryption).
64#[derive(Default, Clone, Debug)]
65pub struct Opts {
66    /// Require SMB2 signing on the connection.
67    pub sign: bool,
68    /// Require SMB3 encryption on the connection.
69    pub encrypt: bool,
70    /// Pin a specific dialect (e.g. `"3.1.1"`), else negotiate the highest.
71    pub dialect: Option<String>,
72}
73
74impl Opts {
75    /// Options pinning a specific dialect.
76    pub fn dialect(d: &str) -> Opts {
77        Opts { dialect: Some(d.to_string()), ..Opts::default() }
78    }
79    /// Options requiring signing.
80    pub fn signed() -> Opts {
81        Opts { sign: true, ..Opts::default() }
82    }
83    /// Options requiring encryption.
84    pub fn encrypted() -> Opts {
85        Opts { encrypt: true, ..Opts::default() }
86    }
87}
88
89/// Locates the Python interpreter and driver script.
90#[derive(Clone, Debug)]
91pub struct Driver {
92    /// Path to the Python interpreter.
93    pub python: String,
94    /// Path to the `smb_driver.py` script.
95    pub script: String,
96}
97
98impl Driver {
99    /// Resolve from `SMB_TEST_PYTHON` / `SMB_TEST_DRIVER`. The driver default
100    /// is resolved from the crate location (not the cwd) so it works both from
101    /// the workspace root (`smb-testrunner`) and the crate dir (`cargo test`).
102    pub fn from_env() -> Driver {
103        Driver {
104            python: std::env::var("SMB_TEST_PYTHON")
105                .unwrap_or_else(|_| "/tmp/smbtest_venv/bin/python".into()),
106            script: std::env::var("SMB_TEST_DRIVER").unwrap_or_else(|_| default_driver_script()),
107        }
108    }
109}
110
111/// `<workspace>/test/driver/smb_driver.py`, derived from this crate's path.
112fn default_driver_script() -> String {
113    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
114        .join("../../test/driver/smb_driver.py")
115        .to_string_lossy()
116        .into_owned()
117}
118
119/// Response from one driver invocation.
120#[derive(Deserialize, Debug, Default)]
121pub struct DriverResp {
122    /// Whether every op in the invocation succeeded.
123    pub ok: bool,
124    /// Failure message when `ok` is false.
125    #[serde(default)]
126    pub error: Option<String>,
127    /// Dialect the driver negotiated.
128    #[serde(default)]
129    pub dialect: String,
130    /// Per-op result JSON, in request order.
131    #[serde(default)]
132    pub steps: Vec<serde_json::Value>,
133}
134
135impl DriverResp {
136    /// The result JSON of the `idx`-th op, if it ran.
137    pub fn step(&self, idx: usize) -> Option<&serde_json::Value> {
138        self.steps.get(idx)
139    }
140}
141
142#[derive(Serialize)]
143struct DriverReq<'a> {
144    endpoint: EndpointReq<'a>,
145    ops: serde_json::Value,
146}
147
148#[derive(Serialize)]
149struct EndpointReq<'a> {
150    host: &'a str,
151    port: u16,
152    user: &'a str,
153    pass: &'a str,
154    share: &'a str,
155    sign: bool,
156    encrypt: bool,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    dialect: Option<&'a str>,
159}
160
161/// Context handed to every case: the endpoint plus a way to run SMB ops.
162pub struct Ctx<'a> {
163    /// The server under test.
164    pub ep: &'a Endpoint,
165    /// The Python driver used to run SMB ops.
166    pub driver: &'a Driver,
167}
168
169impl<'a> Ctx<'a> {
170    /// Run a sequence of ops against the server with default options.
171    pub fn run(&self, ops: serde_json::Value) -> Result<DriverResp, String> {
172        self.run_with(&Opts::default(), ops)
173    }
174
175    /// Run a sequence of ops with explicit connection options.
176    pub fn run_with(&self, opts: &Opts, ops: serde_json::Value) -> Result<DriverResp, String> {
177        let req = DriverReq {
178            endpoint: EndpointReq {
179                host: &self.ep.host,
180                port: self.ep.port,
181                user: &self.ep.user,
182                pass: &self.ep.pass,
183                share: &self.ep.share,
184                sign: opts.sign,
185                encrypt: opts.encrypt,
186                dialect: opts.dialect.as_deref(),
187            },
188            ops,
189        };
190        let payload = serde_json::to_vec(&req).map_err(|e| e.to_string())?;
191        let mut child = Command::new(&self.driver.python)
192            .arg(&self.driver.script)
193            .stdin(Stdio::piped())
194            .stdout(Stdio::piped())
195            .stderr(Stdio::piped())
196            .spawn()
197            .map_err(|e| format!("spawn {} failed: {e}", self.driver.python))?;
198        child
199            .stdin
200            .take()
201            .ok_or("no stdin")?
202            .write_all(&payload)
203            .map_err(|e| e.to_string())?;
204        let out = child.wait_with_output().map_err(|e| e.to_string())?;
205        if !out.status.success() && out.stdout.is_empty() {
206            return Err(format!(
207                "driver exited {}: {}",
208                out.status,
209                String::from_utf8_lossy(&out.stderr).trim()
210            ));
211        }
212        serde_json::from_slice(&out.stdout).map_err(|e| {
213            format!("bad driver output: {e}: {}", String::from_utf8_lossy(&out.stdout))
214        })
215    }
216}
217
218/// One conformance test case.
219pub struct TestCase {
220    /// Stable identifier, e.g. `create.overwrite_if`.
221    pub id: &'static str,
222    /// Grouping used by the runner and UI, e.g. `Create`.
223    pub category: &'static str,
224    /// Spec / MS-suite reference this mirrors.
225    pub spec: &'static str,
226    /// One-line description.
227    pub about: &'static str,
228    /// Runner: `Ok(metrics)` on pass, `Err(message)` on failure.
229    pub run: fn(&Ctx) -> Result<Metrics, String>,
230}
231
232/// Outcome status for a single case.
233#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
234#[serde(rename_all = "lowercase")]
235pub enum Status {
236    /// The case met its assertions.
237    Pass,
238    /// The case ran but an assertion failed.
239    Fail,
240    /// The case was skipped (precondition unmet).
241    Skip,
242    /// The case aborted unexpectedly (panic / driver error).
243    Error,
244}
245
246/// Recorded result for one case in one run.
247#[derive(Serialize, Deserialize, Clone, Debug)]
248pub struct CaseResult {
249    /// Case identifier.
250    pub id: String,
251    /// Case category (grouping).
252    pub category: String,
253    /// Spec reference the case exercises.
254    pub spec: String,
255    /// One-line description of the case.
256    pub about: String,
257    /// Outcome status.
258    pub status: Status,
259    /// Wall-clock duration in milliseconds.
260    pub duration_ms: u128,
261    /// Failure/skip detail, empty on pass.
262    pub message: String,
263    /// Metrics captured during the run.
264    pub metrics: Metrics,
265}
266
267/// Execute one case, timing it and converting panics into an `Error` status.
268pub fn run_case(case: &TestCase, ctx: &Ctx) -> CaseResult {
269    let started = Instant::now();
270    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (case.run)(ctx)));
271    let duration_ms = started.elapsed().as_millis();
272    let (status, message, metrics) = match outcome {
273        Ok(Ok(metrics)) => (Status::Pass, String::new(), metrics),
274        Ok(Err(msg)) => (Status::Fail, msg, Metrics::new()),
275        Err(_) => (Status::Error, "panicked".into(), Metrics::new()),
276    };
277    CaseResult {
278        id: case.id.into(),
279        category: case.category.into(),
280        spec: case.spec.into(),
281        about: case.about.into(),
282        status,
283        duration_ms,
284        message,
285        metrics,
286    }
287}