Replaced the remotefs-smb crate (and its companion remotefs) in the mando-lib workspace with the pure-Rust smb crate v0.11.2 (crates.io/crates/smb, repo github.com/afiffon/smb-rs). The driver was licensing: remotefs-smbpavaopavao-sys FFI-binds the system libsmbclient, and pavao/pavao-sys are GPL-3.0, usable only via dynamic linking — which Alpiq cannot use. smb is pure Rust (no libsmbclient), so it removes the GPL transitive dependency entirely.

Outcome

GPL dependency gone (cargo tree -p mando_lib | grep -iE 'remotefs|pavao' is empty), full cleanup chosen (no dead GPL footprint left), cargo test -p mando_lib = 205 passed / 32 ignored / 0 failed, clippy clean. One open acceptance step remains: live SMB connectivity (see below).

Why — the GPL chain

remotefs-smb ──► pavao ──► pavao-sys ──► (FFI) libsmbclient   [system C lib]
                  └──────────┴─ GPL-3.0, dynamic-link-only  ✗ unusable at Alpiq

smb 0.11.2 is a pure-Rust SMB3 implementation — no libsmbclient, no system C dependency, no GPL. The conscious tradeoff for going pure-Rust is a large dependency tree with pre-release crypto (see gotcha 2).

Blast radius — exactly ONE call site

SMB is used in a single place: the Alpiq EBS (Energy Balance System) adapter, mando-lib/src/adapter/alpiq/ebs.rs. It is upload-only — mando generates .xlsx bid-template files and pushes them to an SMB/CIFS share. Service-layer callers (mando-lib/src/service/alpiq/ebs.rs, …/ebs_clear_bids.rs) go through EbsClient::upload_file, whose public signature was preserved, so they needed no changes.

Files changed (full cleanup — no dead GPL footprint)

FileChange
rust-toolchain.tomlchannel 1.88.0 → 1.89.0 (forced by smb MSRV — gotcha 1)
Cargo.toml (workspace)remotefs/remotefs-smb =0.3.1smb = "=0.11.2"
mando-lib/Cargo.tomlremotefs*.workspace = truesmb.workspace = true
mando-lib/src/adapter/alpiq/ebs.rsrewrote SMB upload path; deleted both #[cfg(target_family=…)] create_client builders (pure-Rust ⇒ no unix/windows split)
mando-lib/src/app/mod.rsremoved pavao=off from the default RUST_LOG
Dockerfile + mando-simulator/Dockerfileremoved samba-libs + libsmbclient apt installs (no longer needed)

Docs to update alongside this change

Mando CI-CD still lists samba-libs / libsmbclient under System Dependencies (Linux Runtime), the runtime image comment, and the build image’s “samba dev libs”; Agent Context lists samba-libs in the runtime image and pins Rust 1.88.0. These were accurate pre-migration and should be reconciled (runtime no longer needs the SMB system libs; toolchain is now 1.89.0) — flagged, not yet edited.

smb 0.11.2 API recipe (reusable)

The crate is low-level (vs. remotefs’s high-level RemoteFs trait) and async (tokio). Use default featuressign, encrypt, compress, async, std-fs-impls, netbios-transport — exactly what an SMB3 client needs. Do NOT enable kerberos (pulls reqwest) or quic.

use std::str::FromStr;
use smb::{Client, ClientConfig, UncPath, FileCreateArgs, FileAttributes, CreateOptions, WriteAt};
 
let client = Client::new(ClientConfig::default());
let unc = UncPath::from_str(&format!(r"\\{host}\{share}"))?;
 
// Workgroup is mapped by formatting the username as DOMAIN\user when non-empty.
let user = if workgroup.is_empty() { username.clone() }
           else { format!("{workgroup}\\{username}") };
client.share_connect(&unc, &user, password.clone()).await?;   // &unc — call BEFORE moving unc
 
