//! SMB share consumer — replaces the kernel-mount NFS path that v0.4.64 //! shipped. //! //! ## Why SMB and not NFS //! //! v0.4.64 tried to make `mount -t nfs` work inside the OpenPXE //! container. With `CAP_SYS_ADMIN` + `--privileged` we still hit the //! same `mount.nfs: failed to apply fstab options` on Unraid because //! Unraid's base kernel ships without the `nfs` / `nfsv4` client //! modules loaded. No amount of container-side configuration can //! load a kernel module on the host. //! //! SMB has the same kernel-side problem (`mount -t cifs` needs the //! `cifs` kernel module) but unlike NFS it has a usable **userspace** //! client: Samba's `smbclient` CLI. It speaks the SMB protocol over a //! plain TCP socket, no kernel modules required. Bootimus uses the //! same approach. //! //! ## How it works //! //! 1. Operator submits a share spec via the Storage tab: //! `{ server: "192.168.1.51", share: "isos", //! username, password, guest }`. //! 2. We write credentials to a 0600-permission tempfile under //! `/smb_creds/`. Passing them on the command line would //! leak them through `ps` and the container's audit log. //! 3. We test the connection by listing the share's root with //! `smbclient //server/share -A creds_file -c 'ls *.iso'`. If the //! server is unreachable, the share doesn't exist, or auth fails, //! we get a clean error before persisting anything. //! 4. We parse the `ls` output for `*.iso` filenames and sizes, and //! register each one with the `IsoStore` as an //! `IsoSource::Smb { share_id, relative_path }`. //! 5. When a PXE client requests the bytes, the HTTP handler asks this //! manager for an async reader. We spawn //! `smbclient //server/share -A creds_file -c 'get file -'` and //! pipe its stdout straight into the response body. No double //! storage, no temp files. //! //! ## Why subprocess and not a Rust library //! //! The Debian runtime image already ships the `samba` package //! (Dockerfile line 84) — `smbclient` is right there. Library options //! like `pavao` wrap `libsmbclient` so they still pull in the same C //! library at runtime. Subprocess is simpler, the API surface is //! whatever the operator can verify with `smbclient` at a shell, and //! debugging "what does smbclient see?" is trivial. //! //! ## Range request limitations (v0.4.65) //! //! `smbclient -c 'get file -'` is a sequential whole-file stream; //! there's no native seek in the CLI. We honor full GETs and reject //! HTTP Range requests with `416 Range Not Satisfiable` for //! SMB-sourced ISOs. PXE clients in practice request the whole file: //! iPXE chain loading, casper sanboot, wimboot all do sequential //! streaming. A follow-up release can add libsmbclient-based seek if //! a real workload needs it. use crate::introspect::provisional_report; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use openpxe_core::{Error, Result}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use time::OffsetDateTime; use tokio::process::Command; /// Default TCP port for SMB / CIFS. The wire protocol moved to 445 /// years ago; 139 (NetBIOS) is legacy and we don't expose it as an /// option. const DEFAULT_SMB_PORT: u16 = 445; /// Maximum time we wait for a TCP connection to the SMB server during /// the pre-flight probe. Same shape as the v0.4.64 NFS probe — short /// enough that a wrong IP doesn't make the UI hang for 30s, long /// enough that a slow appliance can still answer. const PROBE_TIMEOUT: Duration = Duration::from_secs(4); /// One configured SMB share. The id is derived from server+share so an /// operator pasting the same coordinates twice gets idempotent /// behaviour rather than a duplicate row. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SmbShare { pub id: String, pub server: String, pub share: String, /// Username used for the SMB connection. Empty when `guest` is /// true. Stored so the UI can echo it back; the password lives in /// the separate credentials file (see `creds_path`). pub username: String, /// True when we're connecting with `-N` (anonymous / guest mode). /// Most NAS appliances that expose ISO libraries do so as /// guest-readable; this is the common case. pub guest: bool, /// TCP port — 445 unless the operator overrode it. Persisted so /// the UI can echo it back. #[serde(default = "default_port")] pub port: u16, /// Most recent error encountered talking to the share, or `None` /// on success. Cleared every successful operation. pub last_error: Option, /// Operator-friendly translation of `last_error`. None when we /// don't have a friendlier rendition. pub last_hint: Option, #[serde(with = "time::serde::rfc3339::option")] pub last_scan: Option, /// Number of `*.iso` files we know about on the share as of the /// most recent scan. pub iso_count: u32, /// Whether the connection's currently working. `true` after a /// successful scan, `false` after a failure. Drives the UI dot. pub reachable: bool, /// Path to the credentials file on disk. Internal — not surfaced /// in the API JSON; we serialize it for restart-survival but the /// UI doesn't render it. #[serde(default)] #[serde(skip_serializing)] pub(crate) creds_path: Option, } /// Submission from the UI / API. #[derive(Debug, Clone, Deserialize)] pub struct SmbAddRequest { pub server: String, pub share: String, #[serde(default)] pub username: Option, #[serde(default)] pub password: Option, #[serde(default)] pub guest: bool, #[serde(default)] pub port: Option, } fn default_port() -> u16 { DEFAULT_SMB_PORT } /// Structured error surfaced to the API and rendered in the UI as two /// lines: the raw `error` from smbclient + an actionable `hint`. /// Mirrors the v0.4.64 NFS error shape so the storage tab can use a /// single rendering path. #[derive(Debug, Clone, Serialize)] pub struct SmbShareError { pub error: String, pub stderr: String, pub hint: Option, } impl SmbShareError { fn from_raw(error: impl Into, stderr: impl Into) -> Self { let stderr = stderr.into(); let error = error.into(); let hint = hint_for(&stderr).or_else(|| hint_for(&error)); Self { error, stderr, hint, } } } #[derive(Debug, Default)] struct Inner { shares: HashMap, } /// Manages SMB shares and surfaces their ISOs through the IsoStore. /// /// Cheap to clone — internal state is `Arc>`. #[derive(Debug, Clone)] pub struct SmbShareManager { creds_root: Arc, state_path: Arc, inner: Arc>, iso_store: IsoStore, /// Serializes scan/list/get against the same share. smbclient /// itself is fine concurrent across processes, but bundling /// operations through a single lock makes test ordering and log /// output predictable. op_lock: Arc>, } impl SmbShareManager { /// Construct a manager rooted at `work_dir`. Credentials files /// live under `/smb_creds/` with 0600 permissions; state /// persists to `/smb_shares.json`. #[must_use] pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { let creds_root = work_dir.join("smb_creds"); let state_path = work_dir.join("smb_shares.json"); Self { creds_root: Arc::new(creds_root), state_path: Arc::new(state_path), inner: Arc::new(Mutex::new(Inner::default())), iso_store, op_lock: Arc::new(tokio::sync::Mutex::new(())), } } /// Load persisted state and re-scan every share. Errors per share /// are logged and surfaced on the spec; the call itself never /// fails — startup must not block on a single offline server. pub async fn load_and_rescan(&self) -> Result<()> { tokio::fs::create_dir_all(self.creds_root.as_path()).await?; let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await { Ok(text) => serde_json::from_str::>(&text).unwrap_or_default(), Err(_) => Vec::new(), }; for mut s in shares { s.last_error = None; s.last_hint = None; s.reachable = false; self.inner.lock().shares.insert(s.id.clone(), s.clone()); if let Err(e) = self.rescan_inner(&s.id).await { tracing::warn!( target: "openpxe::smb", id = %s.id, server = %s.server, share = %s.share, "rescan on startup failed: {e}" ); } } Ok(()) } /// Add or refresh a share. Validates the input, writes a creds /// file, probes connectivity, and scans for ISOs. pub async fn add(&self, req: SmbAddRequest) -> std::result::Result { let server = normalize_server(&req.server); let share = req.share.trim().trim_start_matches('/').to_string(); if server.is_empty() { return Err(SmbShareError::from_raw("server is required", "")); } if share.is_empty() { return Err(SmbShareError::from_raw("share name is required", "")); } if share.contains('/') { return Err(SmbShareError::from_raw( "share name should be the top-level share (e.g. 'isos'), not a path", "", )); } if server.contains('\0') || share.contains('\0') { return Err(SmbShareError::from_raw("NUL bytes are not allowed", "")); } let guest = req.guest; let username = req.username.unwrap_or_default().trim().to_string(); let password = req.password.unwrap_or_default(); if !guest && username.is_empty() { return Err(SmbShareError::from_raw( "username is required when 'guest' is unchecked", "", )); } let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SMB_PORT); let id = share_id(&server, &share); // Pre-flight TCP probe so a wrong IP / firewall surfaces a // clean error instead of one of smbclient's notoriously // cryptic NT_STATUS codes. if let Err((err, hint)) = tcp_probe(&server, port).await { // No share is persisted yet; just return the error. return Err(SmbShareError { error: err, stderr: String::new(), hint: Some(hint), }); } // Write the creds file. Even guest mode gets a file (empty // username/password) so the code path is uniform. let creds_path = self.creds_root.join(format!("{id}.cred")); if let Err(e) = self.write_creds(&creds_path, &username, &password).await { return Err(SmbShareError::from_raw( format!("could not write credentials file: {e}"), "", )); } let spec = SmbShare { id: id.clone(), server, share, username, guest, port, last_error: None, last_hint: None, last_scan: None, iso_count: 0, reachable: false, creds_path: Some(creds_path), }; self.inner.lock().shares.insert(id.clone(), spec); self.persist_locked(); // Now actually talk to the server. if let Err(e) = self.rescan_inner(&id).await { let m = self.get(&id); return Err(SmbShareError { error: m .as_ref() .and_then(|m| m.last_error.clone()) .unwrap_or_else(|| e.to_string()), stderr: String::new(), hint: m.and_then(|m| m.last_hint), }); } Ok(self.get(&id).expect("just inserted")) } /// Remove a share: drops every ISO sourced from it, scrubs the /// creds file, and forgets the spec. Idempotent. pub async fn remove(&self, id: &str) -> Result<()> { let creds_path = { let mut g = self.inner.lock(); g.shares.remove(id).and_then(|s| s.creds_path) }; self.iso_store.drop_external_source(id); if let Some(p) = creds_path { // Overwrite-then-unlink would be more thorough but the // file is 0600 in a non-root-owned dir; rm is sufficient. let _ = tokio::fs::remove_file(&p).await; } self.persist_locked(); Ok(()) } /// Re-list the share and refresh the IsoStore entries. pub async fn rescan(&self, id: &str) -> Result { self.rescan_inner(id).await } /// Snapshot of every configured share, sorted by id for stable UI /// rendering. #[must_use] pub fn list(&self) -> Vec { let g = self.inner.lock(); let mut v: Vec<_> = g.shares.values().cloned().collect(); v.sort_by(|a, b| a.id.cmp(&b.id)); v } /// Look up a share by id. #[must_use] pub fn get(&self, id: &str) -> Option { self.inner.lock().shares.get(id).cloned() } /// Open an async reader streaming an ISO out of the share. Used /// by the HTTP ISO download handler. /// /// Kept `async` for symmetry with the other I/O entrypoints — /// spawning the child is sync today (no `.await` inside) but a /// future addition (e.g. probing the share before spawn or /// throttling concurrent smbclients) would need to await without /// changing the call sites. #[allow(clippy::unused_async)] pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result { let share = self .get(share_id) .ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?; // Defensive: reject any filename that tries to escape the // share root. smbclient itself accepts only filenames at the // share root in our `get` form, but belt-and-suspenders. if filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err(Error::Invalid(format!("invalid filename '{filename}'"))); } let creds = share .creds_path .as_deref() .ok_or_else(|| Error::Invalid("share has no credentials file".into()))?; let target = format!("//{}/{}", share.server, share.share); let mut cmd = Command::new("smbclient"); cmd.arg(&target) .arg("-A") .arg(creds) .arg("-p") .arg(share.port.to_string()) .arg("-c") .arg(format!("get \"{filename}\" -")) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .stdin(Stdio::null()); if share.guest { cmd.arg("-N"); } // v0.4.66: surface a useful error if smbclient isn't on the // PATH. Shouldn't happen on the stock image but custom // builds may strip it. let mut child = cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { Error::Invalid( "smbclient binary not found on $PATH — install the \ Debian `smbclient` package or pull OpenPXE v0.4.66+" .into(), ) } else { Error::Other(e.into()) } })?; let stdout = child .stdout .take() .ok_or_else(|| Error::Invalid("smbclient stdout missing".into()))?; Ok(SmbStream { child, stdout }) } // ── internals ───────────────────────────────────────────────────── async fn rescan_inner(&self, id: &str) -> Result { let _g = self.op_lock.lock().await; let share = self .get(id) .ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?; let now = OffsetDateTime::now_utc(); // Drop prior entries so a deleted file disappears from the // store on the next scan. self.iso_store.drop_external_source(id); let listing = match self.list_isos(&share).await { Ok(l) => l, Err((err, stderr)) => { let combined = if stderr.is_empty() { err.clone() } else { format!("{err}: {stderr}") }; let hint = hint_for(&stderr).or_else(|| hint_for(&err)); self.update_status(id, 0, false, Some(combined.clone()), hint, now); return Err(Error::Invalid(combined)); } }; // For each ISO we found, we still need its size + a quick // introspection pass. The introspection pass needs random // access into the ISO9660 PVD which lives at offset 0x8000. // For SMB sources we can't seek without downloading the file // first, so we use a degenerate "unknown family" introspection // report for the listing pass. Operators can rescan after the // first PXE boot has touched the file if they want a real // family detection. (Better: a follow-up release adds a tiny // `smbclient -c 'get file -'` bounded read to do introspection // without storing the whole ISO.) let mut count = 0u32; for entry in listing { let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename)); // SMB sources can't get the content-probe pass NFS/SFTP got // in v0.7.4 — the ISO9660 probes need seeks, and smbclient's // CLI streaming doesn't seek. The provisional filename-token // report is as far as SMB detection goes: family for the UI // when the name says it ("rhel-9.0…", "Win11_…"), and // `introspect_rev = 0` so the entry generator keeps the // optimistic sanboot entry. let report = provisional_report(&entry.filename); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Smb { share_id: share.id.clone(), relative_path: entry.filename.clone(), }; self.iso_store.register_external( iso_id, entry.filename, entry.size, report, boot_entries, source, ); count += 1; } self.update_status(id, count, true, None, None, now); tracing::info!( target: "openpxe::smb", id = %id, server = %share.server, share = %share.share, iso_count = count, "SMB share scanned" ); Ok(count) } /// Spawn `smbclient //server/share -A creds -c "ls *.iso"` and /// parse the output. Returns `(error_text, stderr_text)` on /// failure so the caller can surface both. async fn list_isos( &self, share: &SmbShare, ) -> std::result::Result, (String, String)> { let target = format!("//{}/{}", share.server, share.share); let mut cmd = Command::new("smbclient"); cmd.arg(&target) .arg("-A") .arg( share .creds_path .as_deref() .ok_or_else(|| ("no credentials file".to_string(), String::new()))?, ) .arg("-p") .arg(share.port.to_string()) .arg("-c") .arg("ls *.iso"); if share.guest { cmd.arg("-N"); } let output = match cmd.output().await { Ok(o) => o, Err(e) => { // v0.4.66: distinguish ENOENT (missing binary) from // other exec failures and pre-fill the hint so the // UI shows a clear remediation instead of the bare // "No such file or directory (os error 2)". This // shouldn't fire on the stock image — the Dockerfile // installs the `smbclient` package — but is a useful // breadcrumb for anyone running OpenPXE in a stripped // base image. let stderr = if e.kind() == std::io::ErrorKind::NotFound { "smbclient binary not found on $PATH".to_string() } else { String::new() }; return Err((format!("could not exec smbclient: {e}"), stderr)); } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); // smbclient writes most diagnostics to stdout too; merge // them so we don't lose context. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let combined = if stderr.is_empty() { stdout } else { stderr }; return Err(( format!("smbclient exit {}", output.status.code().unwrap_or(-1)), combined, )); } let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_ls_iso(&stdout)) } async fn write_creds( &self, path: &Path, username: &str, password: &str, ) -> std::io::Result<()> { tokio::fs::create_dir_all(self.creds_root.as_path()).await?; // Write the file with 0600 perms. `smbclient -A` accepts the // standard pam_mount-style: // username = foo // password = bar let body = format!( "username = {}\npassword = {}\n", username.replace('\n', ""), password.replace('\n', ""), ); // Synchronous file write to set perms atomically with the // create — there's no async equivalent of OpenOptions+mode // shared with the tokio API in std stable. let path = path.to_path_buf(); tokio::task::spawn_blocking(move || -> std::io::Result<()> { use std::os::unix::fs::OpenOptionsExt; let mut f = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&path)?; f.write_all(body.as_bytes())?; Ok(()) }) .await .map_err(std::io::Error::other)??; Ok(()) } fn update_status( &self, id: &str, iso_count: u32, reachable: bool, err: Option, hint: Option, ts: OffsetDateTime, ) { if let Some(s) = self.inner.lock().shares.get_mut(id) { s.iso_count = iso_count; s.reachable = reachable; s.last_error = err; s.last_hint = hint; s.last_scan = Some(ts); } self.persist_locked(); } /// Atomically replace the on-disk JSON. Persistence errors are /// logged, never propagated. fn persist_locked(&self) { let shares: Vec = self.inner.lock().shares.values().cloned().collect(); let path = self.state_path.as_path(); let tmp = path.with_extension("json.tmp"); let body = match serde_json::to_vec_pretty(&shares) { Ok(b) => b, Err(e) => { tracing::warn!(target: "openpxe::smb", "serialize: {e}"); return; } }; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } if let Err(e) = std::fs::write(&tmp, body) { tracing::warn!(target: "openpxe::smb", "write tmp: {e}"); return; } if let Err(e) = std::fs::rename(&tmp, path) { tracing::warn!(target: "openpxe::smb", "rename: {e}"); } } } /// Async-reader handle for an in-flight `smbclient get file -` stream. /// Wraps the child process + its piped stdout; dropping it kills the /// child. #[derive(Debug)] pub struct SmbStream { /// Kept alive so the child isn't reaped while we're reading. The /// `Drop` impl on `tokio::process::Child` sends SIGKILL on drop /// when `kill_on_drop` is set; we leave that to the default /// (no-kill) so a slow client doesn't tear down the pipe before /// the OS finishes the read. The child exits naturally when its /// stdout closes. #[allow(dead_code)] child: tokio::process::Child, pub stdout: tokio::process::ChildStdout, } #[derive(Debug, Clone)] struct SmbListEntry { filename: String, size: u64, } /// Parse `smbclient ls *.iso` output. The format is: /// /// ```text /// . D 0 Mon May 26 10:00:00 2026 /// .. D 0 Mon May 26 10:00:00 2026 /// ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026 /// /// 4096 blocks of size 1048576. 1234 blocks available /// ``` /// /// Each file line: /// - starts with whitespace /// - has the filename, then attribute flags (D=dir, A=archive, R=read-only, /// H=hidden, S=system, N=normal), then size, then date. /// /// We accept any line where the attributes column doesn't contain `D` /// (i.e. not a directory) and the filename ends in `.iso` (case /// insensitive). fn parse_ls_iso(out: &str) -> Vec { let mut entries = Vec::new(); for raw in out.lines() { let line = raw.trim(); // Skip blank lines, the connection-info banner, and the // trailing "N blocks of size" summary. The actual filter for // "is this a file listing?" is the attribute+size pattern // detection below, which only matches real file rows. if line.is_empty() || line.contains("blocks of size") { continue; } // Find the attribute column: a short token of one or more of // [DAHSRN] that follows a long-enough filename block. // smbclient pads the filename to ~36 columns, so we can split // on multiple consecutive spaces and then look for the // attribute token. let tokens: Vec<&str> = line.split_whitespace().collect(); if tokens.len() < 3 { continue; } // The last 5 tokens are typically: ATTR SIZE Day Mon DD HH:MM:SS YYYY // (sometimes Day is missing depending on locale). Walk // backwards to find ATTR + SIZE: ATTR is 1-6 chars of [DAHSRN], // SIZE is digits. let attr_idx = tokens.iter().enumerate().rev().find_map(|(i, t)| { if i == 0 { return None; } let next = tokens.get(i + 1)?; let is_attr = !t.is_empty() && t.chars().all(|c| "DAHSRN".contains(c)); let is_size = next.chars().all(|c| c.is_ascii_digit()) && !next.is_empty(); if is_attr && is_size { Some(i) } else { None } }); let Some(attr_idx) = attr_idx else { continue }; let attr = tokens[attr_idx]; // Directories aren't ISO files. if attr.contains('D') { continue; } let size_tok = tokens[attr_idx + 1]; let Ok(size) = size_tok.parse::() else { continue; }; // The filename is everything before the attribute token in // the original (un-tokenized) line — we need the original // because filenames can contain spaces. // Locate the attribute token's start column by counting // characters in the prior tokens + separators. Simpler: find // the index of the attribute in the trimmed line by joining // and trimming again. let joined_before: String = tokens[..attr_idx].join(" "); let name = joined_before.trim().to_string(); if name.is_empty() || name == "." || name == ".." { continue; } if !name.to_ascii_lowercase().ends_with(".iso") { continue; } entries.push(SmbListEntry { filename: name, size, }); } entries } /// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS /// probe so the UI banner reads consistently. async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> { use tokio::net::TcpStream; let addr = format!("{server}:{port}"); match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await { Ok(Ok(_)) => Ok(()), Ok(Err(e)) => Err(( format!("cannot reach SMB port: {addr}: {e}"), format!( "verify the SMB service is running on {server} and that port {port} is open" ), )), Err(_) => Err(( format!( "cannot reach SMB port: {addr}: timed out after {}s", PROBE_TIMEOUT.as_secs() ), format!( "no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between", PROBE_TIMEOUT.as_secs() ), )), } } /// Translate well-known smbclient stderr patterns into actionable /// hints. Returns `None` when we don't have a translation. fn hint_for(text: &str) -> Option { let s = text.to_ascii_lowercase(); if s.contains("smbclient binary not found") || s.contains("smbclient: no such file") || (s.contains("could not exec smbclient") && s.contains("os error 2")) { // v0.4.66: this only fires on a stripped / custom runtime // image — the stock OpenPXE container ships `smbclient` from // the Debian `smbclient` package. The error surfaced on // v0.4.65 specifically because that release's Dockerfile // installed `samba` (the server) but not `smbclient` (the // client CLI). Operators on the stock image should never see // this; if they do, the fix is to upgrade. Some( "smbclient isn't installed in this container. Pull the \ official OpenPXE image v0.4.66 or newer — the stock image \ ships smbclient. If you're running a custom build, add the \ Debian `smbclient` package to your runtime stage." .into(), ) } else if s.contains("nt_status_logon_failure") || s.contains("logon_failure") { Some( "the server rejected the credentials. Double-check the username \ and password — many NAS appliances use a separate SMB account \ rather than the system login." .into(), ) } else if s.contains("nt_status_access_denied") || s.contains("access_denied") { Some( "the credentials worked but the account doesn't have read \ access to this share. Check the share's permissions on the \ server." .into(), ) } else if s.contains("nt_status_bad_network_name") || s.contains("nt_status_bad_network_path") || s.contains("bad_network_name") { Some( "the share name doesn't exist on this server. Enter just the \ share name (e.g. 'isos'), not a path. Use `smbclient -L \ //server` to list shares manually." .into(), ) } else if s.contains("connection refused") { Some( "the SMB service isn't accepting connections on this port. \ Verify smbd / Samba is running on the server." .into(), ) } else if s.contains("connection timed out") || s.contains("no route to host") { Some( "the server isn't reachable on this network. Check the IP and \ any firewall in between." .into(), ) } else if s.contains("nt_status_network_unreachable") { Some( "the server's network is unreachable from this container — \ check the host networking setup." .into(), ) } else if s.contains("does not exist") || s.contains("not a directory") { Some( "the listed path doesn't exist on the share. Make sure the \ share name is the top-level share, not a sub-path." .into(), ) } else if s.contains("session setup failed") { Some( "session setup failed — usually a protocol / dialect mismatch. \ Most modern servers speak SMB2/3; very old shares (XP) may \ need legacy support enabled on the server." .into(), ) } else { None } } fn share_id(server: &str, share: &str) -> String { slugify_str(&format!("{server}-{share}")) } /// Normalize a server input: trim, strip scheme prefix the operator /// may have pasted, and drop trailing slashes. UNC-style `\\server` /// and `//server` prefixes are also accepted. fn normalize_server(raw: &str) -> String { let s = raw.trim(); let s = s .strip_prefix("smb://") .or_else(|| s.strip_prefix("cifs://")) .or_else(|| s.strip_prefix("\\\\")) .or_else(|| s.strip_prefix("//")) .unwrap_or(s); s.trim_end_matches('/').trim_end_matches('\\').to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn share_id_is_stable_and_safe() { let a = share_id("10.0.0.5", "isos"); let b = share_id("10.0.0.5", "isos"); assert_eq!(a, b); assert!(!a.contains('/')); assert!(!a.contains('.')); } #[test] fn normalize_server_strips_url_and_unc_prefixes() { assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5"); assert_eq!(normalize_server("smb://nas.lan/"), "nas.lan"); assert_eq!(normalize_server("cifs://192.168.1.51"), "192.168.1.51"); assert_eq!(normalize_server("\\\\192.168.1.51\\"), "192.168.1.51"); assert_eq!(normalize_server("//nas.lan//"), "nas.lan"); assert_eq!(normalize_server("nas.lan"), "nas.lan"); } #[test] fn hint_for_logon_failure_calls_out_credentials() { let h = hint_for("session setup failed: NT_STATUS_LOGON_FAILURE").unwrap(); assert!(h.to_lowercase().contains("credentials")); } #[test] fn hint_for_bad_share_name_calls_out_share_lookup() { let h = hint_for("tree connect failed: NT_STATUS_BAD_NETWORK_NAME").unwrap(); assert!(h.to_lowercase().contains("share")); } #[test] fn hint_for_unknown_is_none() { assert!(hint_for("some unrelated error text").is_none()); } #[test] fn hint_for_missing_smbclient_calls_out_upgrade() { // The exec-side path sets `stderr` to "smbclient binary not // found on $PATH" when ENOENT lands. let h = hint_for("smbclient binary not found on $PATH").unwrap(); assert!( h.contains("smbclient") && h.to_lowercase().contains("install"), "expected upgrade/install guidance, got: {h}" ); // The raw error path on the API side passes the verbatim // exec error in `error` plus an empty `stderr`. The // SmbShareError constructor's hint_for fallback checks error // too, so this pattern needs to translate as well. let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap(); assert!(h2.contains("smbclient")); } #[test] fn parse_ls_iso_finds_one_iso_and_skips_directories() { let out = "\ \tDomain=[WORKGROUP] OS=[Windows] Server=[Samba]\n\ . D 0 Mon May 26 10:00:00 2026\n\ .. D 0 Mon May 26 10:00:00 2026\n\ ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026\n\ \n\ \t\t4096 blocks of size 1048576. 1234 blocks available\n\ "; let entries = parse_ls_iso(out); assert_eq!(entries.len(), 1); assert_eq!(entries[0].filename, "ubuntu-22.04-desktop.iso"); assert_eq!(entries[0].size, 3_650_912_256); } #[test] fn parse_ls_iso_handles_filenames_with_spaces() { let out = "\ Windows Server 2025.iso A 5000000000 Tue May 27 09:00:00 2026\n\ "; let entries = parse_ls_iso(out); assert_eq!(entries.len(), 1); assert_eq!(entries[0].filename, "Windows Server 2025.iso"); assert_eq!(entries[0].size, 5_000_000_000); } #[test] fn parse_ls_iso_skips_non_iso_files() { let out = "\ readme.txt A 100 Tue May 27 09:00:00 2026\n\ archive.zip A 5000 Tue May 27 09:00:00 2026\n\ "; let entries = parse_ls_iso(out); assert!(entries.is_empty()); } #[test] fn add_request_requires_username_when_not_guest() { // We can't easily test the add() path against a real SMB // server in unit tests, but we can confirm the validation // logic at least serializes the request shape we expect. The // actual auth check happens in add() itself which we cover in // integration tests against a stub server. let req = SmbAddRequest { server: "10.0.0.5".into(), share: "isos".into(), username: None, password: None, guest: false, port: None, }; // No SmbShareManager here — we just check the field shape // matches what UI submits. assert!(!req.guest); assert!(req.username.is_none()); } }