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-smb → pavao → pavao-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)
removed 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 features — sign, 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 unclet path = unc.with_path(&file_path); // consumes selflet 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 writtenwhile 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.
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 remotefsexists() + 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, cargohard-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_mandocargo 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)
Check
Result
cargo build -p mando_lib
clean
whole-workspace cargo build
green except pre-existing py_mando maturin link (gotcha 3)
cargo test -p mando_lib
205 passed, 32 ignored, 0 failed
cargo clippy -p mando_lib
clean
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, stablecargo 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.
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 failscargo +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.
Related
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.