smolder 0.3.0

High-level SMB workflows, CLI tools, and remote execution for Smolder
Documentation
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use smolder_core::error::CoreError;
use smolder_tools::prelude::{NtlmCredentials, Share, SmbClient};
use tokio::sync::Mutex;

fn required_env(name: &str) -> Option<String> {
    std::env::var(name).ok().filter(|value| !value.is_empty())
}

#[derive(Debug, Clone)]
struct WindowsEncryptionConfig {
    host: String,
    port: u16,
    username: String,
    password: String,
    share: String,
    test_dir: String,
    domain: Option<String>,
    workstation: Option<String>,
}

impl WindowsEncryptionConfig {
    fn base_from_env() -> Option<Self> {
        Some(Self {
            host: required_env("SMOLDER_WINDOWS_HOST")?,
            port: required_env("SMOLDER_WINDOWS_PORT")
                .and_then(|value| value.parse::<u16>().ok())
                .unwrap_or(445),
            username: required_env("SMOLDER_WINDOWS_USERNAME")?,
            password: required_env("SMOLDER_WINDOWS_PASSWORD")?,
            share: String::new(),
            test_dir: required_env("SMOLDER_WINDOWS_ENCRYPTED_TEST_DIR").unwrap_or_default(),
            domain: required_env("SMOLDER_WINDOWS_DOMAIN"),
            workstation: required_env("SMOLDER_WINDOWS_WORKSTATION"),
        })
    }

    fn from_env() -> Option<Self> {
        let mut config = Self::base_from_env()?;
        config.share = required_env("SMOLDER_WINDOWS_ENCRYPTED_SHARE")?;
        Some(config)
    }

    fn admin_share_probe_from_env() -> Option<Self> {
        let mut config = Self::base_from_env()?;
        config.share = "ADMIN$".to_string();
        config.test_dir.clear();
        Some(config)
    }
}

fn windows_lock() -> &'static Mutex<()> {
    static LOCK: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn unique_path(prefix: &str, test_dir: &str) -> String {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::from_secs(0))
        .as_nanos();
    let file_name = format!("{prefix}-{}-{stamp}.txt", std::process::id());
    if test_dir.trim_matches(['\\', '/']).is_empty() {
        file_name
    } else {
        format!("{}\\{file_name}", test_dir.trim_matches(['\\', '/']))
    }
}

async fn connect_share(
    config: &WindowsEncryptionConfig,
    require_encryption: bool,
) -> Result<Share, CoreError> {
    let mut credentials = NtlmCredentials::new(config.username.clone(), config.password.clone());
    if let Some(domain) = &config.domain {
        credentials = credentials.with_domain(domain.clone());
    }
    if let Some(workstation) = &config.workstation {
        credentials = credentials.with_workstation(workstation.clone());
    }

    let mut builder = SmbClient::builder()
        .server(config.host.clone())
        .port(config.port)
        .credentials(credentials);
    if require_encryption {
        builder = builder.require_encryption(true);
    }

    let client = builder.connect().await?;
    client.share(config.share.clone()).await
}

async fn connected_share() -> Option<(WindowsEncryptionConfig, Share)> {
    let Some(config) = WindowsEncryptionConfig::from_env() else {
        eprintln!(
            "skipping encrypted Windows test: SMOLDER_WINDOWS_HOST, SMOLDER_WINDOWS_USERNAME, SMOLDER_WINDOWS_PASSWORD, and SMOLDER_WINDOWS_ENCRYPTED_SHARE must be set"
        );
        return None;
    };
    let share = connect_share(&config, true)
        .await
        .expect("should connect encrypted Windows share");

    Some((config, share))
}

#[tokio::test]
async fn writes_and_reads_with_required_encryption_when_configured() {
    let _guard = windows_lock().lock().await;
    let Some((config, mut share)) = connected_share().await else {
        return;
    };

    let remote_path = unique_path("smolder-win-encrypted", &config.test_dir);
    let payload = b"smolder windows encrypted io";

    share
        .write(&remote_path, payload)
        .await
        .expect("encrypted Windows write should succeed");
    let round_trip = share
        .read(&remote_path)
        .await
        .expect("encrypted Windows read should succeed");
    share
        .remove(&remote_path)
        .await
        .expect("encrypted Windows remove should succeed");

    assert_eq!(round_trip, payload);
}

#[tokio::test]
async fn require_encryption_rejects_admin_share_when_configured() {
    let _guard = windows_lock().lock().await;
    let Some(config) = WindowsEncryptionConfig::admin_share_probe_from_env() else {
        eprintln!(
            "skipping Windows encryption enforcement test: SMOLDER_WINDOWS_HOST, SMOLDER_WINDOWS_USERNAME, and SMOLDER_WINDOWS_PASSWORD must be set"
        );
        return;
    };

    let admin_share = connect_share(&config, false)
        .await
        .expect("ADMIN$ should allow a baseline connection probe");
    if admin_share.encryption_required() {
        eprintln!(
            "skipping Windows encryption enforcement test: ADMIN$ already requires encryption on this fixture"
        );
        return;
    }

    let error = connect_share(&config, true)
        .await
        .expect_err("ADMIN$ should be rejected when encryption is required");
    assert!(
        matches!(
            error,
            CoreError::Unsupported(
                "SMB encryption was required but the connected share did not require encryption"
            )
        ),
        "unexpected error: {error:?}"
    );
}