let path = unc.with_path(&file_path);                          // consumes self
let res  = client.create_file(&path,
    &FileCreateArgs::make_overwrite(FileAttributes::new(), CreateOptions::new())).await?;
let mut file = res.unwrap_file();
 
let mut off: u64 = 0;                                           // write_at returns bytes written
while off < bytes.len() as u64 {
    off += file.write_at(&bytes[off as usize..], off).await? as u64;   // loop for partial writes
}
file.close().await?;

Key facts:

  • Client::new(ClientConfig::default()) — no result, infallible constructor.
  • UncPath::from_str(r"\\host\share") needs use std::str::FromStr.
  • client.share_connect(&unc, user_name: &str, password: String). Auth identity is parsed by sspi::Username::parse, which accepts DOMAIN\user — that’s why a workgroup is just format!("{workgroup}\\{username}").
  • UncPath::with_path(self, &str) for the in-share file path — consumes self, so share_connect(&unc, …) must run before unc is moved.
  • client.create_file(&UncPath, &FileCreateArgs) -> Resource; Resource::unwrap_file() -> File.
  • File::write_at(&[u8], offset: u64) -> usize (loop for partial writes); File::close().

Single-call overwrite collapses the old delete dance

FileCreateArgs::make_overwrite(FileAttributes::new(), CreateOptions::new()) uses disposition OverwriteIf (create-or-truncate). This collapses the old remotefs exists() + remove_file() + create_file() sequence into one create_file call. smb has no explicit delete (deletion is delete-on-close), and none is needed here since the upload always overwrites.

Import gotcha — the trait must be in scope

write_at is a trait method: the smb::WriteAt trait must be imported or the call won’t resolve. FileAttributes / CreateOptions originate in the smb-fscc sub-crate but are re-exported at the smb crate root (pub use smb_fscc::*), so a single use smb::{Client, ClientConfig, UncPath, FileCreateArgs, FileAttributes, CreateOptions, WriteAt}; resolves everything. (Confirmed in ebs.rs:13.)

Gotchas worth flagging (these cost real time)

1 — MSRV blocker forces the toolchain bump

smb 0.11.2 is edition 2024, and it plus all 8 sub-crates (smb-dtyp, smb-fscc, smb-msg, smb-rpc, smb-transport, and the -derive crates) declare rust-version = "1.89.0". On the old 1.88.0 pin, cargo hard-errors: “rustc 1.88.0 is not supported … requires rustc 1.89.0.” Hence the rust-toolchain.toml bump to 1.89.0. Verify the smb MSRV before any future smb upgrade — it may push the pin again.

2 — Big dependency footprint at release-candidate crypto

The swap adds ~295 crates, pulling the full SMB3 signing + encryption stack at RustCrypto release-candidate versions (aes-gcm 0.11.0-rc.1, aead 0.6.0-rc.2, ccm 0.6.0-pre.0, …) plus sspi for NTLM (picky / picky-krb / rsa / curve25519 …). Signing + NTLM are mandatory for SMB3, so this can’t be trimmed much. Conscious tradeoff: pure-Rust + no GPL, at the cost of a large pre-release-crypto tree. Watch these crates settle to stable in future bumps.

3 — py_mando cargo build link failure is a RED HERRING (pre-existing)

cargo build -p py_mando fails at the link step with undefined libpython symbols (__Py_NoneStruct, __Py_TrueStruct, __Py_NotImplementedStruct, _Py_GetVersion) from pyo3_ffi. This is standard pyo3 extension-module cdylib behavior — libpython is intentionally not linked — and is NOT caused by this swap (verified: identical error on the clean baseline; smb itself compiles fine inside py_mando’s dep closure). Build the Python bindings with maturin develop, not plain cargo build. See py-mando.

4 — Cargo package name uses underscores

It’s mando_lib (underscore) for cargo -p, even though the directory is mando-lib. cargo build -p mando-lib errors “did not match any packages.” (Same convention across the workspace: mando_bess, py_mando, etc.)

Verification status (all green)

CheckResult
cargo build -p mando_libclean
whole-workspace cargo buildgreen except pre-existing py_mando maturin link (gotcha 3)
cargo test -p mando_lib205 passed, 32 ignored, 0 failed
cargo clippy -p mando_libclean
cargo tree -p mando_lib | grep -iE 'remotefs|pavao'empty — GPL dep gone

Post-review hardening (2026-06-25)

A two-reviewer validation pass (correctness + style) ran after the migration; three fixes were applied to the ebs.rs upload path. Re-verified all green: cargo build -p mando_lib clean, cargo test -p mando_lib = 205 passed / 32 ignored / 0 failed, clippy clean for ebs.rs, stable cargo fmt clean. (Package is mando_lib, underscore.)

1 — Resource::unwrap_file() PANICS on a directory; never use it in prod

If the path resolves to an existing directory (server-state-dependent), unwrap_file() panics. Use the fallible conversion instead — smb Resource impls TryInto<File> (error type (smb::Error, Self)):

let file: File = resource.try_into()
    .map_err(|_| EbsError::SambaError("expected a file resource".to_string()))?;

This supersedes the res.unwrap_file() shown in the API recipe above — never call unwrap_file() in production code.

2 — Workgroup/empty-username auth bug (malformed WORKGROUP\ + lost anonymous fallback)

The original 2-arm form if workgroup.is_empty() { user } else { format!("{wg}\\{user}") } produced a malformed "WORKGROUP\" (trailing backslash) when the workgroup was set but the username empty, and dropped the old code’s anonymous-on-empty-username behavior. Fixed with a 3-arm match on (workgroup.is_empty(), username.is_empty()) so an empty username always maps to String::new() (anonymous), otherwise WORKGROUP\user:

let user = match (workgroup.is_empty(), username.is_empty()) {
    (_, true)     => String::new(),                       // anonymous
    (true, _)     => username.clone(),                    // no workgroup
    (false, _)    => format!("{workgroup}\\{username}"),  // DOMAIN\user
};

3 — flush() before close() for upload durability

Added file.flush().await? before file.close().await?. Note flush is an inherent method (pub async fn flush(&self)) on smb File, NOT a trait — so unlike write_at (the smb::WriteAt trait), it needs no extra import.

Formatting: the repo is NOT nightly-formatted — use stable cargo fmt

rustfmt.toml sets nightly-only options (group_imports = "StdExternalCrate", imports_granularity = "Module", plus wrap_comments/format_code_in_doc_comments), but there is NO fmt gate in .gitlab-ci.yml, and every sibling adapter file fails cargo +nightly fmt --check (mdr 5, opl 3, et_3000 6, data_platform 5 diff-lines). So the enforced/consistent bar is plain stable cargo fmt. Do NOT nightly-format individual files — it makes their import grouping inconsistent with every sibling. Also: running rustfmt --check on a file outside the repo root panics ("path is expected to be under the root"); format from within the workspace.

Open item — needs a real SMB share (remaining acceptance step)

Live connectivity not validated in dev

The #[ignore]d live tests in ebs.rs (local_hourly_test, local_quarter_hourly_test) read EBS_SMB_HOST / USERNAME / PASSWORD / SHARE / WORKGROUP / SUB_FOLDER env vars and should be run against a real share to confirm the new crate authenticates + uploads end-to-end. This could not be validated in the dev environment. Flag this as the remaining acceptance step before merge.

  • mando-lib — core crate; owns adapter/alpiq/ebs.rs, the sole SMB call site.
  • Agent Context — dense agent reference (lists Alpiq EBS under external integrations; runtime-image + toolchain notes need reconciling post-migration).
  • Mando CI-CD — runtime/build images still document samba-libs / libsmbclient; reconcile after this lands.
  • py-mando — the cargo build -p py_mando link failure (gotcha 3) is inherent to its pyo3 extension-module cdylib; build with maturin develop.
  • LOG — activity log.