From 1d10531b6af68c98f8bde2cc0b98444c0c130ca5 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 17:57:32 +0000 Subject: [PATCH 01/42] Rework into safe, flexible bindings for Windows SMB operations (0.2.0) - Switch from ANSI (A) to wide (W) WNet APIs: correct Unicode handling for share names, user names, and passwords - Optional credentials (None = logged-on user / SSO), SmbShare builder, type-safe DriveLetter, printer shares, provider override - Full CONNECT_* flag surface via ConnectOptions, incl. SMB signing and encryption enforcement, plus raw-flags escape hatches - connect_auto (WNetUseConnectionW picks the drive letter), RAII Connection guard, cancel_connection by device or remote name - query module: get_connection, get_user, get_universal_name - enumerate module: safe Iterator over WNetOpenEnumW (active/remembered connections, server share listings) - server module (netapi32): share/session/open-file/connection administration with automatic info-level fallback - Error: non_exhaustive, WNetGetLastErrorW capture behind ExtendedError, raw_os_error + io::Error interop - tracing is now an opt-in feature; new zeroize feature wipes password buffers; Debug redacts passwords - windows-sys 0.60, thiserror 2, Windows-gated deps, docs.rs targets, MSRV 1.85 - Env-driven integration suite; CI provisions a real \\localhost share on windows-latest and runs it, plus clippy/fmt/docs/no-op gates Adversarially reviewed by a 2-reviewer + independent-fixer agent workflow; all 6 findings (2 major) verified and fixed. Co-Authored-By: Claude Fable 5 --- .cargo/config.toml | 2 + .github/workflows/ci.yml | 60 ++++ CHANGELOG.md | 63 ++++ Cargo.lock | 74 ++-- Cargo.toml | 36 +- README.md | 147 ++++++-- src/enumerate.rs | 249 +++++++++++++ src/error.rs | 243 ++++++++++++- src/lib.rs | 410 ++++----------------- src/options.rs | 424 ++++++++++++++++++++++ src/query.rs | 99 +++++ src/server.rs | 759 +++++++++++++++++++++++++++++++++++++++ src/share.rs | 443 +++++++++++++++++++++++ src/strings.rs | 128 +++++++ src/trace.rs | 25 ++ tests/integration.rs | 377 +++++++++++++++++++ 16 files changed, 3121 insertions(+), 418 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 src/enumerate.rs create mode 100644 src/options.rs create mode 100644 src/query.rs create mode 100644 src/server.rs create mode 100644 src/share.rs create mode 100644 src/strings.rs create mode 100644 src/trace.rs create mode 100644 tests/integration.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 8af59dd..fafdce5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,4 @@ +# The integration tests mount real drive letters and enumerate live +# connections; running them concurrently would race on that global state. [env] RUST_TEST_THREADS = "1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f0dc3ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Integration tests (Windows, real SMB share) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + + - name: Provision a local user and SMB share + shell: pwsh + run: | + $password = ConvertTo-SecureString 'Sambrs-CI-Pass-1!' -AsPlainText -Force + New-LocalUser -Name 'smbtest' -Password $password -PasswordNeverExpires | Out-Null + New-Item -ItemType Directory -Path 'C:\sambrs-share' | Out-Null + New-SmbShare -Name 'sambrs-test' -Path 'C:\sambrs-share' -FullAccess 'smbtest' | Out-Null + icacls 'C:\sambrs-share' /grant 'smbtest:(OI)(CI)F' | Out-Null + "SAMBRS_TEST_USERNAME=$env:COMPUTERNAME\smbtest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Unit + integration tests (all features) + run: cargo test --all-features -- --include-ignored + env: + SAMBRS_TEST_SHARE: \\localhost\sambrs-test + SAMBRS_TEST_PASSWORD: Sambrs-CI-Pass-1! + SAMBRS_TEST_LOCAL: '1' + + - name: Unit tests (no default features) + run: cargo test + + lint: + name: Lint, docs, cross-target checks (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: rustfmt + run: cargo fmt --check + + - name: clippy (pedantic is enforced in the crate root) + run: cargo clippy --target x86_64-pc-windows-msvc --all-targets --all-features -- -D warnings + + - name: rustdoc + run: cargo doc --target x86_64-pc-windows-msvc --all-features --no-deps + env: + RUSTDOCFLAGS: -D warnings + + - name: crate is a well-behaved no-op on non-Windows hosts + run: cargo check --all-features diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a54612c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +## 0.2.0 (unreleased) + +A ground-up rework: from "a tiny wrapper around two calls" to safe, flexible +bindings for the Windows SMB surface. **Breaking release** — see the +migration table in the README. + +### Fixed + +- **Unicode correctness**: all calls now use the wide (`W`) API variants with + UTF-16 strings. 0.1 used the ANSI variants, which mangled non-ASCII share + names, user names, and passwords (typically surfacing as spurious + `LogonFailure`). +- `ERROR_EXTENDED_ERROR` no longer swallows the real error: the + provider-specific code and message are fetched via `WNetGetLastErrorW` and + carried in `Error::ExtendedError`. +- `disconnect`'s misleading `persist` parameter (which *removed* persistence) + is now `DisconnectOptions::forget`. + +### Added + +- Authenticate as the logged-on user by omitting credentials (0.1 always + passed non-null credentials, making SSO unreachable). +- `SmbShare::builder` with `credentials`, `username`, `password`, `mount_on` + (type-safe `DriveLetter`), `local_device`, `resource_type` (disk/printer), + and `provider`. +- `ConnectOptions` covering every documented `CONNECT_*` flag: `persist`, + `update_recent`, `interactive`, `prompt`, `commandline`, `redirect`, + `current_media`, `save_credentials`, `reset_credentials`, + `require_integrity` (SMB signing), `require_privacy` (SMB encryption), + `write_through` — plus `raw_flags` and `SmbShare::connect_raw` as escape + hatches. +- `SmbShare::connect_auto`: let Windows pick a free drive letter + (`WNetUseConnectionW`), returning the assigned name. +- `SmbShare::connect_guarded`: RAII `Connection` guard that disconnects on + drop, with `leak()` and explicit `disconnect()`. +- `cancel_connection`: disconnect any connection by device or remote name. +- `query` module: `get_connection`, `get_user`, `get_universal_name`. +- `enumerate` module: iterate active connections, remembered connections, and + the shares a server exposes (`WNetOpenEnumW` family), plus `resources_raw`. +- `server` module (netapi32): `shares`, `share_info`, `add_share`, + `delete_share`, `sessions`, `delete_session`, `open_files`, `close_file`, + `connections` — with automatic fallback to lower information levels when + not administrator. +- `Error::raw_os_error` and `From for std::io::Error`. +- Cargo features: `tracing` (now optional!) and `zeroize` (wipe password + buffers on drop). +- CI: full integration suite against a real `\\localhost` share on Windows + runners; clippy/rustfmt/rustdoc gates. + +### Changed + +- `Error` is `#[non_exhaustive]` and gained variants for the query/server + APIs; `Error::CStringConversion` is now `Error::InteriorNul`. +- `windows-sys` 0.52 → 0.60, `thiserror` 1 → 2; dependencies are declared + Windows-only, and the crate compiles to nothing on other targets. +- docs.rs builds Windows targets; MSRV pinned at 1.85. + +## 0.1.2 + +- Description and examples; edition 2024; initial `WNetAddConnection2A` / + `WNetCancelConnection2A` wrapper with typed errors. diff --git a/Cargo.lock b/Cargo.lock index 83ebfab..29c8d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "once_cell" @@ -16,9 +16,9 @@ checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -34,18 +34,19 @@ dependencies = [ [[package]] name = "sambrs" -version = "0.1.2" +version = "0.2.0" dependencies = [ "thiserror", "tracing", "windows-sys", + "zeroize", ] [[package]] name = "syn" -version = "2.0.72" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -54,18 +55,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -109,21 +110,28 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" -version = "0.52.6" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ + "windows-link", "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", @@ -136,48 +144,54 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "zeroize" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/Cargo.toml b/Cargo.toml index 191e780..42d6821 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,40 @@ [package] name = "sambrs" -version = "0.1.2" +version = "0.2.0" authors = ["Samuel Van der Stappen "] license = "MIT" readme = "README.md" edition = "2024" +rust-version = "1.85" repository = "https://github.com/samvdst/sambrs" documentation = "https://docs.rs/sambrs" -categories = ["os::windows-apis", "filesystem"] +categories = ["os::windows-apis", "filesystem", "api-bindings"] keywords = ["windows", "smb", "share", "network"] description = """ -A tiny ergonomic wrapper around WNetAddConnection2A to connect to SMB shares on Windows. +Safe and flexible bindings for Windows SMB share operations: connect and disconnect network shares (WNet), query and enumerate connections and remote shares, and administer server-side shares, sessions, and open files (netapi32). """ -[dependencies] -thiserror = "1" -tracing = "0.1" -windows-sys = { version = "0.52", features = [ - "Win32_NetworkManagement_WNet", +[features] +default = [] +# Emit `tracing` debug/trace events for every Windows API call. +tracing = ["dep:tracing"] +# Wipe password buffers (both the stored String and transient UTF-16 copies) after use. +zeroize = ["dep:zeroize"] + +[target.'cfg(windows)'.dependencies] +thiserror = "2" +tracing = { version = "0.1", optional = true } +zeroize = { version = "1", optional = true } + +[target.'cfg(windows)'.dependencies.windows-sys] +version = "0.60" +features = [ "Win32_Foundation", -] } + "Win32_NetworkManagement_WNet", + "Win32_NetworkManagement_NetManagement", + "Win32_Storage_FileSystem", +] + +[package.metadata.docs.rs] +default-target = "x86_64-pc-windows-msvc" +targets = ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] diff --git a/README.md b/README.md index 73936ef..3a703ea 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,144 @@ -# Sambrs +# sambrs -A tiny ergonomic wrapper around `WNetAddConnection2A` and -`WNetCancelConnection2A`. The goal is to offer an easy to use interface to -connect to SMB network shares on Windows. +[![crates.io](https://img.shields.io/crates/v/sambrs.svg)](https://crates.io/crates/sambrs) +[![docs.rs](https://img.shields.io/docsrs/sambrs)](https://docs.rs/sambrs) -Sam -> SMB -> Rust -> Samba is taken!? -> sambrs +Safe and flexible Rust bindings for Windows SMB share operations. -## Features +Sam -> SMB -> Rust -> Samba is taken!? -> sambrs -- Simple and ergonomic interface to connect to SMB network shares. -- Support for specifying a local Windows mount point. -- Options to persist connections across user login sessions. -- Interactive mode for password prompts. +The crate wraps three areas of the Windows API, aiming to expose the full +flexibility of the underlying calls behind a safe, unopinionated interface: + +- **Connecting** — `WNetAddConnection2W`, `WNetUseConnectionW`, and + `WNetCancelConnection2W`: connect to a share deviceless, mounted on a drive + letter of your choice, or on a letter Windows picks. Every documented + `CONNECT_*` flag is available through `ConnectOptions`, including + per-connection SMB signing (`require_integrity`) and encryption + (`require_privacy`) enforcement, plus a raw-flags escape hatch. An optional + RAII `Connection` guard disconnects on drop. +- **Querying and enumerating** — `WNetGetConnectionW`, `WNetGetUserW`, + `WNetGetUniversalNameW`, and the `WNetOpenEnumW` family: inspect existing + connections, list remembered ones, and enumerate the shares a server + exposes, as a plain Rust `Iterator`. +- **Administering** — the netapi32 `NetShare*`, `NetSession*`, `NetFile*`, + and `NetConnectionEnum` functions: create and delete shares, and manage + sessions and open files, on the local machine or a remote server. + +All strings cross the FFI boundary as UTF-16 (the `W` API variants), so share +names, user names, and passwords with any Unicode content work correctly. ## Installation -Add this to your `Cargo.toml`: - ```toml [dependencies] -sambrs = "0.1" +sambrs = "0.2" ``` ## Usage -Instantiate an `SmbShare` with an optional local Windows mount point and establish -a connection. - -When calling the connect method, you have the option to persist the connection -across user login sessions and to enable interactive mode. Interactive mode will -block until the user either provides a correct password or cancels, resulting in -a `Canceled` error. +Instantiate an `SmbShare` and establish a connection. Once connected (mounted +or deviceless), `std::fs` works on it like on any local path: ```rust -use sambrs::SmbShare; +use sambrs::{ConnectOptions, DriveLetter, SmbShare}; -fn main() { - let share = SmbShare::new(r"\\server\share", "user", "pass", Some('D')); +fn main() -> Result<(), Box> { + let share = SmbShare::builder(r"\\server.local\share") + .credentials(r"LOGONDOMAIN\user", "pass") + .mount_on(DriveLetter::D) + .build()?; - match share.connect(false, false) { - Ok(()) => println!("Connected successfully!"), - Err(e) => eprintln!("Failed to connect: {}", e), - } + share.connect_with( + ConnectOptions::new() + .persist(true) // restore the mapping at logon + .require_privacy(true), // enforce SMB encryption + )?; // use std::fs as if D:\ was a local directory - dbg!(std::fs::metadata(r"D:\").unwrap().is_dir()); + println!("{}", std::fs::metadata(r"D:\")?.is_dir()); + + share.disconnect()?; + Ok(()) } ``` +Without credentials, the connection authenticates as the logged-on user: + +```rust +let share = sambrs::SmbShare::new(r"\\server.local\share"); +share.connect()?; +``` + +Enumerate what a server offers, or administer shares (see the +[`enumerate`](https://docs.rs/sambrs/latest/sambrs/enumerate/) and +[`server`](https://docs.rs/sambrs/latest/sambrs/server/) module docs for the +full API): + +```rust +for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { + println!("{:?}", resource?.remote_name); +} + +sambrs::server::add_share( + None, // local machine + &sambrs::server::NewShare::disk("scratch", r"C:\scratch"), +)?; +``` + +## Cargo features + +Both features are off by default — no forced dependencies: + +- `tracing` — emit [`tracing`](https://docs.rs/tracing) debug/trace events for + every Windows API call. +- `zeroize` — wipe password buffers (the stored `String` and the transient + UTF-16 copies) when they are dropped. + +## Platform support + +Windows only. On other targets the crate compiles to nothing, so it is safe +to keep in cross-platform dependency trees; gate your usage behind +`#[cfg(windows)]`. MSRV is 1.85 (edition 2024). + +## Testing + +The integration tests need a real share and are ignored by default. Point +them at one and include them explicitly: + +```text +SAMBRS_TEST_SHARE=\\server\share +SAMBRS_TEST_USERNAME=DOMAIN\user +SAMBRS_TEST_PASSWORD=... +SAMBRS_TEST_LOCAL=1 # only if the share is local; enables the server:: tests + +cargo test -- --include-ignored +``` + +CI provisions a local user plus `\\localhost\sambrs-test` on a Windows runner +and runs the whole suite against it on every push. + +## Migrating from 0.1 + +`0.2` is a rework of the whole API; the most important changes: + +| 0.1 | 0.2 | +| --- | --- | +| `SmbShare::new(share, user, pass, Some('d'))` | `SmbShare::builder(share).credentials(user, pass).mount_on(DriveLetter::D).build()?` | +| `share.connect(persist, interactive)` | `share.connect_with(ConnectOptions::new().persist(persist).interactive(interactive))` | +| `share.disconnect(persist, force)` | `share.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` — note the rename: the old `persist: true` *removed* the persistence | +| `Error::CStringConversion` | `Error::InteriorNul` | + +Under the hood, 0.1 used the ANSI (`A`) API variants, which silently mangled +non-ASCII share names, user names, and passwords; 0.2 uses the wide (`W`) +variants throughout. Empty-string credentials are no longer the way to say +"use my logon credentials" — omit them instead (empty string means a real +empty password now, matching the Windows API). + ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file -for more details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) +file for more details. ## Special Thanks diff --git a/src/enumerate.rs b/src/enumerate.rs new file mode 100644 index 0000000..68eb65e --- /dev/null +++ b/src/enumerate.rs @@ -0,0 +1,249 @@ +//! Enumerate network resources via `WNetOpenEnumW` / `WNetEnumResourceW`: +//! active connections, remembered (persistent) connections, and the shares a +//! server exposes. +//! +//! ```no_run +//! // Everything \\fileserver offers: +//! for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { +//! println!("{:?}", resource?.remote_name); +//! } +//! # Ok::<(), sambrs::Error>(()) +//! ``` + +use crate::error::{Error, Result, wnet_extended_error}; +use crate::strings::{from_pwstr, len_u32, to_wide}; +use crate::trace::trace; +use std::collections::VecDeque; +use windows_sys::Win32::Foundation::{ + ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, HANDLE, NO_ERROR, +}; +use windows_sys::Win32::NetworkManagement::WNet; + +/// An owned snapshot of one `NETRESOURCEW` entry. +/// +/// The `scope`, `resource_type`, `display_type`, and `usage` fields carry the +/// raw `RESOURCE_*`, `RESOURCETYPE_*`, `RESOURCEDISPLAYTYPE_*`, and +/// `RESOURCEUSAGE_*` values from `winnetwk.h` (available as constants in +/// `windows_sys::Win32::NetworkManagement::WNet`). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct NetResource { + pub scope: u32, + pub resource_type: u32, + pub display_type: u32, + pub usage: u32, + /// Local device when this entry is a redirected connection, e.g. `"Z:"`. + pub local_name: Option, + /// Remote name, e.g. `\\server\share`. + pub remote_name: Option, + pub comment: Option, + pub provider: Option, +} + +impl NetResource { + /// Whether this resource is a container (server, domain) that can itself + /// be enumerated. + #[must_use] + pub fn is_container(&self) -> bool { + self.usage & WNet::RESOURCEUSAGE_CONTAINER != 0 + } + + /// Whether this resource can be connected to. + #[must_use] + pub fn is_connectable(&self) -> bool { + self.usage & WNet::RESOURCEUSAGE_CONNECTABLE != 0 + } + + fn from_raw(raw: &WNet::NETRESOURCEW) -> Self { + // SAFETY: the pointers in a NETRESOURCEW returned by + // WNetEnumResourceW are either null or valid nul-terminated strings + // within the enumeration buffer, which is alive for this call. + unsafe { + Self { + scope: raw.dwScope, + resource_type: raw.dwType, + display_type: raw.dwDisplayType, + usage: raw.dwUsage, + local_name: from_pwstr(raw.lpLocalName), + remote_name: from_pwstr(raw.lpRemoteName), + comment: from_pwstr(raw.lpComment), + provider: from_pwstr(raw.lpProvider), + } + } + } +} + +/// Iterator over enumerated [`NetResource`] entries. Closes the enumeration +/// handle on drop. +#[derive(Debug)] +pub struct Resources { + handle: HANDLE, + batch: VecDeque, + finished: bool, +} + +impl Iterator for Resources { + type Item = Result; + + fn next(&mut self) -> Option { + loop { + if let Some(resource) = self.batch.pop_front() { + return Some(Ok(resource)); + } + if self.finished { + return None; + } + if let Err(e) = self.fill() { + self.finished = true; + return Some(Err(e)); + } + } + } +} + +impl Resources { + fn fill(&mut self) -> Result<()> { + // 16 KiB, u64 elements to keep NETRESOURCEW-aligned. + let mut buf = vec![0u64; 2048]; + loop { + let mut count = u32::MAX; // as many entries as fit + let mut size = len_u32(buf.len() * size_of::()); + // SAFETY: `buf` outlives the call; `size` is its size in bytes. + let status = unsafe { + WNet::WNetEnumResourceW( + self.handle, + &raw mut count, + buf.as_mut_ptr().cast(), + &raw mut size, + ) + }; + match status { + NO_ERROR => { + trace!("WNetEnumResourceW returned {count} entries"); + // SAFETY: on success the buffer starts with `count` + // NETRESOURCEW entries; the strings they point to live in + // `buf` and are copied out immediately by `from_raw`. + let entries = unsafe { + std::slice::from_raw_parts( + buf.as_ptr().cast::(), + count as usize, + ) + }; + self.batch.extend(entries.iter().map(NetResource::from_raw)); + return Ok(()); + } + ERROR_NO_MORE_ITEMS => { + self.finished = true; + return Ok(()); + } + // Buffer too small for a single entry; `size` holds the + // required size in bytes. + ERROR_MORE_DATA => { + buf = vec![0u64; (size as usize).div_ceil(size_of::())]; + } + ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), + code => return Err(Error::from_status(code)), + } + } + } +} + +impl Drop for Resources { + fn drop(&mut self) { + // SAFETY: the handle came from WNetOpenEnumW and is closed only here. + unsafe { + WNet::WNetCloseEnum(self.handle); + } + } +} + +fn open( + scope: u32, + resource_type: u32, + usage: u32, + root_remote: Option<&str>, +) -> Result { + let remote = root_remote.map(to_wide).transpose()?; + let root = remote.as_ref().map(|remote| WNet::NETRESOURCEW { + dwScope: 0, + dwType: 0, + dwDisplayType: 0, + dwUsage: WNet::RESOURCEUSAGE_CONTAINER, + lpLocalName: std::ptr::null_mut(), + lpRemoteName: remote.as_ptr().cast_mut(), + lpComment: std::ptr::null_mut(), + lpProvider: std::ptr::null_mut(), + }); + + let mut handle: HANDLE = std::ptr::null_mut(); + // SAFETY: `root` (when present) and its string live until after the call. + let status = unsafe { + WNet::WNetOpenEnumW( + scope, + resource_type, + usage, + root.as_ref().map_or(std::ptr::null(), std::ptr::from_ref), + &raw mut handle, + ) + }; + match status { + NO_ERROR => Ok(Resources { + handle, + batch: VecDeque::new(), + finished: false, + }), + ERROR_EXTENDED_ERROR => Err(wnet_extended_error()), + code => Err(Error::from_status(code)), + } +} + +/// Currently connected resources (`RESOURCE_CONNECTED`) — every active +/// redirection and deviceless connection of the calling user. +/// +/// # Errors +/// See [`Error`]. +pub fn connections() -> Result { + open(WNet::RESOURCE_CONNECTED, WNet::RESOURCETYPE_ANY, 0, None) +} + +/// Remembered (persistent) connections (`RESOURCE_REMEMBERED`) — restored at +/// logon whether or not they are currently connected. +/// +/// # Errors +/// See [`Error`]. +pub fn remembered() -> Result { + open(WNet::RESOURCE_REMEMBERED, WNet::RESOURCETYPE_ANY, 0, None) +} + +/// The resources a server exposes (`RESOURCE_GLOBALNET` rooted at `server`, +/// e.g. `r"\\fileserver"`): its shares, including shared printers. +/// +/// The server may require an authenticated connection (e.g. to `IPC$`) +/// before it lets you enumerate. +/// +/// # Errors +/// [`Error::BadNetName`] / [`Error::NoNetOrBadPath`] when the server cannot +/// be found, [`Error::AccessDenied`] when it refuses anonymous enumeration. +pub fn server_shares(server: &str) -> Result { + open( + WNet::RESOURCE_GLOBALNET, + WNet::RESOURCETYPE_ANY, + 0, + Some(server), + ) +} + +/// Fully general enumeration — pass raw `RESOURCE_*` scope, `RESOURCETYPE_*` +/// type, and `RESOURCEUSAGE_*` usage values, and optionally the remote name +/// of a container to enumerate (as in [`server_shares`]). +/// +/// # Errors +/// See [`Error`]. +pub fn resources_raw( + scope: u32, + resource_type: u32, + usage: u32, + root_remote: Option<&str>, +) -> Result { + open(scope, resource_type, usage, root_remote) +} diff --git a/src/error.rs b/src/error.rs index 345387f..d70d8c5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,14 +1,40 @@ -use std::ffi::NulError; use thiserror::Error; +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_ALREADY_ASSIGNED, ERROR_BAD_DEV_TYPE, ERROR_BAD_DEVICE, + ERROR_BAD_NET_NAME, ERROR_BAD_PROFILE, ERROR_BAD_PROVIDER, ERROR_BAD_USERNAME, ERROR_BUSY, + ERROR_CANCELLED, ERROR_CANNOT_OPEN_PROFILE, ERROR_CONNECTION_UNAVAIL, ERROR_DEV_NOT_EXIST, + ERROR_DEVICE_ALREADY_REMEMBERED, ERROR_DEVICE_IN_USE, ERROR_EXTENDED_ERROR, + ERROR_INVALID_ADDRESS, ERROR_INVALID_LEVEL, ERROR_INVALID_PARAMETER, ERROR_INVALID_PASSWORD, + ERROR_LOGON_FAILURE, ERROR_NO_NET_OR_BAD_PATH, ERROR_NO_NETWORK, ERROR_NOT_CONNECTED, + ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_SUPPORTED, ERROR_OPEN_FILES, + ERROR_SESSION_CREDENTIAL_CONFLICT, NO_ERROR, +}; +use windows_sys::Win32::NetworkManagement::NetManagement::{ + NERR_ClientNameNotFound as NERR_CLIENT_NAME_NOT_FOUND, + NERR_DuplicateShare as NERR_DUPLICATE_SHARE, NERR_FileIdNotFound as NERR_FILE_ID_NOT_FOUND, + NERR_NetNameNotFound as NERR_NET_NAME_NOT_FOUND, NERR_Success as NERR_SUCCESS, + NERR_UnknownDevDir as NERR_UNKNOWN_DEV_DIR, NERR_UserNotFound as NERR_USER_NOT_FOUND, +}; +use windows_sys::Win32::NetworkManagement::WNet; pub type Result = std::result::Result; -#[allow(clippy::enum_variant_names)] -#[derive(Error, Debug, PartialEq)] +/// Errors returned by sambrs operations. +/// +/// Most variants map 1:1 to a documented Windows error code; +/// [`Error::raw_os_error`] returns the underlying code. The enum is +/// `#[non_exhaustive]` so new variants can be added as more of the Windows +/// API surface is wrapped. +#[non_exhaustive] +#[derive(Error, Debug, Clone, PartialEq, Eq)] pub enum Error { - #[error("Failed to convert share to CString")] - CStringConversion(#[from] NulError), + // ── input validation (no OS error code) ──────────────────────────────── + #[error("input string contains an interior NUL, which cannot be passed to the Windows API")] + InteriorNul, + #[error("'{0}' is not a valid drive letter (expected A-Z)")] + InvalidDriveLetter(char), + // ── WNet connect/disconnect ──────────────────────────────────────────── #[error("The caller does not have access to the network resource.")] AccessDenied, #[error( @@ -45,10 +71,6 @@ pub enum Error { "The local device name has a remembered connection to another network resource. This error is returned if an entry for the device specified by lpLocalName member of the NETRESOURCE structure pointed to by the lpNetResource parameter specifies a value that is already in the user profile for a different connection than that specified in the lpNetResource parameter." )] DeviceAlreadyRemembered, - #[error( - "A network-specific error occurred. Call the WNetGetLastError function to obtain a description of the error." - )] - ExtendedError, #[error( "An attempt was made to access an invalid address. This error is returned if the dwFlags parameter specifies a value of CONNECT_REDIRECT, but the lpLocalName member of the NETRESOURCE structure pointed to by the lpNetResource parameter was unspecified." )] @@ -79,6 +101,207 @@ pub enum Error { NotConnected, #[error("There are open files, and the fForce parameter is FALSE.")] OpenFiles, - #[error("Unknown error {0}.")] + /// A network-specific error reported by the network provider, resolved via + /// `WNetGetLastErrorW`. `code` is the provider's own error code. + #[error( + "A network-specific error occurred (code {code}): {description} [provider: {provider}]" + )] + ExtendedError { + code: u32, + description: String, + provider: String, + }, + + // ── introspection ────────────────────────────────────────────────────── + #[error( + "The device is not currently connected, but it is a remembered (persistent) connection." + )] + ConnectionUnavailable, + #[error("The request is not supported for this resource.")] + NotSupported, + #[error("The specified device does not exist.")] + DeviceNotFound, + + // ── netapi32 share/session/file administration ───────────────────────── + #[error("The share name does not exist on this server.")] + NetNameNotFound, + #[error("The share name is already in use on this server.")] + DuplicateShare, + #[error("The device or directory does not exist.")] + UnknownDeviceOrDirectory, + #[error("A session does not exist with that computer name.")] + ClientNameNotFound, + #[error("The user name could not be found.")] + UserNotFound, + #[error("There is not an open file with that identification number.")] + FileIdNotFound, + #[error("The system call level is not correct.")] + InvalidLevel, + #[error("Not enough memory is available to complete the operation.")] + NotEnoughMemory, + + /// Any status code without a dedicated variant. The message is resolved + /// through the system message table where possible. + #[error("Windows error {code}: {msg}", code = .0, msg = os_message(*.0))] Other(u32), } + +fn os_message(code: u32) -> String { + #[allow(clippy::cast_possible_wrap)] + std::io::Error::from_raw_os_error(code as i32).to_string() +} + +impl Error { + /// Map a raw Windows / `NERR_*` status code to an [`Error`]. + /// + /// `ERROR_EXTENDED_ERROR` is intentionally *not* resolved here — `WNet` call + /// sites intercept it and fetch the provider details via + /// [`wnet_extended_error`]. + pub(crate) fn from_status(code: u32) -> Self { + match code { + ERROR_ACCESS_DENIED => Self::AccessDenied, + ERROR_ALREADY_ASSIGNED => Self::AlreadyAssigned, + ERROR_BAD_DEV_TYPE => Self::BadDevType, + ERROR_BAD_DEVICE => Self::BadDevice, + ERROR_BAD_NET_NAME => Self::BadNetName, + ERROR_BAD_PROFILE => Self::BadProfile, + ERROR_BAD_PROVIDER => Self::BadProvider, + ERROR_BAD_USERNAME => Self::BadUsername, + ERROR_BUSY => Self::Busy, + ERROR_CANCELLED => Self::Cancelled, + ERROR_CANNOT_OPEN_PROFILE => Self::CannotOpenProfile, + ERROR_CONNECTION_UNAVAIL => Self::ConnectionUnavailable, + ERROR_DEV_NOT_EXIST => Self::DeviceNotFound, + ERROR_DEVICE_ALREADY_REMEMBERED => Self::DeviceAlreadyRemembered, + ERROR_DEVICE_IN_USE => Self::DeviceInUse, + ERROR_INVALID_ADDRESS => Self::InvalidAddress, + ERROR_INVALID_LEVEL => Self::InvalidLevel, + ERROR_INVALID_PARAMETER => Self::InvalidParameter, + ERROR_INVALID_PASSWORD => Self::InvalidPassword, + ERROR_LOGON_FAILURE => Self::LogonFailure, + ERROR_NOT_CONNECTED => Self::NotConnected, + ERROR_NOT_ENOUGH_MEMORY => Self::NotEnoughMemory, + ERROR_NOT_SUPPORTED => Self::NotSupported, + ERROR_NO_NET_OR_BAD_PATH => Self::NoNetOrBadPath, + ERROR_NO_NETWORK => Self::NoNetwork, + ERROR_OPEN_FILES => Self::OpenFiles, + ERROR_SESSION_CREDENTIAL_CONFLICT => Self::SessionCredentialConflict, + NERR_CLIENT_NAME_NOT_FOUND => Self::ClientNameNotFound, + NERR_DUPLICATE_SHARE => Self::DuplicateShare, + NERR_FILE_ID_NOT_FOUND => Self::FileIdNotFound, + NERR_NET_NAME_NOT_FOUND => Self::NetNameNotFound, + NERR_UNKNOWN_DEV_DIR => Self::UnknownDeviceOrDirectory, + NERR_USER_NOT_FOUND => Self::UserNotFound, + other => Self::Other(other), + } + } + + /// The underlying Windows error code, when this error originated from a + /// Windows API call. + /// + /// Returns `None` for input-validation errors that never reached the OS + /// ([`Error::InteriorNul`], [`Error::InvalidDriveLetter`]). For + /// [`Error::ExtendedError`] this is `ERROR_EXTENDED_ERROR` (1208); the + /// provider-specific code is in the variant itself. + #[must_use] + pub fn raw_os_error(&self) -> Option { + match self { + Self::InteriorNul | Self::InvalidDriveLetter(_) => None, + Self::AccessDenied => Some(ERROR_ACCESS_DENIED), + Self::AlreadyAssigned => Some(ERROR_ALREADY_ASSIGNED), + Self::BadDevType => Some(ERROR_BAD_DEV_TYPE), + Self::BadDevice => Some(ERROR_BAD_DEVICE), + Self::BadNetName => Some(ERROR_BAD_NET_NAME), + Self::BadProfile => Some(ERROR_BAD_PROFILE), + Self::BadProvider => Some(ERROR_BAD_PROVIDER), + Self::BadUsername => Some(ERROR_BAD_USERNAME), + Self::Busy => Some(ERROR_BUSY), + Self::Cancelled => Some(ERROR_CANCELLED), + Self::CannotOpenProfile => Some(ERROR_CANNOT_OPEN_PROFILE), + Self::ConnectionUnavailable => Some(ERROR_CONNECTION_UNAVAIL), + Self::DeviceNotFound => Some(ERROR_DEV_NOT_EXIST), + Self::DeviceAlreadyRemembered => Some(ERROR_DEVICE_ALREADY_REMEMBERED), + Self::DeviceInUse => Some(ERROR_DEVICE_IN_USE), + Self::ExtendedError { .. } => Some(ERROR_EXTENDED_ERROR), + Self::InvalidAddress => Some(ERROR_INVALID_ADDRESS), + Self::InvalidLevel => Some(ERROR_INVALID_LEVEL), + Self::InvalidParameter => Some(ERROR_INVALID_PARAMETER), + Self::InvalidPassword => Some(ERROR_INVALID_PASSWORD), + Self::LogonFailure => Some(ERROR_LOGON_FAILURE), + Self::NotConnected => Some(ERROR_NOT_CONNECTED), + Self::NotEnoughMemory => Some(ERROR_NOT_ENOUGH_MEMORY), + Self::NotSupported => Some(ERROR_NOT_SUPPORTED), + Self::NoNetOrBadPath => Some(ERROR_NO_NET_OR_BAD_PATH), + Self::NoNetwork => Some(ERROR_NO_NETWORK), + Self::OpenFiles => Some(ERROR_OPEN_FILES), + Self::SessionCredentialConflict => Some(ERROR_SESSION_CREDENTIAL_CONFLICT), + Self::ClientNameNotFound => Some(NERR_CLIENT_NAME_NOT_FOUND), + Self::DuplicateShare => Some(NERR_DUPLICATE_SHARE), + Self::FileIdNotFound => Some(NERR_FILE_ID_NOT_FOUND), + Self::NetNameNotFound => Some(NERR_NET_NAME_NOT_FOUND), + Self::UnknownDeviceOrDirectory => Some(NERR_UNKNOWN_DEV_DIR), + Self::UserNotFound => Some(NERR_USER_NOT_FOUND), + Self::Other(code) => Some(*code), + } + } +} + +impl From for std::io::Error { + fn from(e: Error) -> Self { + match e.raw_os_error() { + #[allow(clippy::cast_possible_wrap)] + Some(code) => Self::from_raw_os_error(code as i32), + None => Self::new(std::io::ErrorKind::InvalidInput, e), + } + } +} + +/// Turn a `WNet` status code into a `Result`, resolving `ERROR_EXTENDED_ERROR` +/// into the provider-specific error details. +pub(crate) fn check_wnet(status: u32) -> Result<()> { + match status { + NO_ERROR => Ok(()), + ERROR_EXTENDED_ERROR => Err(wnet_extended_error()), + code => Err(Error::from_status(code)), + } +} + +/// Turn a netapi32 status code into a `Result`. +pub(crate) fn check_net(status: u32) -> Result<()> { + if status == NERR_SUCCESS { + Ok(()) + } else { + Err(Error::from_status(status)) + } +} + +/// Fetch the provider-specific error behind `ERROR_EXTENDED_ERROR` via +/// `WNetGetLastErrorW`. Must be called on the same thread that received the +/// failing status, before any other `WNet` call. +pub(crate) fn wnet_extended_error() -> Error { + let mut code = 0u32; + let mut description = [0u16; 512]; + let mut provider = [0u16; 256]; + let status = unsafe { + WNet::WNetGetLastErrorW( + &raw mut code, + description.as_mut_ptr(), + crate::strings::len_u32(description.len()), + provider.as_mut_ptr(), + crate::strings::len_u32(provider.len()), + ) + }; + if status == NO_ERROR { + Error::ExtendedError { + code, + description: crate::strings::from_wide_buf(&description), + provider: crate::strings::from_wide_buf(&provider), + } + } else { + Error::ExtendedError { + code: 0, + description: String::from(""), + provider: String::new(), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 127532c..6960dee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,357 +1,89 @@ +#![cfg(windows)] #![warn(clippy::pedantic)] -//! A tiny ergonomic wrapper around `WNetAddConnection2A` and `WNetCancelConnection2A`. The goal is -//! to offer an easy to use interface to connect to SMB network shares on Windows. +//! Safe and flexible bindings for Windows SMB share operations. //! //! Sam -> SMB -> Rust -> Samba is taken!? -> sambrs //! -//! # How To +//! The crate wraps three areas of the Windows API, aiming to expose the full +//! flexibility of the underlying calls behind a safe interface: //! -//! Instantiate an `SmbShare` with an optional local Windows mount point and establish a -//! connection. +//! - **Connecting** ([`SmbShare`]): `WNetAddConnection2W`, +//! `WNetUseConnectionW`, and `WNetCancelConnection2W` — connect to a share +//! (deviceless, mounted on a drive letter of your choice, or a letter +//! Windows picks), with every documented `CONNECT_*` flag available through +//! [`ConnectOptions`], including per-connection SMB signing/encryption +//! enforcement. An optional RAII [`Connection`] guard disconnects on drop. +//! - **Querying and enumerating** ([`query`], [`enumerate`]): +//! `WNetGetConnectionW`, `WNetGetUserW`, `WNetGetUniversalNameW`, and the +//! `WNetOpenEnumW` family — inspect existing connections, list remembered +//! ones, and enumerate the shares a server exposes. +//! - **Administering** ([`server`]): the netapi32 `NetShare*`, `NetSession*`, +//! `NetFile*`, and `NetConnectionEnum` functions — create and delete +//! shares, and manage sessions and open files, locally or on a remote +//! server. //! -//! When calling the connect method, you have the option to persist the connection across user -//! login sessions and to enable interactive mode. Interactive mode will block until the user -//! either provides a correct password or cancels, resulting in a `Canceled` error. +//! All strings cross the FFI boundary as UTF-16 (the `W` API variants), so +//! share names, user names, and passwords with any Unicode content work +//! correctly. +//! +//! # Connecting to a share +//! +//! Instantiate an [`SmbShare`] and establish a connection. Once connected +//! (mounted or deviceless), `std::fs` works on it like on any local path: //! //! ```no_run -//! use sambrs::SmbShare; +//! use sambrs::{ConnectOptions, DriveLetter, SmbShare}; //! -//! let share = SmbShare::new(r"\\server\share", "user", "pass", Some('D')); +//! let share = SmbShare::builder(r"\\server.local\share") +//! .credentials(r"LOGONDOMAIN\user", "pass") +//! .mount_on(DriveLetter::D) +//! .build()?; //! -//! match share.connect(false, false) { -//! Ok(()) => println!("Connected successfully!"), -//! Err(e) => eprintln!("Failed to connect: {}", e), -//! } +//! share.connect_with( +//! ConnectOptions::new() +//! .persist(true) // restore the mapping at logon +//! .require_privacy(true), // enforce SMB encryption +//! )?; //! //! // use std::fs as if D:\ was a local directory -//! dbg!(std::fs::metadata(r"D:\").unwrap().is_dir()); +//! println!("{}", std::fs::metadata(r"D:\")?.is_dir()); +//! # Ok::<(), Box>(()) //! ``` +//! +//! Without credentials, the connection authenticates as the logged-on user; +//! [`SmbShare::new`] is the shortest path to that: +//! +//! ```no_run +//! use sambrs::SmbShare; +//! +//! let share = SmbShare::new(r"\\server.local\share"); +//! share.connect()?; +//! # Ok::<(), sambrs::Error>(()) +//! ``` +//! +//! # Cargo features +//! +//! - `tracing` — emit [`tracing`](https://docs.rs/tracing) debug/trace events +//! for every Windows API call. +//! - `zeroize` — wipe password buffers (the stored `String` and the transient +//! UTF-16 copies) when they are dropped. +//! +//! # Platform support +//! +//! This crate is Windows-only; on other targets it compiles to nothing. Gate +//! usage behind `#[cfg(windows)]` in cross-platform projects. mod error; +mod options; +mod share; +mod strings; +mod trace; -pub use error::{Error, Result}; -use std::ffi::CString; -use tracing::{debug, trace}; -use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_ALREADY_ASSIGNED, ERROR_BAD_DEV_TYPE, ERROR_BAD_DEVICE, - ERROR_BAD_NET_NAME, ERROR_BAD_PROFILE, ERROR_BAD_PROVIDER, ERROR_BAD_USERNAME, ERROR_BUSY, - ERROR_CANCELLED, ERROR_CANNOT_OPEN_PROFILE, ERROR_DEVICE_ALREADY_REMEMBERED, - ERROR_DEVICE_IN_USE, ERROR_EXTENDED_ERROR, ERROR_INVALID_ADDRESS, ERROR_INVALID_PARAMETER, - ERROR_INVALID_PASSWORD, ERROR_LOGON_FAILURE, ERROR_NO_NET_OR_BAD_PATH, ERROR_NO_NETWORK, - ERROR_NOT_CONNECTED, ERROR_OPEN_FILES, ERROR_SESSION_CREDENTIAL_CONFLICT, FALSE, NO_ERROR, - TRUE, -}; -use windows_sys::Win32::NetworkManagement::WNet; - -pub struct SmbShare { - share: String, - username: String, - password: String, - mount_on: Option, -} - -impl SmbShare { - /// Create an `SmbShare` representation to connect to. - /// - /// Optionally specify `mount_on` to map the SMB share to a local device. Otherwise it will be - /// a deviceless connection. Case insensitive. - /// - /// # Example - /// - /// ```no_run - /// let share = sambrs::SmbShare::new(r"\\server.local\share", r"LOGONDOMAIN\user", "pass", None); - /// ``` - pub fn new( - share: impl Into, - username: impl Into, - password: impl Into, - mount_on: Option, - ) -> Self { - Self { - share: share.into(), - username: username.into(), - password: password.into(), - mount_on, - } - } - - /// Connect to the SMB share. Connecting multiple times works fine in deviceless mode but fails - /// with a local mount point. - /// - /// - `persist` will remember the connection and restore when the user logs off and on again. No-op - /// if `mount_on` is `None` - /// - `interactive` will prompt the user for a password instead of failing with `Error::InvalidPassword` - /// - /// # Some excerpts from the [Microsoft docs](https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2a) - /// - /// `persist` (`CONNECT_UPDATE_PROFILE`): The network resource connection should be remembered. If this bit - /// flag is set, the operating system automatically attempts to restore the connection when the - /// user logs on. - /// - /// The operating system remembers only successful connections that redirect local devices. It does - /// not remember connections that are unsuccessful or deviceless connections. (A deviceless - /// connection occurs when the `lpLocalName` member is NULL or points to an empty string.) - /// - /// If this bit flag is clear, the operating system does not try to restore the connection when the - /// user logs on. - /// - /// `!persist` (`CONNECT_TEMPORARY`): The network resource connection should not be remembered. If this flag is - /// set, the operating system will not attempt to restore the connection when the user logs on - /// again. - /// - /// `interactive` (`CONNECT_INTERACTIVE`): If this flag is set, the operating system may interact with the user for - /// authentication purposes. - /// - /// # Errors - /// This method will error if Windows is unable to connect to the SMB share. - pub fn connect(&self, persist: bool, interactive: bool) -> Result<()> { - let local_name = self - .mount_on - .map(|ln| format!("{ln}:")) - .map(CString::new) - .transpose()?; - - let local_name = match local_name { - Some(ref cstring) => cstring.as_c_str().as_ptr().cast::().cast_mut(), - None => std::ptr::null_mut(), - }; - - let mut flags = 0u32; - - if persist && self.mount_on.is_some() { - flags |= WNet::CONNECT_UPDATE_PROFILE; - } else { - flags |= WNet::CONNECT_TEMPORARY; - } - - if interactive { - flags |= WNet::CONNECT_INTERACTIVE; - } - - debug!("Connection flags: {flags:#?}"); - - let share = CString::new(&*self.share)?; - let username = CString::new(&*self.username)?; - let password = CString::new(&*self.password)?; - - // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcea - let mut netresource = WNet::NETRESOURCEA { - dwDisplayType: 0, // ignored by WNetAddConnection2A - dwScope: 0, // ignored by WNetAddConnection2A - dwType: WNet::RESOURCETYPE_DISK, - dwUsage: 0, // ignored by WNetAddConnection2A - lpLocalName: local_name, - lpRemoteName: share.as_c_str().as_ptr().cast::().cast_mut(), - lpComment: std::ptr::null_mut(), // ignored by WNetAddConnection2A - lpProvider: std::ptr::null_mut(), // Microsoft docs: You should set this member only if you know the network provider you want to use. - // Otherwise, let the operating system determine which provider the network name maps to. - }; - - trace!("Trying to connect to {}", self.share); +pub mod enumerate; +pub mod query; +pub mod server; - // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2a - let connection_result = unsafe { - let username = username.as_ref().as_ptr(); - let password = password.as_ref().as_ptr(); - WNet::WNetAddConnection2A( - std::ptr::from_mut::(&mut netresource), - password.cast::(), - username.cast::(), - flags, - ) - }; - - debug!("Connection result: {connection_result:#?}"); - - let connection_result = match connection_result { - NO_ERROR => Ok(()), - ERROR_ACCESS_DENIED => Err(Error::AccessDenied), - ERROR_ALREADY_ASSIGNED => Err(Error::AlreadyAssigned), - ERROR_BAD_DEV_TYPE => Err(Error::BadDevType), - ERROR_BAD_DEVICE => Err(Error::BadDevice), - ERROR_BAD_NET_NAME => Err(Error::BadNetName), - ERROR_BAD_PROFILE => Err(Error::BadProfile), - ERROR_BAD_PROVIDER => Err(Error::BadProvider), - ERROR_BAD_USERNAME => Err(Error::BadUsername), - ERROR_BUSY => Err(Error::Busy), - ERROR_CANCELLED => Err(Error::Cancelled), - ERROR_CANNOT_OPEN_PROFILE => Err(Error::CannotOpenProfile), - ERROR_DEVICE_ALREADY_REMEMBERED => Err(Error::DeviceAlreadyRemembered), - ERROR_EXTENDED_ERROR => Err(Error::ExtendedError), - ERROR_INVALID_ADDRESS => Err(Error::InvalidAddress), - ERROR_INVALID_PARAMETER => Err(Error::InvalidParameter), - ERROR_INVALID_PASSWORD => Err(Error::InvalidPassword), - ERROR_LOGON_FAILURE => Err(Error::LogonFailure), - ERROR_NO_NET_OR_BAD_PATH => Err(Error::NoNetOrBadPath), - ERROR_NO_NETWORK => Err(Error::NoNetwork), - ERROR_SESSION_CREDENTIAL_CONFLICT => Err(Error::SessionCredentialConflict), - _ => Err(Error::Other(connection_result)), - }; - - match connection_result { - Ok(()) => { - trace!("Successfully connected"); - } - Err(ref e) => trace!("Connection failed: {e}"), - }; - - connection_result - } - - /// Disconnect from the SMB share. - /// - /// `persist` (`CONNECT_UPDATE_PROFILE`): The system updates the user profile with the - /// information that the connection is no longer a persistent one. The system will not restore - /// this connection during subsequent logon operations. (Disconnecting resources using remote - /// names has no effect on persistent connections.) - /// - /// `force`: Specifies whether the disconnection should occur if there are open files or jobs - /// on the connection. If this parameter is FALSE, the function fails if there are open files - /// or jobs. - /// - /// # Errors - /// This method will return an error if Windows is unable to disconnect from the smb share. - pub fn disconnect(&self, persist: bool, force: bool) -> Result<()> { - let local_name = self - .mount_on - .map(|ln| format!("{ln}:")) - .map(CString::new) - .transpose()?; - - let resource_to_disconnect = local_name.unwrap_or(CString::new(&*self.share)?); - - let force = if force { TRUE } else { FALSE }; - - let persist = if persist && self.mount_on.is_some() { - WNet::CONNECT_UPDATE_PROFILE - } else { - 0 - }; - - let disconnect_result = unsafe { - WNet::WNetCancelConnection2A(resource_to_disconnect.as_ptr() as *mut u8, persist, force) - }; - - debug!("Disconnect result: {disconnect_result:#?}"); - - let disconnect_result = match disconnect_result { - NO_ERROR => Ok(()), - ERROR_BAD_PROFILE => Err(Error::BadProfile), - ERROR_CANNOT_OPEN_PROFILE => Err(Error::CannotOpenProfile), - ERROR_DEVICE_IN_USE => Err(Error::DeviceInUse), - ERROR_EXTENDED_ERROR => Err(Error::ExtendedError), - ERROR_NOT_CONNECTED => Err(Error::NotConnected), - ERROR_OPEN_FILES => Err(Error::OpenFiles), - _ => Err(Error::Other(disconnect_result)), - }; - - match disconnect_result { - Ok(()) => trace!("Successfully disconnected"), - Err(ref e) => trace!("Disconnect failed: {e}"), - } - - disconnect_result - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // TODO: propper integration test setup - - const VALID_SHARE: &str = r"PUTYOURSHARE"; - const CORRECT_USERNAME: &str = r"PUTYOURUSER"; - const CORRECT_PASSWORD: &str = r"PUTYOURPASS"; - - const WRONG_SHARE: &str = r"\\thisisnotashare.local\Share-Name"; - const WRONG_PASSWORD: &str = r"pass"; - - // I really wanted to assert against a specific error, but lovely Windows sometimes returns - // `LogonFailure` and sometimes `InvalidPassword`. - #[test] - fn sad_non_interactive_does_not_prompt_and_returns_error() { - let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, WRONG_PASSWORD, None); - let connection = share.connect(false, false); - assert!(connection.is_err()); - if let Err(e) = connection { - assert!(e == Error::InvalidPassword || e == Error::LogonFailure); - } - } - - #[test] - fn sad_non_existent_share() { - let share = SmbShare::new(WRONG_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, None); - let connection = share.connect(false, false); - assert!(connection.is_err()); - if let Err(e) = connection { - assert_eq!(e, Error::BadNetName); - } - } - - #[test] - fn happy_mount_on_works_and_does_not_persist() { - let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('s')); - let connection = share.connect(false, false); - assert!(connection.is_ok()); - assert!(std::path::Path::new(r"s:\").is_dir()); - let disconnect = share.disconnect(false, false); - assert!(disconnect.is_ok()); - } - - #[test] - fn happy_deviceless_works() { - let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, None); - let connection = share.connect(false, false); - assert!(connection.is_ok()); - assert!(std::path::Path::new(VALID_SHARE).is_dir()); - let disconnect = share.disconnect(false, false); - assert!(disconnect.is_ok()); - } - - #[test] - fn happy_deviceless_reconnecting_is_fine() { - let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, None); - let connection = share.connect(false, false); - assert!(connection.is_ok()); - let connection = share.connect(false, false); - assert!(connection.is_ok()); - assert!(std::path::Path::new(VALID_SHARE).is_dir()); - let disconnect = share.disconnect(false, false); - assert!(disconnect.is_ok()); - } - - #[test] - fn sad_mounted_reconnecting_returns_already_assigned_error() { - let share = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('s')); - let connection = share.connect(false, false); - assert!(connection.is_ok()); - assert!(std::path::Path::new(r"s:\").is_dir()); - let connection = share.connect(false, false); - assert!(connection.is_err()); - if let Err(e) = connection { - assert_eq!(e, Error::AlreadyAssigned); - } - let disconnect = share.disconnect(false, false); - assert!(disconnect.is_ok()); - } - - #[test] - fn happy_connecting_multiple_letters_to_same_share_works() { - let share_one = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('s')); - let connection1 = share_one.connect(false, false); - assert!(connection1.is_ok()); - let share_two = SmbShare::new(VALID_SHARE, CORRECT_USERNAME, CORRECT_PASSWORD, Some('t')); - let connection2 = share_two.connect(false, false); - assert!(connection2.is_ok()); - assert!(std::path::Path::new(r"s:\").is_dir()); - assert!(std::path::Path::new(r"t:\").is_dir()); - let share_one_disconnect = share_one.disconnect(false, false); - assert!(share_one_disconnect.is_ok()); - assert!(!std::path::Path::new(r"s:\").is_dir()); - let share_two_disconnect = share_two.disconnect(false, false); - assert!(share_two_disconnect.is_ok()); - assert!(!std::path::Path::new(r"t:\").is_dir()); - } -} +pub use error::{Error, Result}; +pub use options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; +pub use share::{Connection, SmbShare, SmbShareBuilder, cancel_connection}; diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..3207d5d --- /dev/null +++ b/src/options.rs @@ -0,0 +1,424 @@ +use crate::error::Error; +use windows_sys::Win32::Foundation::{FALSE, TRUE}; +use windows_sys::Win32::NetworkManagement::WNet; + +/// A local Windows drive letter (`A:` – `Z:`). +/// +/// Guarantees at compile time that a mount point is a valid device letter, +/// instead of failing at connect time with a confusing [`Error::BadDevice`]. +/// +/// ``` +/// use sambrs::DriveLetter; +/// +/// let letter = DriveLetter::try_from('d').unwrap(); +/// assert_eq!(letter, DriveLetter::D); +/// assert_eq!(letter.to_string(), "D:"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[rustfmt::skip] +pub enum DriveLetter { + A, B, C, D, E, F, G, H, I, J, K, L, M, + N, O, P, Q, R, S, T, U, V, W, X, Y, Z, +} + +impl DriveLetter { + /// The letter as an uppercase `char`. + #[must_use] + pub const fn as_char(self) -> char { + (b'A' + self as u8) as char + } + + /// The device name Windows expects, e.g. `"D:"`. + pub(crate) fn device(self) -> String { + format!("{}:", self.as_char()) + } +} + +impl TryFrom for DriveLetter { + type Error = Error; + + /// Case insensitive; anything outside `A-Z` returns + /// [`Error::InvalidDriveLetter`]. + fn try_from(c: char) -> Result { + #[rustfmt::skip] + const LETTERS: [DriveLetter; 26] = [ + DriveLetter::A, DriveLetter::B, DriveLetter::C, DriveLetter::D, DriveLetter::E, + DriveLetter::F, DriveLetter::G, DriveLetter::H, DriveLetter::I, DriveLetter::J, + DriveLetter::K, DriveLetter::L, DriveLetter::M, DriveLetter::N, DriveLetter::O, + DriveLetter::P, DriveLetter::Q, DriveLetter::R, DriveLetter::S, DriveLetter::T, + DriveLetter::U, DriveLetter::V, DriveLetter::W, DriveLetter::X, DriveLetter::Y, + DriveLetter::Z, + ]; + let upper = c.to_ascii_uppercase(); + if upper.is_ascii_uppercase() { + Ok(LETTERS[(upper as u8 - b'A') as usize]) + } else { + Err(Error::InvalidDriveLetter(c)) + } + } +} + +impl std::fmt::Display for DriveLetter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:", self.as_char()) + } +} + +/// The type of network resource to connect to (`dwType`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum ResourceType { + /// A disk share (`RESOURCETYPE_DISK`). The default. + #[default] + Disk, + /// A shared printer (`RESOURCETYPE_PRINT`). + Print, + /// Any resource type (`RESOURCETYPE_ANY`). + /// + /// Only valid for deviceless connections: Windows rejects it with + /// [`Error::InvalidParameter`] when a local device is redirected — + /// whether configured explicitly or chosen automatically by + /// [`SmbShare::connect_auto`](crate::SmbShare::connect_auto). + Any, +} + +impl ResourceType { + pub(crate) fn to_dword(self) -> u32 { + match self { + Self::Disk => WNet::RESOURCETYPE_DISK, + Self::Print => WNet::RESOURCETYPE_PRINT, + Self::Any => WNet::RESOURCETYPE_ANY, + } + } +} + +/// Options for [`SmbShare::connect_with`](crate::SmbShare::connect_with), +/// covering every documented `CONNECT_*` flag of `WNetAddConnection2W` / +/// `WNetUseConnectionW`. +/// +/// The default (`ConnectOptions::new()`) is a temporary, non-interactive +/// connection — the same behavior as +/// [`SmbShare::connect`](crate::SmbShare::connect). +/// +/// ``` +/// use sambrs::ConnectOptions; +/// +/// let opts = ConnectOptions::new() +/// .persist(true) +/// .require_privacy(true); // enforce SMB encryption +/// ``` +// One independent boolean per CONNECT_* flag is exactly the shape of the +// underlying API; a state machine would misrepresent it. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[must_use] +pub struct ConnectOptions { + persist: bool, + update_recent: bool, + interactive: bool, + prompt: bool, + commandline: bool, + redirect: bool, + current_media: bool, + save_credentials: bool, + reset_credentials: bool, + require_integrity: bool, + require_privacy: bool, + write_through: bool, + raw_flags: u32, +} + +impl ConnectOptions { + pub fn new() -> Self { + Self::default() + } + + /// `CONNECT_UPDATE_PROFILE`: remember the connection and restore it when + /// the user logs on again. + /// + /// From the [Microsoft docs](https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2w): + /// the operating system remembers only successful connections that + /// redirect local devices; deviceless connections are not remembered. + /// When this is `false` (the default), `CONNECT_TEMPORARY` is passed + /// instead. + pub fn persist(mut self, yes: bool) -> Self { + self.persist = yes; + self + } + + /// `CONNECT_UPDATE_RECENT`: keep the connection out of the recent + /// connection list unless it has a redirected local device associated + /// with it. + pub fn update_recent(mut self, yes: bool) -> Self { + self.update_recent = yes; + self + } + + /// `CONNECT_INTERACTIVE`: the operating system may interact with the user + /// for authentication purposes, e.g. by showing a password prompt instead + /// of failing with [`Error::InvalidPassword`]. + pub fn interactive(mut self, yes: bool) -> Self { + self.interactive = yes; + self + } + + /// `CONNECT_PROMPT`: always prompt for the user name and password instead + /// of first trying supplied or remembered credentials. Only valid + /// together with [`interactive`](Self::interactive). + pub fn prompt(mut self, yes: bool) -> Self { + self.prompt = yes; + self + } + + /// `CONNECT_COMMANDLINE`: prompt for authentication on the command line + /// instead of with a GUI dialog. Only valid together with + /// [`interactive`](Self::interactive); also a prerequisite for + /// [`save_credentials`](Self::save_credentials) and + /// [`reset_credentials`](Self::reset_credentials) to take effect. + pub fn commandline(mut self, yes: bool) -> Self { + self.commandline = yes; + self + } + + /// `CONNECT_REDIRECT`: force the redirection of a local device even if a + /// local device name was not specified. + pub fn redirect(mut self, yes: bool) -> Self { + self.redirect = yes; + self + } + + /// `CONNECT_CURRENT_MEDIA`: do not start a new media to establish the + /// connection (e.g. do not initiate a new dial-up connection). + pub fn current_media(mut self, yes: bool) -> Self { + self.current_media = yes; + self + } + + /// `CONNECT_CMD_SAVECRED`: if the operating system prompts for a + /// credential, it is saved by the credential manager. + /// + /// Windows ignores this flag unless [`interactive`](Self::interactive) + /// and [`commandline`](Self::commandline) are also set. + pub fn save_credentials(mut self, yes: bool) -> Self { + self.save_credentials = yes; + self + } + + /// `CONNECT_CRED_RESET`: reset the credentials for this connection that + /// are saved by the credential manager. + /// + /// Windows ignores this flag unless [`commandline`](Self::commandline) + /// is also set. + pub fn reset_credentials(mut self, yes: bool) -> Self { + self.reset_credentials = yes; + self + } + + /// `CONNECT_REQUIRE_INTEGRITY`: fail the connection if SMB signing cannot + /// be enforced. + pub fn require_integrity(mut self, yes: bool) -> Self { + self.require_integrity = yes; + self + } + + /// `CONNECT_REQUIRE_PRIVACY`: fail the connection if SMB encryption + /// cannot be enforced. + pub fn require_privacy(mut self, yes: bool) -> Self { + self.require_privacy = yes; + self + } + + /// `CONNECT_WRITE_THROUGH_SEMANTICS`: writes go through to the server + /// before the operation completes (no write caching). + pub fn write_through(mut self, yes: bool) -> Self { + self.write_through = yes; + self + } + + /// OR arbitrary raw `CONNECT_*` bits into the final flag value — an + /// escape hatch for flags this crate has no named setter for. The bits + /// are passed through verbatim; when they already contain + /// `CONNECT_UPDATE_PROFILE` or `CONNECT_TEMPORARY`, the automatic + /// `CONNECT_TEMPORARY` default is suppressed. + pub fn raw_flags(mut self, flags: u32) -> Self { + self.raw_flags = flags; + self + } + + pub(crate) fn to_flags(self) -> u32 { + let mut flags = self.raw_flags; + if self.persist { + flags |= WNet::CONNECT_UPDATE_PROFILE; + } else if flags & (WNet::CONNECT_UPDATE_PROFILE | WNet::CONNECT_TEMPORARY) == 0 { + flags |= WNet::CONNECT_TEMPORARY; + } + if self.update_recent { + flags |= WNet::CONNECT_UPDATE_RECENT; + } + if self.interactive { + flags |= WNet::CONNECT_INTERACTIVE; + } + if self.prompt { + flags |= WNet::CONNECT_PROMPT; + } + if self.commandline { + flags |= WNet::CONNECT_COMMANDLINE; + } + if self.redirect { + flags |= WNet::CONNECT_REDIRECT; + } + if self.current_media { + flags |= WNet::CONNECT_CURRENT_MEDIA; + } + if self.save_credentials { + flags |= WNet::CONNECT_CMD_SAVECRED; + } + if self.reset_credentials { + flags |= WNet::CONNECT_CRED_RESET; + } + if self.require_integrity { + flags |= WNet::CONNECT_REQUIRE_INTEGRITY; + } + if self.require_privacy { + flags |= WNet::CONNECT_REQUIRE_PRIVACY; + } + if self.write_through { + flags |= WNet::CONNECT_WRITE_THROUGH_SEMANTICS; + } + flags + } +} + +/// Options for [`SmbShare::disconnect_with`](crate::SmbShare::disconnect_with) +/// and [`cancel_connection`](crate::cancel_connection). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[must_use] +pub struct DisconnectOptions { + force: bool, + forget: bool, +} + +impl DisconnectOptions { + pub fn new() -> Self { + Self::default() + } + + /// Disconnect even if there are open files or jobs on the connection. + /// When `false` (the default), disconnecting fails with + /// [`Error::OpenFiles`] if anything is still open. + pub fn force(mut self, yes: bool) -> Self { + self.force = yes; + self + } + + /// `CONNECT_UPDATE_PROFILE`: update the user profile so this connection + /// is no longer persistent — the system will not restore it on the next + /// logon. Disconnecting by remote name (deviceless) has no effect on + /// persistent connections. + /// + /// Note: this was called `persist` in sambrs 0.1, which suggested the + /// opposite of what the flag does. + pub fn forget(mut self, yes: bool) -> Self { + self.forget = yes; + self + } + + pub(crate) fn flags(self) -> u32 { + if self.forget { + WNet::CONNECT_UPDATE_PROFILE + } else { + 0 + } + } + + pub(crate) fn force_bool(self) -> i32 { + if self.force { TRUE } else { FALSE } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drive_letter_from_char_is_case_insensitive() { + assert_eq!(DriveLetter::try_from('d').unwrap(), DriveLetter::D); + assert_eq!(DriveLetter::try_from('Z').unwrap(), DriveLetter::Z); + assert_eq!( + DriveLetter::try_from('é').unwrap_err(), + Error::InvalidDriveLetter('é') + ); + assert_eq!( + DriveLetter::try_from('1').unwrap_err(), + Error::InvalidDriveLetter('1') + ); + } + + #[test] + fn drive_letter_formats_as_device() { + assert_eq!(DriveLetter::A.device(), "A:"); + assert_eq!(DriveLetter::Z.to_string(), "Z:"); + } + + #[test] + fn default_options_are_temporary() { + assert_eq!(ConnectOptions::new().to_flags(), WNet::CONNECT_TEMPORARY); + } + + #[test] + fn persist_replaces_temporary() { + let flags = ConnectOptions::new().persist(true).to_flags(); + assert_eq!(flags, WNet::CONNECT_UPDATE_PROFILE); + } + + #[test] + fn all_flags_map() { + let flags = ConnectOptions::new() + .update_recent(true) + .interactive(true) + .prompt(true) + .commandline(true) + .redirect(true) + .current_media(true) + .save_credentials(true) + .reset_credentials(true) + .require_integrity(true) + .require_privacy(true) + .write_through(true) + .to_flags(); + assert_eq!( + flags, + WNet::CONNECT_TEMPORARY + | WNet::CONNECT_UPDATE_RECENT + | WNet::CONNECT_INTERACTIVE + | WNet::CONNECT_PROMPT + | WNet::CONNECT_COMMANDLINE + | WNet::CONNECT_REDIRECT + | WNet::CONNECT_CURRENT_MEDIA + | WNet::CONNECT_CMD_SAVECRED + | WNet::CONNECT_CRED_RESET + | WNet::CONNECT_REQUIRE_INTEGRITY + | WNet::CONNECT_REQUIRE_PRIVACY + | WNet::CONNECT_WRITE_THROUGH_SEMANTICS + ); + } + + #[test] + fn raw_flags_suppress_automatic_temporary() { + let flags = ConnectOptions::new() + .raw_flags(WNet::CONNECT_UPDATE_PROFILE) + .to_flags(); + assert_eq!(flags, WNet::CONNECT_UPDATE_PROFILE); + } + + #[test] + fn disconnect_options_map() { + assert_eq!(DisconnectOptions::new().flags(), 0); + assert_eq!(DisconnectOptions::new().force_bool(), FALSE); + assert_eq!( + DisconnectOptions::new().forget(true).flags(), + WNet::CONNECT_UPDATE_PROFILE + ); + assert_eq!(DisconnectOptions::new().force(true).force_bool(), TRUE); + } +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..b99db13 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,99 @@ +//! Query information about existing connections: which remote a device is +//! mapped to, which user a connection runs as, and UNC paths for local paths +//! on redirected drives. + +use crate::error::{Error, Result, wnet_extended_error}; +use crate::strings::{from_pwstr, len_u32, opt_ptr, to_wide}; +use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, NO_ERROR}; +use windows_sys::Win32::NetworkManagement::WNet; + +/// Call a `WNet` function that fills a wide-string output buffer, growing the +/// buffer when Windows reports `ERROR_MORE_DATA`. +fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { + let mut buf = vec![0u16; 256]; + for _ in 0..4 { + let mut len = len_u32(buf.len()); + match call(buf.as_mut_ptr(), &raw mut len) { + NO_ERROR => return Ok(crate::strings::from_wide_buf(&buf)), + // `len` now holds the required size in characters. + ERROR_MORE_DATA => buf = vec![0u16; len as usize + 1], + ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), + code => return Err(Error::from_status(code)), + } + } + Err(Error::Other(ERROR_MORE_DATA)) +} + +/// The remote name a redirected local device is connected to, via +/// `WNetGetConnectionW`. +/// +/// ```no_run +/// let remote = sambrs::query::get_connection("Z:")?; +/// assert_eq!(remote, r"\\server\share"); +/// # Ok::<(), sambrs::Error>(()) +/// ``` +/// +/// # Errors +/// [`Error::NotConnected`] if the device is not redirected, +/// [`Error::ConnectionUnavailable`] if it is remembered but not currently +/// connected, [`Error::BadDevice`] for invalid device names. +pub fn get_connection(local_device: &str) -> Result { + let device = to_wide(local_device)?; + // SAFETY: `device` outlives the call; the buffer is sized via `len`. + wide_out(|buf, len| unsafe { WNet::WNetGetConnectionW(device.as_ptr(), buf, len) }) +} + +/// The user name used to establish a connection, via `WNetGetUserW`. +/// +/// `connection` is a local device (`"Z:"`) or a remote name; `None` returns +/// the name of the current user of the process. +/// +/// # Errors +/// [`Error::NotConnected`] if the name is not a connected resource. +pub fn get_user(connection: Option<&str>) -> Result { + let name = connection.map(to_wide).transpose()?; + // SAFETY: `name` (when present) outlives the call. + wide_out(|buf, len| unsafe { WNet::WNetGetUserW(opt_ptr(name.as_deref()), buf, len) }) +} + +/// The UNC path for a local path on a redirected drive, via +/// `WNetGetUniversalNameW`. For example `Z:\dir\file.txt` becomes +/// `\\server\share\dir\file.txt`. +/// +/// # Errors +/// [`Error::NotSupported`] or [`Error::NotConnected`] when the path is not on +/// a network-redirected device. +pub fn get_universal_name(local_path: &str) -> Result { + let path = to_wide(local_path)?; + // u64 elements keep the buffer aligned for UNIVERSAL_NAME_INFOW. + let mut buf = vec![0u64; 128]; + for _ in 0..4 { + let mut size = len_u32(buf.len() * size_of::()); + // SAFETY: `path` and `buf` outlive the call; `size` tells Windows the + // buffer size in bytes. + let status = unsafe { + WNet::WNetGetUniversalNameW( + path.as_ptr(), + WNet::UNIVERSAL_NAME_INFO_LEVEL, + buf.as_mut_ptr().cast(), + &raw mut size, + ) + }; + match status { + NO_ERROR => { + // SAFETY: on success the buffer starts with a + // UNIVERSAL_NAME_INFOW whose string pointer points into `buf`. + let name = unsafe { + let info = &*buf.as_ptr().cast::(); + from_pwstr(info.lpUniversalName) + }; + return Ok(name.unwrap_or_default()); + } + // `size` now holds the required size in bytes. + ERROR_MORE_DATA => buf = vec![0u64; (size as usize).div_ceil(size_of::())], + ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), + code => return Err(Error::from_status(code)), + } + } + Err(Error::Other(ERROR_MORE_DATA)) +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..cf8e8e4 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,759 @@ +//! Server-side SMB administration via netapi32: enumerate, inspect, create, +//! and delete shares; list and end sessions; list and close open files; and +//! list the connections made to a share. +//! +//! Every function takes the target server as `Option<&str>` — `None` means +//! the local machine, `Some(r"\\fileserver")` administers a remote one. Most +//! of these calls require administrative rights on the target; the +//! enumeration functions fall back to a lower information level (fewer +//! fields, see the struct docs) when access is denied. +//! +//! ```no_run +//! use sambrs::server; +//! +//! // Create a share on the local machine, list everything, delete it again. +//! server::add_share(None, &server::NewShare::disk("scratch", r"C:\scratch"))?; +//! for share in server::shares(None)? { +//! println!("{} -> {:?}", share.name, share.path); +//! } +//! server::delete_share(None, "scratch")?; +//! # Ok::<(), sambrs::Error>(()) +//! ``` + +use crate::error::{Error, Result, check_net}; +use crate::strings::{from_pwstr, opt_ptr, to_wide}; +use crate::trace::debug; +use std::time::Duration; +use windows_sys::Win32::Foundation::ERROR_MORE_DATA; +use windows_sys::Win32::NetworkManagement::NetManagement::{ + MAX_PREFERRED_LENGTH, NERR_Success as NERR_SUCCESS, NetApiBufferFree, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CONNECTION_INFO_1, FILE_INFO_3, NetConnectionEnum, NetFileClose, NetFileEnum, NetSessionDel, + NetSessionEnum, NetShareAdd, NetShareDel, NetShareEnum, NetShareGetInfo, PERM_FILE_CREATE, + PERM_FILE_READ, PERM_FILE_WRITE, SESS_GUEST, SESSION_INFO_1, SESSION_INFO_10, SHARE_INFO_1, + SHARE_INFO_2, SHI_USES_UNLIMITED, STYPE_DEVICE, STYPE_DISKTREE, STYPE_IPC, STYPE_MASK, + STYPE_PRINTQ, STYPE_SPECIAL, STYPE_TEMPORARY, +}; + +/// Frees a netapi32-allocated buffer on drop. +struct NetBuffer(*mut u8); + +impl Drop for NetBuffer { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the pointer was allocated by netapi32 and is freed once. + unsafe { + NetApiBufferFree(self.0.cast()); + } + } + } +} + +/// Run a `Net*Enum` call to completion, handling the resume/`ERROR_MORE_DATA` +/// protocol and buffer ownership. `call` receives the out-buffer pointer and +/// the entries-read / total-entries out-params; each returned entry is passed +/// to `each` while the buffer is still alive. +fn net_enum( + mut call: impl FnMut(*mut *mut u8, *mut u32, *mut u32) -> u32, + mut each: impl FnMut(&T), +) -> Result<()> { + loop { + let mut buf: *mut u8 = std::ptr::null_mut(); + let mut read = 0u32; + let mut total = 0u32; + let status = call(&raw mut buf, &raw mut read, &raw mut total); + let _guard = NetBuffer(buf); + match status { + NERR_SUCCESS | ERROR_MORE_DATA => { + if !buf.is_null() { + // SAFETY: netapi32 returned `read` entries of type T in a + // properly aligned buffer owned by `_guard`. + let entries = + unsafe { std::slice::from_raw_parts(buf.cast::(), read as usize) }; + for entry in entries { + each(entry); + } + } + if status == NERR_SUCCESS { + return Ok(()); + } + // ERROR_MORE_DATA: loop again, the resume handle captured by + // `call` continues where this batch ended. + } + code => return Err(Error::from_status(code)), + } + } +} + +/// The type of a share, unpacked from the raw `STYPE_*` value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ShareType { + pub kind: ShareKind, + /// `STYPE_SPECIAL`: an administrative share (`C$`, `ADMIN$`, `IPC$`, …). + pub special: bool, + /// `STYPE_TEMPORARY`: not persistent across server restarts. + pub temporary: bool, +} + +/// The base kind of a share. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum ShareKind { + /// A disk share (`STYPE_DISKTREE`). + #[default] + Disk, + /// A print queue (`STYPE_PRINTQ`). + PrintQueue, + /// A communication device (`STYPE_DEVICE`). + Device, + /// Interprocess communication, i.e. `IPC$` (`STYPE_IPC`). + Ipc, + /// Any other raw `STYPE_*` base value. + Other(u32), +} + +impl ShareType { + fn from_raw(raw: u32) -> Self { + let kind = match raw & STYPE_MASK { + STYPE_DISKTREE => ShareKind::Disk, + STYPE_PRINTQ => ShareKind::PrintQueue, + STYPE_DEVICE => ShareKind::Device, + STYPE_IPC => ShareKind::Ipc, + other => ShareKind::Other(other), + }; + Self { + kind, + special: raw & STYPE_SPECIAL != 0, + temporary: raw & STYPE_TEMPORARY != 0, + } + } +} + +impl ShareKind { + fn to_raw(self) -> u32 { + match self { + Self::Disk => STYPE_DISKTREE, + Self::PrintQueue => STYPE_PRINTQ, + Self::Device => STYPE_DEVICE, + Self::Ipc => STYPE_IPC, + Self::Other(raw) => raw, + } + } +} + +/// A share on a server, from `NetShareEnum` / `NetShareGetInfo`. +/// +/// The `path`, `permissions`, `max_uses`, and `current_uses` fields come from +/// information level 2, which requires administrative rights on the target +/// server; they are `None` when the call had to fall back to level 1. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ShareInfo { + pub name: String, + pub share_type: ShareType, + pub remark: Option, + /// Local path backing the share, e.g. `C:\data` (level 2 only). + pub path: Option, + /// Share-level permission bits, `ACCESS_*` (level 2 only). + pub permissions: Option, + /// Concurrent-user limit; `u32::MAX` means unlimited (level 2 only). + pub max_uses: Option, + /// Current number of connections to the share (level 2 only). + pub current_uses: Option, +} + +impl ShareInfo { + /// # Safety + /// `info` must come from a live netapi32 level-2 buffer. + unsafe fn from_level2(info: &SHARE_INFO_2) -> Self { + // SAFETY: guaranteed by caller. + unsafe { + Self { + name: from_pwstr(info.shi2_netname).unwrap_or_default(), + share_type: ShareType::from_raw(info.shi2_type), + remark: from_pwstr(info.shi2_remark).filter(|r| !r.is_empty()), + path: from_pwstr(info.shi2_path), + permissions: Some(info.shi2_permissions), + max_uses: Some(info.shi2_max_uses), + current_uses: Some(info.shi2_current_uses), + } + } + } + + /// # Safety + /// `info` must come from a live netapi32 level-1 buffer. + unsafe fn from_level1(info: &SHARE_INFO_1) -> Self { + // SAFETY: guaranteed by caller. + unsafe { + Self { + name: from_pwstr(info.shi1_netname).unwrap_or_default(), + share_type: ShareType::from_raw(info.shi1_type), + remark: from_pwstr(info.shi1_remark).filter(|r| !r.is_empty()), + path: None, + permissions: None, + max_uses: None, + current_uses: None, + } + } + } +} + +/// List the shares on a server via `NetShareEnum`. +/// +/// Tries information level 2 (full detail, administrative rights) first and +/// transparently falls back to level 1 on [`Error::AccessDenied`]. +/// +/// # Errors +/// See [`Error`]. +pub fn shares(server: Option<&str>) -> Result> { + let server_w = server.map(to_wide).transpose()?; + let server_ptr = opt_ptr(server_w.as_deref()); + + let mut out = Vec::new(); + let mut resume = 0u32; + let level2 = net_enum::( + |buf, read, total| unsafe { + NetShareEnum( + server_ptr, + 2, + buf, + MAX_PREFERRED_LENGTH, + read, + total, + &raw mut resume, + ) + }, + |info| out.push(unsafe { ShareInfo::from_level2(info) }), + ); + match level2 { + Ok(()) => Ok(out), + Err(Error::AccessDenied) => { + debug!("NetShareEnum level 2 denied, falling back to level 1"); + out.clear(); + let mut resume = 0u32; + net_enum::( + |buf, read, total| unsafe { + NetShareEnum( + server_ptr, + 1, + buf, + MAX_PREFERRED_LENGTH, + read, + total, + &raw mut resume, + ) + }, + |info| out.push(unsafe { ShareInfo::from_level1(info) }), + )?; + Ok(out) + } + Err(e) => Err(e), + } +} + +/// Details of a single share via `NetShareGetInfo`, with the same level-2 → +/// level-1 fallback as [`shares`]. +/// +/// # Errors +/// [`Error::NetNameNotFound`] when the share does not exist. +// netapi32 allocates its info buffers with alignment suitable for any of its +// info structures, so the u8 -> SHARE_INFO_* casts below are sound. +#[allow(clippy::cast_ptr_alignment)] +pub fn share_info(server: Option<&str>, name: &str) -> Result { + let server_w = server.map(to_wide).transpose()?; + let name_w = to_wide(name)?; + + let get = |level: u32| -> Result { + let mut buf: *mut u8 = std::ptr::null_mut(); + // SAFETY: pointers outlive the call; `buf` receives an allocation + // owned by the returned NetBuffer. + let status = unsafe { + NetShareGetInfo( + opt_ptr(server_w.as_deref()), + name_w.as_ptr(), + level, + &raw mut buf, + ) + }; + let guard = NetBuffer(buf); + check_net(status)?; + Ok(guard) + }; + + match get(2) { + Ok(buf) => { + // SAFETY: level-2 success means `buf` holds one SHARE_INFO_2. + Ok(unsafe { ShareInfo::from_level2(&*buf.0.cast::()) }) + } + Err(Error::AccessDenied) => { + let buf = get(1)?; + // SAFETY: level-1 success means `buf` holds one SHARE_INFO_1. + Ok(unsafe { ShareInfo::from_level1(&*buf.0.cast::()) }) + } + Err(e) => Err(e), + } +} + +/// Definition of a share to create with [`add_share`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewShare { + name: String, + path: String, + kind: ShareKind, + remark: Option, + permissions: u32, + max_uses: u32, +} + +impl NewShare { + /// A disk share exposing the local directory `path` (e.g. `C:\data`) as + /// `name`, with default share-level permissions (`ACCESS_ALL`) and no + /// user limit. + /// + /// Note that share-level permissions are largely historical — access is + /// normally governed by the NTFS ACLs of the backing directory. + pub fn disk(name: impl Into, path: impl Into) -> Self { + Self { + name: name.into(), + path: path.into(), + kind: ShareKind::Disk, + remark: None, + permissions: windows_sys::Win32::Storage::FileSystem::ACCESS_ALL, + max_uses: SHI_USES_UNLIMITED, + } + } + + /// An optional comment shown next to the share. + #[must_use] + pub fn remark(mut self, remark: impl Into) -> Self { + self.remark = Some(remark.into()); + self + } + + /// Override the share kind (e.g. [`ShareKind::PrintQueue`]). + #[must_use] + pub fn kind(mut self, kind: ShareKind) -> Self { + self.kind = kind; + self + } + + /// Override the share-level permission bits (`ACCESS_*`). + #[must_use] + pub fn permissions(mut self, permissions: u32) -> Self { + self.permissions = permissions; + self + } + + /// Limit the number of concurrent users. + #[must_use] + pub fn max_uses(mut self, max_uses: u32) -> Self { + self.max_uses = max_uses; + self + } +} + +/// Create a share via `NetShareAdd` (information level 2). Requires +/// administrative rights on the target server. +/// +/// # Errors +/// [`Error::DuplicateShare`] when the name is taken, +/// [`Error::UnknownDeviceOrDirectory`] when the backing path does not exist, +/// [`Error::AccessDenied`] without administrative rights. +pub fn add_share(server: Option<&str>, share: &NewShare) -> Result<()> { + let server_w = server.map(to_wide).transpose()?; + let name_w = to_wide(&share.name)?; + let path_w = to_wide(&share.path)?; + let remark_w = share.remark.as_deref().map(to_wide).transpose()?; + + let info = SHARE_INFO_2 { + shi2_netname: name_w.as_ptr().cast_mut(), + shi2_type: share.kind.to_raw(), + shi2_remark: opt_ptr(remark_w.as_deref()), + shi2_permissions: share.permissions, + shi2_max_uses: share.max_uses, + shi2_current_uses: 0, + shi2_path: path_w.as_ptr().cast_mut(), + shi2_passwd: std::ptr::null_mut(), + }; + + let mut parm_err = 0u32; + // SAFETY: `info` and all buffers it points into outlive the call. + let status = unsafe { + NetShareAdd( + opt_ptr(server_w.as_deref()), + 2, + std::ptr::from_ref(&info).cast(), + &raw mut parm_err, + ) + }; + if status != NERR_SUCCESS { + debug!("NetShareAdd failed with {status} (parameter index {parm_err})"); + } + check_net(status) +} + +/// Delete a share via `NetShareDel`. Requires administrative rights on the +/// target server. +/// +/// # Errors +/// [`Error::NetNameNotFound`] when the share does not exist. +pub fn delete_share(server: Option<&str>, name: &str) -> Result<()> { + let server_w = server.map(to_wide).transpose()?; + let name_w = to_wide(name)?; + // SAFETY: both strings outlive the call. + let status = unsafe { NetShareDel(opt_ptr(server_w.as_deref()), name_w.as_ptr(), 0) }; + check_net(status) +} + +/// An SMB session established with a server, from `NetSessionEnum`. +/// +/// The `open_files` and `is_guest` fields come from information level 1, +/// which requires administrative rights; they are `None` when the call had +/// to fall back to level 10. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct SessionInfo { + /// Name of the computer that established the session. + pub client: String, + /// Name of the user who established the session. + pub username: Option, + /// Number of files, devices, and pipes opened during the session + /// (level 1 only). + pub open_files: Option, + /// How long the session has been active. + pub active: Duration, + /// How long the session has been idle. + pub idle: Duration, + /// Whether the client connected as a guest (level 1 only). + pub is_guest: Option, +} + +impl SessionInfo { + /// # Safety + /// `info` must come from a live netapi32 level-1 buffer. + unsafe fn from_level1(info: &SESSION_INFO_1) -> Self { + // SAFETY: guaranteed by caller. + unsafe { + Self { + client: from_pwstr(info.sesi1_cname).unwrap_or_default(), + username: from_pwstr(info.sesi1_username), + open_files: Some(info.sesi1_num_opens), + active: Duration::from_secs(u64::from(info.sesi1_time)), + idle: Duration::from_secs(u64::from(info.sesi1_idle_time)), + is_guest: Some(info.sesi1_user_flags & SESS_GUEST != 0), + } + } + } + + /// # Safety + /// `info` must come from a live netapi32 level-10 buffer. + unsafe fn from_level10(info: &SESSION_INFO_10) -> Self { + // SAFETY: guaranteed by caller. + unsafe { + Self { + client: from_pwstr(info.sesi10_cname).unwrap_or_default(), + username: from_pwstr(info.sesi10_username), + open_files: None, + active: Duration::from_secs(u64::from(info.sesi10_time)), + idle: Duration::from_secs(u64::from(info.sesi10_idle_time)), + is_guest: None, + } + } + } +} + +/// List the SMB sessions on a server via `NetSessionEnum`, optionally +/// filtered by client computer name and/or user name. +/// +/// The `client` filter is the API's `UncClientName` parameter and must be in +/// UNC form with the leading backslashes (`r"\\workstation"`); note that +/// [`SessionInfo::client`] may report the bare name without them. +/// +/// Tries information level 1 (full detail, administrative rights) first and +/// transparently falls back to level 10 on [`Error::AccessDenied`]. +/// +/// # Errors +/// [`Error::ClientNameNotFound`] / [`Error::UserNotFound`] when a filter +/// matches nothing. +pub fn sessions( + server: Option<&str>, + client: Option<&str>, + username: Option<&str>, +) -> Result> { + let server_w = server.map(to_wide).transpose()?; + let client_w = client.map(to_wide).transpose()?; + let user_w = username.map(to_wide).transpose()?; + let (server_ptr, client_ptr, user_ptr) = ( + opt_ptr(server_w.as_deref()), + opt_ptr(client_w.as_deref()), + opt_ptr(user_w.as_deref()), + ); + + let mut out = Vec::new(); + let mut resume = 0u32; + let level1 = net_enum::( + |buf, read, total| unsafe { + NetSessionEnum( + server_ptr, + client_ptr, + user_ptr, + 1, + buf, + MAX_PREFERRED_LENGTH, + read, + total, + &raw mut resume, + ) + }, + |info| out.push(unsafe { SessionInfo::from_level1(info) }), + ); + match level1 { + Ok(()) => Ok(out), + Err(Error::AccessDenied) => { + debug!("NetSessionEnum level 1 denied, falling back to level 10"); + out.clear(); + let mut resume = 0u32; + net_enum::( + |buf, read, total| unsafe { + NetSessionEnum( + server_ptr, + client_ptr, + user_ptr, + 10, + buf, + MAX_PREFERRED_LENGTH, + read, + total, + &raw mut resume, + ) + }, + |info| out.push(unsafe { SessionInfo::from_level10(info) }), + )?; + Ok(out) + } + Err(e) => Err(e), + } +} + +/// End SMB sessions on a server via `NetSessionDel`. Requires administrative +/// rights on the target server. +/// +/// `client` and `username` scope what is deleted: sessions from that +/// computer (in UNC form, `r"\\workstation"` — see [`sessions`]), sessions +/// of that user, or their intersection. **Both `None` ends every session on +/// the server.** +/// +/// # Errors +/// [`Error::ClientNameNotFound`] / [`Error::UserNotFound`] when nothing +/// matches. +pub fn delete_session( + server: Option<&str>, + client: Option<&str>, + username: Option<&str>, +) -> Result<()> { + let server_w = server.map(to_wide).transpose()?; + let client_w = client.map(to_wide).transpose()?; + let user_w = username.map(to_wide).transpose()?; + // SAFETY: all strings outlive the call. + let status = unsafe { + NetSessionDel( + opt_ptr(server_w.as_deref()), + opt_ptr(client_w.as_deref()), + opt_ptr(user_w.as_deref()), + ) + }; + check_net(status) +} + +/// A file, device, or pipe opened through SMB on a server, from +/// `NetFileEnum` (information level 3). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct OpenFile { + /// Server-assigned identifier; pass to [`close_file`]. + pub id: u32, + pub path: String, + /// The user (or computer, for null sessions) that opened it. + pub username: Option, + /// Number of locks held on the file. + pub locks: u32, + /// Raw `PERM_FILE_*` access bits; see the `can_*` methods. + pub permissions: u32, +} + +impl OpenFile { + #[must_use] + pub fn can_read(&self) -> bool { + self.permissions & PERM_FILE_READ != 0 + } + + #[must_use] + pub fn can_write(&self) -> bool { + self.permissions & PERM_FILE_WRITE != 0 + } + + #[must_use] + pub fn can_create(&self) -> bool { + self.permissions & PERM_FILE_CREATE != 0 + } +} + +/// List the open files/devices/pipes on a server via `NetFileEnum`, +/// optionally filtered to a base path prefix and/or user name. Requires +/// administrative rights on the target server. +/// +/// # Errors +/// [`Error::AccessDenied`] without administrative rights. +pub fn open_files( + server: Option<&str>, + base_path: Option<&str>, + username: Option<&str>, +) -> Result> { + let server_w = server.map(to_wide).transpose()?; + let path_w = base_path.map(to_wide).transpose()?; + let user_w = username.map(to_wide).transpose()?; + + let mut out = Vec::new(); + let mut resume = 0usize; // NetFileEnum's resume handle is pointer-sized + net_enum::( + |buf, read, total| unsafe { + NetFileEnum( + opt_ptr(server_w.as_deref()), + opt_ptr(path_w.as_deref()), + opt_ptr(user_w.as_deref()), + 3, + buf, + MAX_PREFERRED_LENGTH, + read, + total, + &raw mut resume, + ) + }, + |info: &FILE_INFO_3| { + // SAFETY: strings live in the enumeration buffer held by net_enum. + unsafe { + out.push(OpenFile { + id: info.fi3_id, + path: from_pwstr(info.fi3_pathname).unwrap_or_default(), + username: from_pwstr(info.fi3_username), + locks: info.fi3_num_locks, + permissions: info.fi3_permissions, + }); + } + }, + )?; + Ok(out) +} + +/// Force-close an open file/device/pipe by its [`OpenFile::id`] via +/// `NetFileClose`. Requires administrative rights on the target server. +/// +/// The client is not notified — use with care, this can cause data loss on +/// the client side. +/// +/// # Errors +/// [`Error::FileIdNotFound`] when the id does not exist. +pub fn close_file(server: Option<&str>, id: u32) -> Result<()> { + let server_w = server.map(to_wide).transpose()?; + // SAFETY: the string outlives the call. + let status = unsafe { NetFileClose(opt_ptr(server_w.as_deref()), id) }; + check_net(status) +} + +/// A connection made to a shared resource, from `NetConnectionEnum` +/// (information level 1). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ShareConnection { + /// Server-assigned connection identifier. + pub id: u32, + pub share_type: ShareType, + /// Number of files currently open through this connection. + pub open_files: u32, + /// Number of users on this connection. + pub users: u32, + /// How long the connection has been established. + pub active: Duration, + pub username: Option, + /// Depending on the qualifier passed to [`connections`]: the share name + /// or the client computer name. + pub name: Option, +} + +/// List the connections to a share, or from a client computer, via +/// `NetConnectionEnum`. Requires administrative rights on the target server. +/// +/// `qualifier` is either a share name (`"data"`) — listing the connections +/// made to that share — or a computer name (`r"\\workstation"`) — listing +/// the connections made from that computer. +/// +/// # Errors +/// [`Error::NetNameNotFound`] when the qualifier matches no share. +pub fn connections(server: Option<&str>, qualifier: &str) -> Result> { + let server_w = server.map(to_wide).transpose()?; + let qualifier_w = to_wide(qualifier)?; + + let mut out = Vec::new(); + let mut resume = 0u32; + net_enum::( + |buf, read, total| unsafe { + NetConnectionEnum( + opt_ptr(server_w.as_deref()), + qualifier_w.as_ptr(), + 1, + buf, + MAX_PREFERRED_LENGTH, + read, + total, + &raw mut resume, + ) + }, + |info: &CONNECTION_INFO_1| { + // SAFETY: strings live in the enumeration buffer held by net_enum. + unsafe { + out.push(ShareConnection { + id: info.coni1_id, + share_type: ShareType::from_raw(info.coni1_type), + open_files: info.coni1_num_opens, + users: info.coni1_num_users, + active: Duration::from_secs(u64::from(info.coni1_time)), + username: from_pwstr(info.coni1_username), + name: from_pwstr(info.coni1_netname), + }); + } + }, + )?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn share_type_unpacks_flags() { + let t = ShareType::from_raw(STYPE_DISKTREE | STYPE_SPECIAL); + assert_eq!(t.kind, ShareKind::Disk); + assert!(t.special); + assert!(!t.temporary); + + let t = ShareType::from_raw(STYPE_PRINTQ | STYPE_TEMPORARY); + assert_eq!(t.kind, ShareKind::PrintQueue); + assert!(!t.special); + assert!(t.temporary); + } + + #[test] + fn share_kind_roundtrips() { + for kind in [ + ShareKind::Disk, + ShareKind::PrintQueue, + ShareKind::Device, + ShareKind::Ipc, + ] { + assert_eq!(ShareType::from_raw(kind.to_raw()).kind, kind); + } + } +} diff --git a/src/share.rs b/src/share.rs new file mode 100644 index 0000000..a064021 --- /dev/null +++ b/src/share.rs @@ -0,0 +1,443 @@ +use crate::error::{Error, Result, check_wnet}; +use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; +use crate::strings::{from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide, to_wide_secret}; +use crate::trace::{debug, trace}; +use windows_sys::Win32::Foundation::ERROR_MORE_DATA; +use windows_sys::Win32::NetworkManagement::WNet; + +/// A remote SMB share, optionally redirected to a local device. +/// +/// Construct one with [`SmbShare::new`] (deviceless, current-user +/// credentials) or [`SmbShare::builder`] for full control, then establish the +/// connection with one of the `connect*` methods. +/// +/// ```no_run +/// use sambrs::{DriveLetter, SmbShare}; +/// +/// let share = SmbShare::builder(r"\\server\share") +/// .credentials("user", "pass") +/// .mount_on(DriveLetter::D) +/// .build()?; +/// +/// share.connect()?; +/// // use std::fs as if D:\ was a local directory +/// assert!(std::fs::metadata(r"D:\")?.is_dir()); +/// share.disconnect()?; +/// # Ok::<(), Box>(()) +/// ``` +pub struct SmbShare { + remote: String, + username: Option, + password: Option, + local: Option, + resource_type: ResourceType, + provider: Option, +} + +// Deliberately manual: must never leak the password. +impl std::fmt::Debug for SmbShare { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SmbShare") + .field("remote", &self.remote) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "")) + .field("local", &self.local) + .field("resource_type", &self.resource_type) + .field("provider", &self.provider) + .finish() + } +} + +#[cfg(feature = "zeroize")] +impl Drop for SmbShare { + fn drop(&mut self) { + use zeroize::Zeroize; + self.password.zeroize(); + } +} + +impl SmbShare { + /// A deviceless connection to `remote` (e.g. `\\server\share`) using the + /// credentials of the currently logged-on user. + /// + /// Use [`SmbShare::builder`] to set credentials, a local mount point, a + /// resource type, or a network provider. + pub fn new(remote: impl Into) -> Self { + Self { + remote: remote.into(), + username: None, + password: None, + local: None, + resource_type: ResourceType::Disk, + provider: None, + } + } + + /// Start building a share representation with full control over + /// credentials, mount point, resource type, and provider. + pub fn builder(remote: impl Into) -> SmbShareBuilder { + SmbShareBuilder { + share: Self::new(remote), + } + } + + /// The remote name, e.g. `\\server\share`. + #[must_use] + pub fn remote(&self) -> &str { + &self.remote + } + + /// The local device this share is redirected to (e.g. `"D:"`), if any. + #[must_use] + pub fn local_device(&self) -> Option<&str> { + self.local.as_deref() + } + + /// The user name used to authenticate; `None` means the credentials of + /// the currently logged-on user. + #[must_use] + pub fn username(&self) -> Option<&str> { + self.username.as_deref() + } + + /// Connect with default options: a temporary, non-interactive connection. + /// + /// Connecting multiple times works fine in deviceless mode but fails with + /// [`Error::AlreadyAssigned`] when a local mount point is set. + /// + /// # Errors + /// See [`Error`] — every documented `WNetAddConnection2W` failure has a + /// dedicated variant. + pub fn connect(&self) -> Result<()> { + self.connect_with(ConnectOptions::new()) + } + + /// Connect with explicit [`ConnectOptions`]. + /// + /// # Errors + /// See [`Error`] — every documented `WNetAddConnection2W` failure has a + /// dedicated variant. + pub fn connect_with(&self, options: ConnectOptions) -> Result<()> { + self.connect_raw(options.to_flags()) + } + + /// Connect passing `dwFlags` verbatim to `WNetAddConnection2W` — an + /// escape hatch when [`ConnectOptions`] doesn't expose a flag you need. + /// + /// # Errors + /// See [`Error`]. + pub fn connect_raw(&self, flags: u32) -> Result<()> { + let remote = to_wide(&self.remote)?; + let local = self.local.as_deref().map(to_wide).transpose()?; + let provider = self.provider.as_deref().map(to_wide).transpose()?; + let username = self.username.as_deref().map(to_wide).transpose()?; + let password = self.password.as_deref().map(to_wide_secret).transpose()?; + + // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcew + let resource = WNet::NETRESOURCEW { + dwScope: 0, // ignored by WNetAddConnection2W + dwType: self.resource_type.to_dword(), + dwDisplayType: 0, // ignored by WNetAddConnection2W + dwUsage: 0, // ignored by WNetAddConnection2W + lpLocalName: opt_ptr(local.as_deref()), + lpRemoteName: remote.as_ptr().cast_mut(), + lpComment: std::ptr::null_mut(), // ignored by WNetAddConnection2W + lpProvider: opt_ptr(provider.as_deref()), + }; + + trace!("connecting to {} with flags {flags:#x}", self.remote); + + // SAFETY: all pointers in `resource` and the credential pointers stay + // valid for the duration of the call — they borrow from the wide + // buffers bound above, which live until the end of this function. + let status = unsafe { + WNet::WNetAddConnection2W( + &raw const resource, + secret_ptr(password.as_ref()), + opt_ptr(username.as_deref()), + flags, + ) + }; + + debug!("WNetAddConnection2W returned {status}"); + check_wnet(status) + } + + /// Connect and let Windows pick a free local device, via + /// `WNetUseConnectionW` with `CONNECT_REDIRECT`. + /// + /// Returns the name through which the share is accessible — the assigned + /// device (e.g. `"Z:"`), or the local device configured on this share if + /// one was set. Pass the returned name to + /// [`cancel_connection`] to disconnect. + /// + /// The share's resource type must be [`ResourceType::Disk`] or + /// [`ResourceType::Print`]: Windows rejects `RESOURCETYPE_ANY` with + /// [`Error::InvalidParameter`] when it chooses the device itself. + /// + /// # Errors + /// See [`Error`]. + pub fn connect_auto(&self, options: ConnectOptions) -> Result { + self.connect_auto_raw(options.to_flags() | WNet::CONNECT_REDIRECT) + } + + /// [`connect_auto`](Self::connect_auto) with verbatim `dwFlags` (note: + /// `CONNECT_REDIRECT` is *not* added for you here). + /// + /// # Errors + /// See [`Error`]. + pub fn connect_auto_raw(&self, flags: u32) -> Result { + let remote = to_wide(&self.remote)?; + let local = self.local.as_deref().map(to_wide).transpose()?; + let provider = self.provider.as_deref().map(to_wide).transpose()?; + let username = self.username.as_deref().map(to_wide).transpose()?; + let password = self.password.as_deref().map(to_wide_secret).transpose()?; + + let resource = WNet::NETRESOURCEW { + dwScope: 0, + dwType: self.resource_type.to_dword(), + dwDisplayType: 0, + dwUsage: 0, + lpLocalName: opt_ptr(local.as_deref()), + lpRemoteName: remote.as_ptr().cast_mut(), + lpComment: std::ptr::null_mut(), + lpProvider: opt_ptr(provider.as_deref()), + }; + + trace!("auto-connecting to {} with flags {flags:#x}", self.remote); + + let mut access_name = vec![0u16; 1024]; + // One retry with the size Windows asked for. + for _ in 0..2 { + let mut size = len_u32(access_name.len()); + let mut result = 0u32; + // SAFETY: as in `connect_raw`; `access_name` outlives the call. + let status = unsafe { + WNet::WNetUseConnectionW( + std::ptr::null_mut(), // no owner window for credential dialogs + &raw const resource, + secret_ptr(password.as_ref()), + opt_ptr(username.as_deref()), + flags, + access_name.as_mut_ptr(), + &raw mut size, + &raw mut result, + ) + }; + debug!("WNetUseConnectionW returned {status}, result {result:#x}"); + if status == ERROR_MORE_DATA { + access_name = vec![0u16; size as usize]; + continue; + } + check_wnet(status)?; + return Ok(from_wide_buf(&access_name)); + } + Err(Error::Other(ERROR_MORE_DATA)) + } + + /// Connect and return an RAII [`Connection`] guard that disconnects when + /// dropped. + /// + /// # Errors + /// See [`Error`]. + pub fn connect_guarded(&self, options: ConnectOptions) -> Result> { + self.connect_with(options)?; + Ok(Connection { + share: self, + on_drop: DisconnectOptions::new(), + armed: true, + }) + } + + /// Disconnect with default options: non-forced, keeping any persistence. + /// + /// Disconnects by local device name when this share has one, otherwise by + /// remote name. + /// + /// # Errors + /// See [`Error`] — every documented `WNetCancelConnection2W` failure has + /// a dedicated variant. + pub fn disconnect(&self) -> Result<()> { + self.disconnect_with(DisconnectOptions::new()) + } + + /// Disconnect with explicit [`DisconnectOptions`]. + /// + /// # Errors + /// See [`Error`]. + pub fn disconnect_with(&self, options: DisconnectOptions) -> Result<()> { + let name = self.local.as_deref().unwrap_or(&self.remote); + cancel_connection(name, options) + } +} + +/// Cancel a connection by name: either a redirected local device (e.g. +/// `"Z:"`) or a remote name (`\\server\share`) for deviceless connections. +/// +/// This is the direct wrapper around `WNetCancelConnection2W`; use it to +/// disconnect names returned by +/// [`SmbShare::connect_auto`] or found via [`crate::enumerate`]. +/// +/// # Errors +/// See [`Error`]. +pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { + let wide = to_wide(name)?; + trace!("disconnecting {name}"); + // SAFETY: `wide` is a valid nul-terminated string outliving the call. + let status = unsafe { + WNet::WNetCancelConnection2W(wide.as_ptr(), options.flags(), options.force_bool()) + }; + debug!("WNetCancelConnection2W returned {status}"); + check_wnet(status) +} + +/// Builder for [`SmbShare`], created via [`SmbShare::builder`]. +#[derive(Debug)] +pub struct SmbShareBuilder { + share: SmbShare, +} + +impl SmbShareBuilder { + /// Authenticate with an explicit user name and password. + /// + /// The user name can carry a domain (`DOMAIN\user` or `user@domain`). + /// Without credentials, the connection uses the logged-on user's + /// credentials. An empty password string is a real (empty) password, not + /// "no password". + #[must_use] + pub fn credentials(self, username: impl Into, password: impl Into) -> Self { + self.username(username).password(password) + } + + /// Set only the user name; Windows will use the default password + /// associated with that user. + #[must_use] + pub fn username(mut self, username: impl Into) -> Self { + self.share.username = Some(username.into()); + self + } + + /// Set only the password; Windows will use the default user name. + #[must_use] + pub fn password(mut self, password: impl Into) -> Self { + // Wipe any previously set password before the assignment drops it. + #[cfg(feature = "zeroize")] + { + use zeroize::Zeroize; + self.share.password.zeroize(); + } + self.share.password = Some(password.into()); + self + } + + /// Redirect the share to a local drive letter. + #[must_use] + pub fn mount_on(mut self, letter: DriveLetter) -> Self { + self.share.local = Some(letter.device()); + self + } + + /// Redirect to an arbitrary local device name (e.g. `"LPT1"` for a + /// printer share). Prefer [`mount_on`](Self::mount_on) for drive letters; + /// this escape hatch is passed to Windows unvalidated. + #[must_use] + pub fn local_device(mut self, device: impl Into) -> Self { + self.share.local = Some(device.into()); + self + } + + /// The resource type to connect to. Defaults to [`ResourceType::Disk`]. + /// + /// [`ResourceType::Any`] is only valid for deviceless connections; see + /// its documentation. + #[must_use] + pub fn resource_type(mut self, resource_type: ResourceType) -> Self { + self.share.resource_type = resource_type; + self + } + + /// The network provider to use (`lpProvider`). Microsoft: set this only + /// if you know the network provider you want; otherwise let the operating + /// system determine which provider the network name maps to. + #[must_use] + pub fn provider(mut self, provider: impl Into) -> Self { + self.share.provider = Some(provider.into()); + self + } + + /// Validate the configuration and build the [`SmbShare`]. + /// + /// # Errors + /// [`Error::InteriorNul`] if any string contains a NUL character. + pub fn build(self) -> Result { + fn validate(s: Option<&str>) -> Result<()> { + if s.is_some_and(|s| s.contains('\0')) { + Err(Error::InteriorNul) + } else { + Ok(()) + } + } + validate(Some(&self.share.remote))?; + validate(self.share.username.as_deref())?; + validate(self.share.password.as_deref())?; + validate(self.share.local.as_deref())?; + validate(self.share.provider.as_deref())?; + Ok(self.share) + } +} + +/// RAII guard returned by [`SmbShare::connect_guarded`]: disconnects the +/// share when dropped (best effort — a failure on drop is only visible as a +/// `tracing` event, with the `tracing` feature enabled). +/// +/// Use [`Connection::disconnect`] for explicit error handling, or +/// [`Connection::leak`] to keep the connection open past the guard. +#[derive(Debug)] +#[must_use = "dropping the guard disconnects the share immediately"] +pub struct Connection<'a> { + share: &'a SmbShare, + on_drop: DisconnectOptions, + armed: bool, +} + +impl<'a> Connection<'a> { + /// Configure the [`DisconnectOptions`] used when this guard drops (e.g. + /// force-close open files). + pub fn on_drop(mut self, options: DisconnectOptions) -> Self { + self.on_drop = options; + self + } + + /// The share this guard belongs to. + #[must_use] + pub fn share(&self) -> &'a SmbShare { + self.share + } + + /// Consume the guard without disconnecting, keeping the connection open. + pub fn leak(mut self) { + self.armed = false; + } + + /// Disconnect now, with explicit error handling. + /// + /// # Errors + /// See [`Error`]. + pub fn disconnect(mut self, options: DisconnectOptions) -> Result<()> { + self.armed = false; + self.share.disconnect_with(options) + } +} + +impl Drop for Connection<'_> { + fn drop(&mut self) { + if self.armed { + if let Err(e) = self.share.disconnect_with(self.on_drop) { + debug!( + "failed to disconnect {} on guard drop: {e}", + self.share.remote() + ); + } + } + } +} diff --git a/src/strings.rs b/src/strings.rs new file mode 100644 index 0000000..ed476a5 --- /dev/null +++ b/src/strings.rs @@ -0,0 +1,128 @@ +//! Internal UTF-16 string helpers shared by every module that talks to the +//! wide (`W`) variants of the Windows API. + +use crate::{Error, Result}; + +/// Encode a `&str` as a nul-terminated UTF-16 buffer. +/// +/// The buffer is allocated with its final capacity up front so no intermediate +/// heap copy of the data is left behind (relevant for the `zeroize` feature). +pub(crate) fn to_wide(s: &str) -> Result> { + // A UTF-16 encoding never has more code units than the UTF-8 encoding has + // bytes, so this capacity guarantees a single allocation. + let mut wide: Vec = Vec::with_capacity(s.len() + 1); + wide.extend(s.encode_utf16()); + if wide.contains(&0) { + return Err(Error::InteriorNul); + } + wide.push(0); + Ok(wide) +} + +/// A nul-terminated UTF-16 buffer that is wiped on drop when the `zeroize` +/// feature is enabled. +#[cfg(feature = "zeroize")] +pub(crate) type WideSecret = zeroize::Zeroizing>; +#[cfg(not(feature = "zeroize"))] +pub(crate) type WideSecret = Vec; + +/// Encode a secret (password) as a nul-terminated UTF-16 buffer. +pub(crate) fn to_wide_secret(s: &str) -> Result { + #[cfg(feature = "zeroize")] + { + to_wide(s).map(zeroize::Zeroizing::new) + } + #[cfg(not(feature = "zeroize"))] + { + to_wide(s) + } +} + +/// Pointer to an optional wide string, or null when absent. +/// +/// The caller must keep the owning buffer alive for as long as the returned +/// pointer is in use. +pub(crate) fn opt_ptr(buf: Option<&[u16]>) -> *mut u16 { + buf.map_or(std::ptr::null_mut(), |b| b.as_ptr().cast_mut()) +} + +/// Owned `String` from a wide buffer, up to the first nul (or the full buffer +/// if it contains none). +pub(crate) fn from_wide_buf(buf: &[u16]) -> String { + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..len]) +} + +/// Owned `String` from a nul-terminated wide pointer; `None` when null. +/// +/// # Safety +/// `ptr` must either be null or point to a valid nul-terminated UTF-16 string. +pub(crate) unsafe fn from_pwstr(ptr: *const u16) -> Option { + if ptr.is_null() { + return None; + } + let mut len = 0usize; + // SAFETY: the caller guarantees a valid nul-terminated string. + unsafe { + while *ptr.add(len) != 0 { + len += 1; + } + Some(String::from_utf16_lossy(std::slice::from_raw_parts( + ptr, len, + ))) + } +} + +/// Pointer to an optional secret wide string, or null when absent. +/// +/// The caller must keep the owning buffer alive for as long as the returned +/// pointer is in use. (A `match` rather than a closure so the body works for +/// both `Vec` and `Zeroizing>` via auto-deref.) +pub(crate) fn secret_ptr(buf: Option<&WideSecret>) -> *const u16 { + match buf { + Some(b) => b.as_ptr(), + None => std::ptr::null(), + } +} + +/// Buffer length as `u32` for Windows APIs; saturates instead of panicking. +pub(crate) fn len_u32(len: usize) -> u32 { + u32::try_from(len).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_wide_appends_nul() { + assert_eq!(to_wide("ab").unwrap(), vec![97, 98, 0]); + } + + #[test] + fn to_wide_handles_non_ascii() { + // 'ü' is a single UTF-16 code unit but two UTF-8 bytes. + assert_eq!(to_wide("ü").unwrap(), vec![0xFC, 0]); + } + + #[test] + fn to_wide_rejects_interior_nul() { + assert_eq!(to_wide("a\0b").unwrap_err(), Error::InteriorNul); + } + + #[test] + fn from_wide_buf_stops_at_nul() { + assert_eq!(from_wide_buf(&[97, 98, 0, 99]), "ab"); + assert_eq!(from_wide_buf(&[97, 98]), "ab"); + } + + #[test] + fn from_pwstr_roundtrip() { + let wide = to_wide("hello").unwrap(); + assert_eq!( + unsafe { from_pwstr(wide.as_ptr()) }.as_deref(), + Some("hello") + ); + assert_eq!(unsafe { from_pwstr(std::ptr::null()) }, None); + } +} diff --git a/src/trace.rs b/src/trace.rs new file mode 100644 index 0000000..c05a26b --- /dev/null +++ b/src/trace.rs @@ -0,0 +1,25 @@ +//! Internal shim so the crate can emit `tracing` events without forcing the +//! dependency on users. With the `tracing` feature disabled these macros +//! expand to nothing. + +#[cfg(feature = "tracing")] +pub(crate) use tracing::{debug, trace}; + +// The disabled variants still type-check their arguments (at zero runtime +// cost) so code compiles identically with and without the feature. +#[cfg(not(feature = "tracing"))] +macro_rules! debug { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} + +#[cfg(not(feature = "tracing"))] +macro_rules! trace { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} + +#[cfg(not(feature = "tracing"))] +pub(crate) use {debug, trace}; diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..511f9f0 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,377 @@ +//! Integration tests against a real SMB share. +//! +//! These tests are `#[ignore]`d by default because they need a live share. +//! Provide one via environment variables and run them explicitly: +//! +//! ```text +//! SAMBRS_TEST_SHARE=\\server\share +//! SAMBRS_TEST_USERNAME=DOMAIN\user +//! SAMBRS_TEST_PASSWORD=... +//! SAMBRS_TEST_LOCAL=1 # only if the share is on THIS machine and the test +//! # process can administer it (enables server:: tests) +//! +//! cargo test -- --include-ignored +//! ``` +//! +//! CI provisions `\\localhost\sambrs-test` with a dedicated local user and +//! runs the full suite; see `.github/workflows/ci.yml`. Drive letters S-Z are +//! used by these tests and must be free. Tests run single-threaded (see +//! `.cargo/config.toml`). +#![cfg(windows)] + +use sambrs::{ + ConnectOptions, DisconnectOptions, DriveLetter, Error, SmbShare, cancel_connection, enumerate, + query, server, +}; + +fn share_name() -> String { + std::env::var("SAMBRS_TEST_SHARE").expect("SAMBRS_TEST_SHARE must be set") +} + +fn username() -> String { + std::env::var("SAMBRS_TEST_USERNAME").expect("SAMBRS_TEST_USERNAME must be set") +} + +fn password() -> String { + std::env::var("SAMBRS_TEST_PASSWORD").expect("SAMBRS_TEST_PASSWORD must be set") +} + +/// `server::*` tests only make sense against the local machine, with rights +/// to administer it. +fn local_admin() -> bool { + std::env::var("SAMBRS_TEST_LOCAL").is_ok_and(|v| v == "1") +} + +fn share(mount: Option) -> SmbShare { + let mut builder = SmbShare::builder(share_name()).credentials(username(), password()); + if let Some(letter) = mount { + builder = builder.mount_on(letter); + } + builder.build().expect("test credentials contain no NUL") +} + +fn drive_exists(letter: DriveLetter) -> bool { + std::path::Path::new(&format!(r"{letter}\")).is_dir() +} + +// ── connect / disconnect ──────────────────────────────────────────────────── + +// Lovely Windows sometimes returns `LogonFailure` and sometimes +// `InvalidPassword` for a bad password. +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn wrong_password_fails_without_prompting() { + let share = SmbShare::builder(share_name()) + .credentials(username(), "definitely-the-wrong-password-1") + .build() + .unwrap(); + let result = share.connect(); + assert!( + matches!( + result, + Err(Error::InvalidPassword | Error::LogonFailure | Error::AccessDenied) + ), + "unexpected result: {result:?}" + ); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn nonexistent_share_fails() { + let share = SmbShare::builder(r"\\thisisnotashare.local\Share-Name") + .credentials(username(), password()) + .build() + .unwrap(); + let result = share.connect(); + // The documented WNetAddConnection2W error list is not exhaustive + // ("Other: use FormatMessage"): unresolvable hosts commonly surface as + // ERROR_BAD_NETPATH (53) or ERROR_SEM_TIMEOUT (121), which have no + // dedicated variant. + assert!( + matches!( + result, + Err(Error::BadNetName + | Error::NoNetOrBadPath + | Error::NoNetwork + | Error::Other(53 | 121)) + ), + "unexpected result: {result:?}" + ); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn deviceless_connect_and_reconnect_works() { + let share = share(None); + share.connect().unwrap(); + // Reconnecting a deviceless connection is fine. + share.connect().unwrap(); + assert!(std::path::Path::new(&share_name()).is_dir()); + share.disconnect().unwrap(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn mount_on_drive_letter_works_and_does_not_persist() { + let share = share(Some(DriveLetter::S)); + share.connect().unwrap(); + assert!(drive_exists(DriveLetter::S)); + share.disconnect().unwrap(); + assert!(!drive_exists(DriveLetter::S)); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn mounted_reconnect_fails_with_already_assigned() { + let share = share(Some(DriveLetter::S)); + share.connect().unwrap(); + assert_eq!(share.connect(), Err(Error::AlreadyAssigned)); + share.disconnect().unwrap(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn two_letters_to_the_same_share_work() { + let one = share(Some(DriveLetter::S)); + let two = share(Some(DriveLetter::T)); + one.connect().unwrap(); + two.connect().unwrap(); + assert!(drive_exists(DriveLetter::S)); + assert!(drive_exists(DriveLetter::T)); + one.disconnect().unwrap(); + assert!(!drive_exists(DriveLetter::S)); + two.disconnect().unwrap(); + assert!(!drive_exists(DriveLetter::T)); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn force_disconnect_with_open_file_works() { + let share = share(Some(DriveLetter::U)); + share.connect().unwrap(); + let file = std::fs::File::create(r"U:\sambrs-force-disconnect.txt").unwrap(); + // Non-forced disconnect must refuse while a file is open. + assert_eq!(share.disconnect(), Err(Error::OpenFiles)); + share + .disconnect_with(DisconnectOptions::new().force(true)) + .unwrap(); + drop(file); + assert!(!drive_exists(DriveLetter::U)); + // Clean up the file via a fresh connection. + let share = self::share(Some(DriveLetter::U)); + share.connect().unwrap(); + let _ = std::fs::remove_file(r"U:\sambrs-force-disconnect.txt"); + share.disconnect().unwrap(); +} + +// ── RAII guard ────────────────────────────────────────────────────────────── + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn guard_disconnects_on_drop() { + let share = share(Some(DriveLetter::V)); + { + let _guard = share.connect_guarded(ConnectOptions::new()).unwrap(); + assert!(drive_exists(DriveLetter::V)); + } + assert!(!drive_exists(DriveLetter::V)); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn guard_leak_keeps_the_connection() { + let share = share(Some(DriveLetter::V)); + share.connect_guarded(ConnectOptions::new()).unwrap().leak(); + assert!(drive_exists(DriveLetter::V)); + share.disconnect().unwrap(); +} + +// ── auto-assigned drive letter ────────────────────────────────────────────── + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn connect_auto_assigns_a_device() { + let share = share(None); + let access_name = share.connect_auto(ConnectOptions::new()).unwrap(); + assert!( + access_name.ends_with(':'), + "expected a device name, got {access_name:?}" + ); + assert!(std::path::Path::new(&format!(r"{access_name}\")).is_dir()); + cancel_connection(&access_name, DisconnectOptions::new()).unwrap(); +} + +// ── query ─────────────────────────────────────────────────────────────────── + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn get_connection_returns_the_remote_name() { + let share = share(Some(DriveLetter::W)); + share.connect().unwrap(); + let remote = query::get_connection("W:").unwrap(); + assert!( + remote.eq_ignore_ascii_case(&share_name()), + "{remote} != {}", + share_name() + ); + share.disconnect().unwrap(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn get_user_returns_a_user() { + let share = share(Some(DriveLetter::W)); + share.connect().unwrap(); + let user = query::get_user(Some("W:")).unwrap(); + assert!(!user.is_empty()); + share.disconnect().unwrap(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn get_universal_name_resolves_a_mounted_path() { + let share = share(Some(DriveLetter::W)); + share.connect().unwrap(); + let unc = query::get_universal_name(r"W:\").unwrap(); + assert!( + unc.to_ascii_lowercase() + .starts_with(&share_name().to_ascii_lowercase()), + "{unc} does not start with {}", + share_name() + ); + share.disconnect().unwrap(); +} + +// ── enumerate ─────────────────────────────────────────────────────────────── + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn enumerate_connections_contains_the_share() { + let share = share(Some(DriveLetter::X)); + share.connect().unwrap(); + let found = enumerate::connections() + .unwrap() + .filter_map(Result::ok) + .any(|r| { + r.remote_name + .is_some_and(|n| n.eq_ignore_ascii_case(&share_name())) + }); + share.disconnect().unwrap(); + assert!(found, "active connection to the test share not enumerated"); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn enumerate_server_shares_contains_the_share() { + // \\server\share -> \\server + let full = share_name(); + let server_root = full + .trim_start_matches('\\') + .split('\\') + .next() + .map(|s| format!(r"\\{s}")) + .unwrap(); + // Authenticate first: servers may refuse anonymous enumeration. + let share = share(None); + share.connect().unwrap(); + let found = enumerate::server_shares(&server_root) + .unwrap() + .filter_map(Result::ok) + .any(|r| r.remote_name.is_some_and(|n| n.eq_ignore_ascii_case(&full))); + share.disconnect().unwrap(); + assert!(found, "test share not found in {server_root}'s share list"); +} + +// ── server administration (local machine only) ────────────────────────────── + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn server_shares_lists_the_test_share() { + if !local_admin() { + eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); + return; + } + let leaf = share_name().rsplit('\\').next().unwrap().to_string(); + let all = server::shares(None).unwrap(); + assert!( + all.iter().any(|s| s.name.eq_ignore_ascii_case(&leaf)), + "share {leaf} not in {all:?}" + ); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn server_add_get_delete_share_roundtrip() { + if !local_admin() { + eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); + return; + } + let dir = std::env::temp_dir().join("sambrs-roundtrip-share"); + std::fs::create_dir_all(&dir).unwrap(); + let name = format!("sambrs-tmp-{}", std::process::id()); + + server::add_share( + None, + &server::NewShare::disk(&name, dir.to_str().unwrap()).remark("sambrs test share"), + ) + .unwrap(); + + let info = server::share_info(None, &name).unwrap(); + assert_eq!(info.name.to_ascii_lowercase(), name.to_ascii_lowercase()); + assert_eq!(info.share_type.kind, server::ShareKind::Disk); + assert_eq!(info.remark.as_deref(), Some("sambrs test share")); + + // Adding the same name again must fail cleanly. + let dup = server::add_share(None, &server::NewShare::disk(&name, dir.to_str().unwrap())); + assert_eq!(dup, Err(Error::DuplicateShare)); + + server::delete_share(None, &name).unwrap(); + assert_eq!(server::share_info(None, &name), Err(Error::NetNameNotFound)); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn server_sessions_and_connections_are_listable() { + if !local_admin() { + eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); + return; + } + let leaf = share_name().rsplit('\\').next().unwrap().to_string(); + let share = share(None); + share.connect().unwrap(); + + // Establishing the connection above means at least one session and one + // connection must be visible. + let sessions = server::sessions(None, None, None).unwrap(); + assert!(!sessions.is_empty(), "no SMB sessions listed"); + + let connections = server::connections(None, &leaf).unwrap(); + assert!(!connections.is_empty(), "no connections to {leaf} listed"); + + share.disconnect().unwrap(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn server_open_files_are_listable_and_closable() { + if !local_admin() { + eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); + return; + } + let share = share(Some(DriveLetter::Y)); + share.connect().unwrap(); + let path = r"Y:\sambrs-open-file.txt"; + let file = std::fs::File::create(path).unwrap(); + + let open = server::open_files(None, None, None).unwrap(); + let ours = open.iter().find(|f| f.path.contains("sambrs-open-file")); + if let Some(ours) = ours { + server::close_file(None, ours.id).unwrap(); + } + + drop(file); + let _ = std::fs::remove_file(path); + share + .disconnect_with(DisconnectOptions::new().force(true)) + .unwrap(); +} From 13b27d7282d3c2dda6591c69fbf53449d79edd2c Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:23:09 +0000 Subject: [PATCH 02/42] add code-review skill --- .agents/skills/code-review-expert/README.md | 74 +++++++++ .agents/skills/code-review-expert/SKILL.md | 156 ++++++++++++++++++ .../code-review-expert/agents/agent.yaml | 7 + .../references/code-quality-checklist.md | 130 +++++++++++++++ .../references/removal-plan.md | 52 ++++++ .../references/security-checklist.md | 118 +++++++++++++ .../references/solid-checklist.md | 65 ++++++++ .claude/skills/code-review-expert/README.md | 74 +++++++++ .claude/skills/code-review-expert/SKILL.md | 156 ++++++++++++++++++ .../code-review-expert/agents/agent.yaml | 7 + .../references/code-quality-checklist.md | 130 +++++++++++++++ .../references/removal-plan.md | 52 ++++++ .../references/security-checklist.md | 118 +++++++++++++ .../references/solid-checklist.md | 65 ++++++++ skills-lock.json | 11 ++ 15 files changed, 1215 insertions(+) create mode 100644 .agents/skills/code-review-expert/README.md create mode 100644 .agents/skills/code-review-expert/SKILL.md create mode 100644 .agents/skills/code-review-expert/agents/agent.yaml create mode 100644 .agents/skills/code-review-expert/references/code-quality-checklist.md create mode 100644 .agents/skills/code-review-expert/references/removal-plan.md create mode 100644 .agents/skills/code-review-expert/references/security-checklist.md create mode 100644 .agents/skills/code-review-expert/references/solid-checklist.md create mode 100644 .claude/skills/code-review-expert/README.md create mode 100644 .claude/skills/code-review-expert/SKILL.md create mode 100644 .claude/skills/code-review-expert/agents/agent.yaml create mode 100644 .claude/skills/code-review-expert/references/code-quality-checklist.md create mode 100644 .claude/skills/code-review-expert/references/removal-plan.md create mode 100644 .claude/skills/code-review-expert/references/security-checklist.md create mode 100644 .claude/skills/code-review-expert/references/solid-checklist.md create mode 100644 skills-lock.json diff --git a/.agents/skills/code-review-expert/README.md b/.agents/skills/code-review-expert/README.md new file mode 100644 index 0000000..138b876 --- /dev/null +++ b/.agents/skills/code-review-expert/README.md @@ -0,0 +1,74 @@ +# Code Review Expert + +A comprehensive code review skill for AI agents. Performs structured reviews with a senior engineer lens, covering architecture, security, performance, and code quality. + +## Installation + +```bash +npx skills add sanyuan0704/sanyuan-skills --path skills/code-review-expert +``` + +## Features + +- **SOLID Principles** - Detect SRP, OCP, LSP, ISP, DIP violations +- **Security Scan** - XSS, injection, SSRF, race conditions, auth gaps, secrets leakage +- **Performance** - N+1 queries, CPU hotspots, missing cache, memory issues +- **Error Handling** - Swallowed exceptions, async errors, missing boundaries +- **Boundary Conditions** - Null handling, empty collections, off-by-one, numeric limits +- **Removal Planning** - Identify dead code with safe deletion plans + +## Usage + +After installation, simply run: + +``` +/code-review-expert +``` + +The skill will automatically review your current git changes. + +## Workflow + +1. **Preflight** - Scope changes via `git diff` +2. **SOLID + Architecture** - Check design principles +3. **Removal Candidates** - Find dead/unused code +4. **Security Scan** - Vulnerability detection +5. **Code Quality** - Error handling, performance, boundaries +6. **Output** - Findings by severity (P0-P3) +7. **Confirmation** - Ask user before implementing fixes + +## Severity Levels + +| Level | Name | Action | +|-------|------|--------| +| P0 | Critical | Must block merge | +| P1 | High | Should fix before merge | +| P2 | Medium | Fix or create follow-up | +| P3 | Low | Optional improvement | + +## Structure + +``` +code-review-expert/ +├── SKILL.md # Main skill definition +├── agents/ +│ └── agent.yaml # Agent interface config +└── references/ + ├── solid-checklist.md # SOLID smell prompts + ├── security-checklist.md # Security & reliability + ├── code-quality-checklist.md # Error, perf, boundaries + └── removal-plan.md # Deletion planning template +``` + +## References + +Each checklist provides detailed prompts and anti-patterns: + +- **solid-checklist.md** - SOLID violations + common code smells +- **security-checklist.md** - OWASP risks, race conditions, crypto, supply chain +- **code-quality-checklist.md** - Error handling, caching, N+1, null safety +- **removal-plan.md** - Safe vs deferred deletion with rollback plans + +## License + +MIT diff --git a/.agents/skills/code-review-expert/SKILL.md b/.agents/skills/code-review-expert/SKILL.md new file mode 100644 index 0000000..ea17fe0 --- /dev/null +++ b/.agents/skills/code-review-expert/SKILL.md @@ -0,0 +1,156 @@ +--- +name: code-review-expert +description: "Expert code review of current git changes with a senior engineer lens. Detects SOLID violations, security risks, and proposes actionable improvements." +--- + +# Code Review Expert + +## Overview + +Perform a structured review of the current git changes with focus on SOLID, architecture, removal candidates, and security risks. Default to review-only output unless the user asks to implement changes. + +## Severity Levels + +| Level | Name | Description | Action | +|-------|------|-------------|--------| +| **P0** | Critical | Security vulnerability, data loss risk, correctness bug | Must block merge | +| **P1** | High | Logic error, significant SOLID violation, performance regression | Should fix before merge | +| **P2** | Medium | Code smell, maintainability concern, minor SOLID violation | Fix in this PR or create follow-up | +| **P3** | Low | Style, naming, minor suggestion | Optional improvement | + +## Workflow + +### 1) Preflight context + +- Use `git status -sb`, `git diff --stat`, and `git diff` to scope changes. +- If needed, use `rg` or `grep` to find related modules, usages, and contracts. +- Identify entry points, ownership boundaries, and critical paths (auth, payments, data writes, network). + +**Edge cases:** +- **No changes**: If `git diff` is empty, inform user and ask if they want to review staged changes or a specific commit range. +- **Large diff (>500 lines)**: Summarize by file first, then review in batches by module/feature area. +- **Mixed concerns**: Group findings by logical feature, not just file order. + +### 2) SOLID + architecture smells + +- Load `references/solid-checklist.md` for specific prompts. +- Look for: + - **SRP**: Overloaded modules with unrelated responsibilities. + - **OCP**: Frequent edits to add behavior instead of extension points. + - **LSP**: Subclasses that break expectations or require type checks. + - **ISP**: Wide interfaces with unused methods. + - **DIP**: High-level logic tied to low-level implementations. +- When you propose a refactor, explain *why* it improves cohesion/coupling and outline a minimal, safe split. +- If refactor is non-trivial, propose an incremental plan instead of a large rewrite. + +### 3) Removal candidates + iteration plan + +- Load `references/removal-plan.md` for template. +- Identify code that is unused, redundant, or feature-flagged off. +- Distinguish **safe delete now** vs **defer with plan**. +- Provide a follow-up plan with concrete steps and checkpoints (tests/metrics). + +### 4) Security and reliability scan + +- Load `references/security-checklist.md` for coverage. +- Check for: + - XSS, injection (SQL/NoSQL/command), SSRF, path traversal + - AuthZ/AuthN gaps, missing tenancy checks + - Secret leakage or API keys in logs/env/files + - Rate limits, unbounded loops, CPU/memory hotspots + - Unsafe deserialization, weak crypto, insecure defaults + - **Race conditions**: concurrent access, check-then-act, TOCTOU, missing locks +- Call out both **exploitability** and **impact**. + +### 5) Code quality scan + +- Load `references/code-quality-checklist.md` for coverage. +- Check for: + - **Error handling**: swallowed exceptions, overly broad catch, missing error handling, async errors + - **Performance**: N+1 queries, CPU-intensive ops in hot paths, missing cache, unbounded memory + - **Boundary conditions**: null/undefined handling, empty collections, numeric boundaries, off-by-one +- Flag issues that may cause silent failures or production incidents. + +### 6) Output format + +Structure your review as follows: + +```markdown +## Code Review Summary + +**Files reviewed**: X files, Y lines changed +**Overall assessment**: [APPROVE / REQUEST_CHANGES / COMMENT] + +--- + +## Findings + +### P0 - Critical +(none or list) + +### P1 - High +1. **[file:line]** Brief title + - Description of issue + - Suggested fix + +### P2 - Medium +2. (continue numbering across sections) + - ... + +### P3 - Low +... + +--- + +## Removal/Iteration Plan +(if applicable) + +## Additional Suggestions +(optional improvements, not blocking) +``` + +**Inline comments**: Use this format for file-specific findings: +``` +::code-comment{file="path/to/file.ts" line="42" severity="P1"} +Description of the issue and suggested fix. +:: +``` + +**Clean review**: If no issues found, explicitly state: +- What was checked +- Any areas not covered (e.g., "Did not verify database migrations") +- Residual risks or recommended follow-up tests + +### 7) Next steps confirmation + +After presenting findings, ask user how to proceed: + +```markdown +--- + +## Next Steps + +I found X issues (P0: _, P1: _, P2: _, P3: _). + +**How would you like to proceed?** + +1. **Fix all** - I'll implement all suggested fixes +2. **Fix P0/P1 only** - Address critical and high priority issues +3. **Fix specific items** - Tell me which issues to fix +4. **No changes** - Review complete, no implementation needed + +Please choose an option or provide specific instructions. +``` + +**Important**: Do NOT implement any changes until user explicitly confirms. This is a review-first workflow. + +## Resources + +### references/ + +| File | Purpose | +|------|---------| +| `solid-checklist.md` | SOLID smell prompts and refactor heuristics | +| `security-checklist.md` | Web/app security and runtime risk checklist | +| `code-quality-checklist.md` | Error handling, performance, boundary conditions | +| `removal-plan.md` | Template for deletion candidates and follow-up plan | diff --git a/.agents/skills/code-review-expert/agents/agent.yaml b/.agents/skills/code-review-expert/agents/agent.yaml new file mode 100644 index 0000000..70be2aa --- /dev/null +++ b/.agents/skills/code-review-expert/agents/agent.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Code Review Expert" + short_description: "Senior engineer code review: SOLID, security, performance, error handling" + default_prompt: "Review current git changes for SOLID violations, security risks, race conditions, error handling issues, performance problems, and boundary condition bugs." + +# Agent-agnostic skill - works with any LLM provider. +# No provider-specific configuration required. diff --git a/.agents/skills/code-review-expert/references/code-quality-checklist.md b/.agents/skills/code-review-expert/references/code-quality-checklist.md new file mode 100644 index 0000000..0dbfb87 --- /dev/null +++ b/.agents/skills/code-review-expert/references/code-quality-checklist.md @@ -0,0 +1,130 @@ +# Code Quality Checklist + +## Error Handling + +### Anti-patterns to Flag + +- **Swallowed exceptions**: Empty catch blocks or catch with only logging + ```javascript + try { ... } catch (e) { } // Silent failure + try { ... } catch (e) { console.log(e) } // Log and forget + ``` +- **Overly broad catch**: Catching `Exception`/`Error` base class instead of specific types +- **Error information leakage**: Stack traces or internal details exposed to users +- **Missing error handling**: No try-catch around fallible operations (I/O, network, parsing) +- **Async error handling**: Unhandled promise rejections, missing `.catch()`, no error boundary + +### Best Practices to Check + +- [ ] Errors are caught at appropriate boundaries +- [ ] Error messages are user-friendly (no internal details exposed) +- [ ] Errors are logged with sufficient context for debugging +- [ ] Async errors are properly propagated or handled +- [ ] Fallback behavior is defined for recoverable errors +- [ ] Critical errors trigger alerts/monitoring + +### Questions to Ask +- "What happens when this operation fails?" +- "Will the caller know something went wrong?" +- "Is there enough context to debug this error?" + +--- + +## Performance & Caching + +### CPU-Intensive Operations + +- **Expensive operations in hot paths**: Regex compilation, JSON parsing, crypto in loops +- **Blocking main thread**: Sync I/O, heavy computation without worker/async +- **Unnecessary recomputation**: Same calculation done multiple times +- **Missing memoization**: Pure functions called repeatedly with same inputs + +### Database & I/O + +- **N+1 queries**: Loop that makes a query per item instead of batch + ```javascript + // Bad: N+1 + for (const id of ids) { + const user = await db.query(`SELECT * FROM users WHERE id = ?`, id) + } + // Good: Batch + const users = await db.query(`SELECT * FROM users WHERE id IN (?)`, ids) + ``` +- **Missing indexes**: Queries on unindexed columns +- **Over-fetching**: SELECT * when only few columns needed +- **No pagination**: Loading entire dataset into memory + +### Caching Issues + +- **Missing cache for expensive operations**: Repeated API calls, DB queries, computations +- **Cache without TTL**: Stale data served indefinitely +- **Cache without invalidation strategy**: Data updated but cache not cleared +- **Cache key collisions**: Insufficient key uniqueness +- **Caching user-specific data globally**: Security/privacy issue + +### Memory + +- **Unbounded collections**: Arrays/maps that grow without limit +- **Large object retention**: Holding references preventing GC +- **String concatenation in loops**: Use StringBuilder/join instead +- **Loading large files entirely**: Use streaming instead + +### Questions to Ask +- "What's the time complexity of this operation?" +- "How does this behave with 10x/100x data?" +- "Is this result cacheable? Should it be?" +- "Can this be batched instead of one-by-one?" + +--- + +## Boundary Conditions + +### Null/Undefined Handling + +- **Missing null checks**: Accessing properties on potentially null objects +- **Truthy/falsy confusion**: `if (value)` when `0` or `""` are valid +- **Optional chaining overuse**: `a?.b?.c?.d` hiding structural issues +- **Null vs undefined inconsistency**: Mixed usage without clear convention + +### Empty Collections + +- **Empty array not handled**: Code assumes array has items +- **Empty object edge case**: `for...in` or `Object.keys` on empty object +- **First/last element access**: `arr[0]` or `arr[arr.length-1]` without length check + +### Numeric Boundaries + +- **Division by zero**: Missing check before division +- **Integer overflow**: Large numbers exceeding safe integer range +- **Floating point comparison**: Using `===` instead of epsilon comparison +- **Negative values**: Index or count that shouldn't be negative +- **Off-by-one errors**: Loop bounds, array slicing, pagination + +### String Boundaries + +- **Empty string**: Not handled as edge case +- **Whitespace-only string**: Passes truthy check but is effectively empty +- **Very long strings**: No length limits causing memory/display issues +- **Unicode edge cases**: Emoji, RTL text, combining characters + +### Common Patterns to Flag + +```javascript +// Dangerous: no null check +const name = user.profile.name + +// Dangerous: array access without check +const first = items[0] + +// Dangerous: division without check +const avg = total / count + +// Dangerous: truthy check excludes valid values +if (value) { ... } // fails for 0, "", false +``` + +### Questions to Ask +- "What if this is null/undefined?" +- "What if this collection is empty?" +- "What's the valid range for this number?" +- "What happens at the boundaries (0, -1, MAX_INT)?" diff --git a/.agents/skills/code-review-expert/references/removal-plan.md b/.agents/skills/code-review-expert/references/removal-plan.md new file mode 100644 index 0000000..33f2383 --- /dev/null +++ b/.agents/skills/code-review-expert/references/removal-plan.md @@ -0,0 +1,52 @@ +# Removal and Iteration Plan Template + +## Priority Levels + +- [ ] **P0**: Immediate removal needed (security risk, significant cost, blocking other work) +- [ ] **P1**: Remove in current sprint +- [ ] **P2**: Backlog / next iteration + +--- + +## Safe to Remove Now + +### Item: [Name/Description] + +| Field | Details | +|-------|---------| +| **Location** | `path/to/file.ts:line` | +| **Rationale** | Why this should be removed | +| **Evidence** | Unused (no references), dead feature flag, deprecated API | +| **Impact** | None / Low - no active consumers | +| **Deletion steps** | 1. Remove code 2. Remove tests 3. Remove config | +| **Verification** | Run tests, check no runtime errors, monitor logs | + +--- + +## Defer Removal (Plan Required) + +### Item: [Name/Description] + +| Field | Details | +|-------|---------| +| **Location** | `path/to/file.ts:line` | +| **Why defer** | Active consumers, needs migration, stakeholder sign-off | +| **Preconditions** | Feature flag off for 2 weeks, telemetry shows 0 usage | +| **Breaking changes** | List any API/contract changes | +| **Migration plan** | Steps for consumers to migrate | +| **Timeline** | Target date or sprint | +| **Owner** | Person/team responsible | +| **Validation** | Metrics to confirm safe removal (error rates, usage counts) | +| **Rollback plan** | How to restore if issues found | + +--- + +## Checklist Before Removal + +- [ ] Searched codebase for all references (`rg`, `grep`) +- [ ] Checked for dynamic/reflection-based usage +- [ ] Verified no external consumers (APIs, SDKs, docs) +- [ ] Feature flag telemetry reviewed (if applicable) +- [ ] Tests updated/removed +- [ ] Documentation updated +- [ ] Team notified (if shared code) diff --git a/.agents/skills/code-review-expert/references/security-checklist.md b/.agents/skills/code-review-expert/references/security-checklist.md new file mode 100644 index 0000000..193469e --- /dev/null +++ b/.agents/skills/code-review-expert/references/security-checklist.md @@ -0,0 +1,118 @@ +# Security and Reliability Checklist + +## Input/Output Safety + +- **XSS**: Unsafe HTML injection, `dangerouslySetInnerHTML`, unescaped templates, innerHTML assignments +- **Injection**: SQL/NoSQL/command/GraphQL injection via string concatenation or template literals +- **SSRF**: User-controlled URLs reaching internal services without allowlist validation +- **Path traversal**: User input in file paths without sanitization (`../` attacks) +- **Prototype pollution**: Unsafe object merging in JavaScript (`Object.assign`, spread with user input) + +## AuthN/AuthZ + +- Missing tenant or ownership checks for read/write operations +- New endpoints without auth guards or RBAC enforcement +- Trusting client-provided roles/flags/IDs +- Broken access control (IDOR - Insecure Direct Object Reference) +- Session fixation or weak session management + +## JWT & Token Security + +- Algorithm confusion attacks (accepting `none` or `HS256` when expecting `RS256`) +- Weak or hardcoded secrets +- Missing expiration (`exp`) or not validating it +- Sensitive data in JWT payload (tokens are base64, not encrypted) +- Not validating `iss` (issuer) or `aud` (audience) + +## Secrets and PII + +- API keys, tokens, or credentials in code/config/logs +- Secrets in git history or environment variables exposed to client +- Excessive logging of PII or sensitive payloads +- Missing data masking in error messages + +## Supply Chain & Dependencies + +- Unpinned dependencies allowing malicious updates +- Dependency confusion (private package name collision) +- Importing from untrusted sources or CDNs without integrity checks +- Outdated dependencies with known CVEs + +## CORS & Headers + +- Overly permissive CORS (`Access-Control-Allow-Origin: *` with credentials) +- Missing security headers (CSP, X-Frame-Options, X-Content-Type-Options) +- Exposed internal headers or stack traces + +## Runtime Risks + +- Unbounded loops, recursive calls, or large in-memory buffers +- Missing timeouts, retries, or rate limiting on external calls +- Blocking operations on request path (sync I/O in async context) +- Resource exhaustion (file handles, connections, memory) +- ReDoS (Regular Expression Denial of Service) + +## Cryptography + +- Weak algorithms (MD5, SHA1 for security purposes) +- Hardcoded IVs or salts +- Using encryption without authentication (ECB mode, no HMAC) +- Insufficient key length + +## Race Conditions + +Race conditions are subtle bugs that cause intermittent failures and security vulnerabilities. Pay special attention to: + +### Shared State Access +- Multiple threads/goroutines/async tasks accessing shared variables without synchronization +- Global state or singletons modified concurrently +- Lazy initialization without proper locking (double-checked locking issues) +- Non-thread-safe collections used in concurrent context + +### Check-Then-Act (TOCTOU) +- `if (exists) then use` patterns without atomic operations +- `if (authorized) then perform` where authorization can change +- File existence check followed by file operation +- Balance check followed by deduction (financial operations) +- Inventory check followed by order placement + +### Database Concurrency +- Missing optimistic locking (`version` column, `updated_at` checks) +- Missing pessimistic locking (`SELECT FOR UPDATE`) +- Read-modify-write without transaction isolation +- Counter increments without atomic operations (`UPDATE SET count = count + 1`) +- Unique constraint violations in concurrent inserts + +### Distributed Systems +- Missing distributed locks for shared resources +- Leader election race conditions +- Cache invalidation races (stale reads after writes) +- Event ordering dependencies without proper sequencing +- Split-brain scenarios in cluster operations + +### Common Patterns to Flag +``` +# Dangerous patterns: +if not exists(key): # TOCTOU + create(key) + +value = get(key) # Read-modify-write +value += 1 +set(key, value) + +if user.balance >= amount: # Check-then-act + user.balance -= amount +``` + +### Questions to Ask +- "What happens if two requests hit this code simultaneously?" +- "Is this operation atomic or can it be interrupted?" +- "What shared state does this code access?" +- "How does this behave under high concurrency?" + +## Data Integrity + +- Missing transactions, partial writes, or inconsistent state updates +- Weak validation before persistence (type coercion issues) +- Missing idempotency for retryable operations +- Lost updates due to concurrent modifications diff --git a/.agents/skills/code-review-expert/references/solid-checklist.md b/.agents/skills/code-review-expert/references/solid-checklist.md new file mode 100644 index 0000000..0d08c7a --- /dev/null +++ b/.agents/skills/code-review-expert/references/solid-checklist.md @@ -0,0 +1,65 @@ +# SOLID Smell Prompts + +## SRP (Single Responsibility) + +- File owns unrelated concerns (e.g., HTTP + DB + domain rules in one file) +- Large class/module with low cohesion or multiple reasons to change +- Functions that orchestrate many unrelated steps +- God objects that know too much about the system +- **Ask**: "What is the single reason this module would change?" + +## OCP (Open/Closed) + +- Adding a new behavior requires editing many switch/if blocks +- Feature growth requires modifying core logic rather than extending +- No plugin/strategy/hook points for variation +- **Ask**: "Can I add a new variant without touching existing code?" + +## LSP (Liskov Substitution) + +- Subclass checks for concrete type or throws for base method +- Overridden methods weaken preconditions or strengthen postconditions +- Subclass ignores or no-ops parent behavior +- **Ask**: "Can I substitute any subclass without the caller knowing?" + +## ISP (Interface Segregation) + +- Interfaces with many methods, most unused by implementers +- Callers depend on broad interfaces for narrow needs +- Empty/stub implementations of interface methods +- **Ask**: "Do all implementers use all methods?" + +## DIP (Dependency Inversion) + +- High-level logic depends on concrete IO, storage, or network types +- Hard-coded implementations instead of abstractions or injection +- Import chains that couple business logic to infrastructure +- **Ask**: "Can I swap the implementation without changing business logic?" + +--- + +## Common Code Smells (Beyond SOLID) + +| Smell | Signs | +|-------|-------| +| **Long method** | Function > 30 lines, multiple levels of nesting | +| **Feature envy** | Method uses more data from another class than its own | +| **Data clumps** | Same group of parameters passed together repeatedly | +| **Primitive obsession** | Using strings/numbers instead of domain types | +| **Shotgun surgery** | One change requires edits across many files | +| **Divergent change** | One file changes for many unrelated reasons | +| **Dead code** | Unreachable or never-called code | +| **Speculative generality** | Abstractions for hypothetical future needs | +| **Magic numbers/strings** | Hardcoded values without named constants | + +--- + +## Refactor Heuristics + +1. **Split by responsibility, not by size** - A small file can still violate SRP +2. **Introduce abstraction only when needed** - Wait for the second use case +3. **Keep refactors incremental** - Isolate behavior before moving +4. **Preserve behavior first** - Add tests before restructuring +5. **Name things by intent** - If naming is hard, the abstraction might be wrong +6. **Prefer composition over inheritance** - Inheritance creates tight coupling +7. **Make illegal states unrepresentable** - Use types to enforce invariants diff --git a/.claude/skills/code-review-expert/README.md b/.claude/skills/code-review-expert/README.md new file mode 100644 index 0000000..138b876 --- /dev/null +++ b/.claude/skills/code-review-expert/README.md @@ -0,0 +1,74 @@ +# Code Review Expert + +A comprehensive code review skill for AI agents. Performs structured reviews with a senior engineer lens, covering architecture, security, performance, and code quality. + +## Installation + +```bash +npx skills add sanyuan0704/sanyuan-skills --path skills/code-review-expert +``` + +## Features + +- **SOLID Principles** - Detect SRP, OCP, LSP, ISP, DIP violations +- **Security Scan** - XSS, injection, SSRF, race conditions, auth gaps, secrets leakage +- **Performance** - N+1 queries, CPU hotspots, missing cache, memory issues +- **Error Handling** - Swallowed exceptions, async errors, missing boundaries +- **Boundary Conditions** - Null handling, empty collections, off-by-one, numeric limits +- **Removal Planning** - Identify dead code with safe deletion plans + +## Usage + +After installation, simply run: + +``` +/code-review-expert +``` + +The skill will automatically review your current git changes. + +## Workflow + +1. **Preflight** - Scope changes via `git diff` +2. **SOLID + Architecture** - Check design principles +3. **Removal Candidates** - Find dead/unused code +4. **Security Scan** - Vulnerability detection +5. **Code Quality** - Error handling, performance, boundaries +6. **Output** - Findings by severity (P0-P3) +7. **Confirmation** - Ask user before implementing fixes + +## Severity Levels + +| Level | Name | Action | +|-------|------|--------| +| P0 | Critical | Must block merge | +| P1 | High | Should fix before merge | +| P2 | Medium | Fix or create follow-up | +| P3 | Low | Optional improvement | + +## Structure + +``` +code-review-expert/ +├── SKILL.md # Main skill definition +├── agents/ +│ └── agent.yaml # Agent interface config +└── references/ + ├── solid-checklist.md # SOLID smell prompts + ├── security-checklist.md # Security & reliability + ├── code-quality-checklist.md # Error, perf, boundaries + └── removal-plan.md # Deletion planning template +``` + +## References + +Each checklist provides detailed prompts and anti-patterns: + +- **solid-checklist.md** - SOLID violations + common code smells +- **security-checklist.md** - OWASP risks, race conditions, crypto, supply chain +- **code-quality-checklist.md** - Error handling, caching, N+1, null safety +- **removal-plan.md** - Safe vs deferred deletion with rollback plans + +## License + +MIT diff --git a/.claude/skills/code-review-expert/SKILL.md b/.claude/skills/code-review-expert/SKILL.md new file mode 100644 index 0000000..ea17fe0 --- /dev/null +++ b/.claude/skills/code-review-expert/SKILL.md @@ -0,0 +1,156 @@ +--- +name: code-review-expert +description: "Expert code review of current git changes with a senior engineer lens. Detects SOLID violations, security risks, and proposes actionable improvements." +--- + +# Code Review Expert + +## Overview + +Perform a structured review of the current git changes with focus on SOLID, architecture, removal candidates, and security risks. Default to review-only output unless the user asks to implement changes. + +## Severity Levels + +| Level | Name | Description | Action | +|-------|------|-------------|--------| +| **P0** | Critical | Security vulnerability, data loss risk, correctness bug | Must block merge | +| **P1** | High | Logic error, significant SOLID violation, performance regression | Should fix before merge | +| **P2** | Medium | Code smell, maintainability concern, minor SOLID violation | Fix in this PR or create follow-up | +| **P3** | Low | Style, naming, minor suggestion | Optional improvement | + +## Workflow + +### 1) Preflight context + +- Use `git status -sb`, `git diff --stat`, and `git diff` to scope changes. +- If needed, use `rg` or `grep` to find related modules, usages, and contracts. +- Identify entry points, ownership boundaries, and critical paths (auth, payments, data writes, network). + +**Edge cases:** +- **No changes**: If `git diff` is empty, inform user and ask if they want to review staged changes or a specific commit range. +- **Large diff (>500 lines)**: Summarize by file first, then review in batches by module/feature area. +- **Mixed concerns**: Group findings by logical feature, not just file order. + +### 2) SOLID + architecture smells + +- Load `references/solid-checklist.md` for specific prompts. +- Look for: + - **SRP**: Overloaded modules with unrelated responsibilities. + - **OCP**: Frequent edits to add behavior instead of extension points. + - **LSP**: Subclasses that break expectations or require type checks. + - **ISP**: Wide interfaces with unused methods. + - **DIP**: High-level logic tied to low-level implementations. +- When you propose a refactor, explain *why* it improves cohesion/coupling and outline a minimal, safe split. +- If refactor is non-trivial, propose an incremental plan instead of a large rewrite. + +### 3) Removal candidates + iteration plan + +- Load `references/removal-plan.md` for template. +- Identify code that is unused, redundant, or feature-flagged off. +- Distinguish **safe delete now** vs **defer with plan**. +- Provide a follow-up plan with concrete steps and checkpoints (tests/metrics). + +### 4) Security and reliability scan + +- Load `references/security-checklist.md` for coverage. +- Check for: + - XSS, injection (SQL/NoSQL/command), SSRF, path traversal + - AuthZ/AuthN gaps, missing tenancy checks + - Secret leakage or API keys in logs/env/files + - Rate limits, unbounded loops, CPU/memory hotspots + - Unsafe deserialization, weak crypto, insecure defaults + - **Race conditions**: concurrent access, check-then-act, TOCTOU, missing locks +- Call out both **exploitability** and **impact**. + +### 5) Code quality scan + +- Load `references/code-quality-checklist.md` for coverage. +- Check for: + - **Error handling**: swallowed exceptions, overly broad catch, missing error handling, async errors + - **Performance**: N+1 queries, CPU-intensive ops in hot paths, missing cache, unbounded memory + - **Boundary conditions**: null/undefined handling, empty collections, numeric boundaries, off-by-one +- Flag issues that may cause silent failures or production incidents. + +### 6) Output format + +Structure your review as follows: + +```markdown +## Code Review Summary + +**Files reviewed**: X files, Y lines changed +**Overall assessment**: [APPROVE / REQUEST_CHANGES / COMMENT] + +--- + +## Findings + +### P0 - Critical +(none or list) + +### P1 - High +1. **[file:line]** Brief title + - Description of issue + - Suggested fix + +### P2 - Medium +2. (continue numbering across sections) + - ... + +### P3 - Low +... + +--- + +## Removal/Iteration Plan +(if applicable) + +## Additional Suggestions +(optional improvements, not blocking) +``` + +**Inline comments**: Use this format for file-specific findings: +``` +::code-comment{file="path/to/file.ts" line="42" severity="P1"} +Description of the issue and suggested fix. +:: +``` + +**Clean review**: If no issues found, explicitly state: +- What was checked +- Any areas not covered (e.g., "Did not verify database migrations") +- Residual risks or recommended follow-up tests + +### 7) Next steps confirmation + +After presenting findings, ask user how to proceed: + +```markdown +--- + +## Next Steps + +I found X issues (P0: _, P1: _, P2: _, P3: _). + +**How would you like to proceed?** + +1. **Fix all** - I'll implement all suggested fixes +2. **Fix P0/P1 only** - Address critical and high priority issues +3. **Fix specific items** - Tell me which issues to fix +4. **No changes** - Review complete, no implementation needed + +Please choose an option or provide specific instructions. +``` + +**Important**: Do NOT implement any changes until user explicitly confirms. This is a review-first workflow. + +## Resources + +### references/ + +| File | Purpose | +|------|---------| +| `solid-checklist.md` | SOLID smell prompts and refactor heuristics | +| `security-checklist.md` | Web/app security and runtime risk checklist | +| `code-quality-checklist.md` | Error handling, performance, boundary conditions | +| `removal-plan.md` | Template for deletion candidates and follow-up plan | diff --git a/.claude/skills/code-review-expert/agents/agent.yaml b/.claude/skills/code-review-expert/agents/agent.yaml new file mode 100644 index 0000000..70be2aa --- /dev/null +++ b/.claude/skills/code-review-expert/agents/agent.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Code Review Expert" + short_description: "Senior engineer code review: SOLID, security, performance, error handling" + default_prompt: "Review current git changes for SOLID violations, security risks, race conditions, error handling issues, performance problems, and boundary condition bugs." + +# Agent-agnostic skill - works with any LLM provider. +# No provider-specific configuration required. diff --git a/.claude/skills/code-review-expert/references/code-quality-checklist.md b/.claude/skills/code-review-expert/references/code-quality-checklist.md new file mode 100644 index 0000000..0dbfb87 --- /dev/null +++ b/.claude/skills/code-review-expert/references/code-quality-checklist.md @@ -0,0 +1,130 @@ +# Code Quality Checklist + +## Error Handling + +### Anti-patterns to Flag + +- **Swallowed exceptions**: Empty catch blocks or catch with only logging + ```javascript + try { ... } catch (e) { } // Silent failure + try { ... } catch (e) { console.log(e) } // Log and forget + ``` +- **Overly broad catch**: Catching `Exception`/`Error` base class instead of specific types +- **Error information leakage**: Stack traces or internal details exposed to users +- **Missing error handling**: No try-catch around fallible operations (I/O, network, parsing) +- **Async error handling**: Unhandled promise rejections, missing `.catch()`, no error boundary + +### Best Practices to Check + +- [ ] Errors are caught at appropriate boundaries +- [ ] Error messages are user-friendly (no internal details exposed) +- [ ] Errors are logged with sufficient context for debugging +- [ ] Async errors are properly propagated or handled +- [ ] Fallback behavior is defined for recoverable errors +- [ ] Critical errors trigger alerts/monitoring + +### Questions to Ask +- "What happens when this operation fails?" +- "Will the caller know something went wrong?" +- "Is there enough context to debug this error?" + +--- + +## Performance & Caching + +### CPU-Intensive Operations + +- **Expensive operations in hot paths**: Regex compilation, JSON parsing, crypto in loops +- **Blocking main thread**: Sync I/O, heavy computation without worker/async +- **Unnecessary recomputation**: Same calculation done multiple times +- **Missing memoization**: Pure functions called repeatedly with same inputs + +### Database & I/O + +- **N+1 queries**: Loop that makes a query per item instead of batch + ```javascript + // Bad: N+1 + for (const id of ids) { + const user = await db.query(`SELECT * FROM users WHERE id = ?`, id) + } + // Good: Batch + const users = await db.query(`SELECT * FROM users WHERE id IN (?)`, ids) + ``` +- **Missing indexes**: Queries on unindexed columns +- **Over-fetching**: SELECT * when only few columns needed +- **No pagination**: Loading entire dataset into memory + +### Caching Issues + +- **Missing cache for expensive operations**: Repeated API calls, DB queries, computations +- **Cache without TTL**: Stale data served indefinitely +- **Cache without invalidation strategy**: Data updated but cache not cleared +- **Cache key collisions**: Insufficient key uniqueness +- **Caching user-specific data globally**: Security/privacy issue + +### Memory + +- **Unbounded collections**: Arrays/maps that grow without limit +- **Large object retention**: Holding references preventing GC +- **String concatenation in loops**: Use StringBuilder/join instead +- **Loading large files entirely**: Use streaming instead + +### Questions to Ask +- "What's the time complexity of this operation?" +- "How does this behave with 10x/100x data?" +- "Is this result cacheable? Should it be?" +- "Can this be batched instead of one-by-one?" + +--- + +## Boundary Conditions + +### Null/Undefined Handling + +- **Missing null checks**: Accessing properties on potentially null objects +- **Truthy/falsy confusion**: `if (value)` when `0` or `""` are valid +- **Optional chaining overuse**: `a?.b?.c?.d` hiding structural issues +- **Null vs undefined inconsistency**: Mixed usage without clear convention + +### Empty Collections + +- **Empty array not handled**: Code assumes array has items +- **Empty object edge case**: `for...in` or `Object.keys` on empty object +- **First/last element access**: `arr[0]` or `arr[arr.length-1]` without length check + +### Numeric Boundaries + +- **Division by zero**: Missing check before division +- **Integer overflow**: Large numbers exceeding safe integer range +- **Floating point comparison**: Using `===` instead of epsilon comparison +- **Negative values**: Index or count that shouldn't be negative +- **Off-by-one errors**: Loop bounds, array slicing, pagination + +### String Boundaries + +- **Empty string**: Not handled as edge case +- **Whitespace-only string**: Passes truthy check but is effectively empty +- **Very long strings**: No length limits causing memory/display issues +- **Unicode edge cases**: Emoji, RTL text, combining characters + +### Common Patterns to Flag + +```javascript +// Dangerous: no null check +const name = user.profile.name + +// Dangerous: array access without check +const first = items[0] + +// Dangerous: division without check +const avg = total / count + +// Dangerous: truthy check excludes valid values +if (value) { ... } // fails for 0, "", false +``` + +### Questions to Ask +- "What if this is null/undefined?" +- "What if this collection is empty?" +- "What's the valid range for this number?" +- "What happens at the boundaries (0, -1, MAX_INT)?" diff --git a/.claude/skills/code-review-expert/references/removal-plan.md b/.claude/skills/code-review-expert/references/removal-plan.md new file mode 100644 index 0000000..33f2383 --- /dev/null +++ b/.claude/skills/code-review-expert/references/removal-plan.md @@ -0,0 +1,52 @@ +# Removal and Iteration Plan Template + +## Priority Levels + +- [ ] **P0**: Immediate removal needed (security risk, significant cost, blocking other work) +- [ ] **P1**: Remove in current sprint +- [ ] **P2**: Backlog / next iteration + +--- + +## Safe to Remove Now + +### Item: [Name/Description] + +| Field | Details | +|-------|---------| +| **Location** | `path/to/file.ts:line` | +| **Rationale** | Why this should be removed | +| **Evidence** | Unused (no references), dead feature flag, deprecated API | +| **Impact** | None / Low - no active consumers | +| **Deletion steps** | 1. Remove code 2. Remove tests 3. Remove config | +| **Verification** | Run tests, check no runtime errors, monitor logs | + +--- + +## Defer Removal (Plan Required) + +### Item: [Name/Description] + +| Field | Details | +|-------|---------| +| **Location** | `path/to/file.ts:line` | +| **Why defer** | Active consumers, needs migration, stakeholder sign-off | +| **Preconditions** | Feature flag off for 2 weeks, telemetry shows 0 usage | +| **Breaking changes** | List any API/contract changes | +| **Migration plan** | Steps for consumers to migrate | +| **Timeline** | Target date or sprint | +| **Owner** | Person/team responsible | +| **Validation** | Metrics to confirm safe removal (error rates, usage counts) | +| **Rollback plan** | How to restore if issues found | + +--- + +## Checklist Before Removal + +- [ ] Searched codebase for all references (`rg`, `grep`) +- [ ] Checked for dynamic/reflection-based usage +- [ ] Verified no external consumers (APIs, SDKs, docs) +- [ ] Feature flag telemetry reviewed (if applicable) +- [ ] Tests updated/removed +- [ ] Documentation updated +- [ ] Team notified (if shared code) diff --git a/.claude/skills/code-review-expert/references/security-checklist.md b/.claude/skills/code-review-expert/references/security-checklist.md new file mode 100644 index 0000000..193469e --- /dev/null +++ b/.claude/skills/code-review-expert/references/security-checklist.md @@ -0,0 +1,118 @@ +# Security and Reliability Checklist + +## Input/Output Safety + +- **XSS**: Unsafe HTML injection, `dangerouslySetInnerHTML`, unescaped templates, innerHTML assignments +- **Injection**: SQL/NoSQL/command/GraphQL injection via string concatenation or template literals +- **SSRF**: User-controlled URLs reaching internal services without allowlist validation +- **Path traversal**: User input in file paths without sanitization (`../` attacks) +- **Prototype pollution**: Unsafe object merging in JavaScript (`Object.assign`, spread with user input) + +## AuthN/AuthZ + +- Missing tenant or ownership checks for read/write operations +- New endpoints without auth guards or RBAC enforcement +- Trusting client-provided roles/flags/IDs +- Broken access control (IDOR - Insecure Direct Object Reference) +- Session fixation or weak session management + +## JWT & Token Security + +- Algorithm confusion attacks (accepting `none` or `HS256` when expecting `RS256`) +- Weak or hardcoded secrets +- Missing expiration (`exp`) or not validating it +- Sensitive data in JWT payload (tokens are base64, not encrypted) +- Not validating `iss` (issuer) or `aud` (audience) + +## Secrets and PII + +- API keys, tokens, or credentials in code/config/logs +- Secrets in git history or environment variables exposed to client +- Excessive logging of PII or sensitive payloads +- Missing data masking in error messages + +## Supply Chain & Dependencies + +- Unpinned dependencies allowing malicious updates +- Dependency confusion (private package name collision) +- Importing from untrusted sources or CDNs without integrity checks +- Outdated dependencies with known CVEs + +## CORS & Headers + +- Overly permissive CORS (`Access-Control-Allow-Origin: *` with credentials) +- Missing security headers (CSP, X-Frame-Options, X-Content-Type-Options) +- Exposed internal headers or stack traces + +## Runtime Risks + +- Unbounded loops, recursive calls, or large in-memory buffers +- Missing timeouts, retries, or rate limiting on external calls +- Blocking operations on request path (sync I/O in async context) +- Resource exhaustion (file handles, connections, memory) +- ReDoS (Regular Expression Denial of Service) + +## Cryptography + +- Weak algorithms (MD5, SHA1 for security purposes) +- Hardcoded IVs or salts +- Using encryption without authentication (ECB mode, no HMAC) +- Insufficient key length + +## Race Conditions + +Race conditions are subtle bugs that cause intermittent failures and security vulnerabilities. Pay special attention to: + +### Shared State Access +- Multiple threads/goroutines/async tasks accessing shared variables without synchronization +- Global state or singletons modified concurrently +- Lazy initialization without proper locking (double-checked locking issues) +- Non-thread-safe collections used in concurrent context + +### Check-Then-Act (TOCTOU) +- `if (exists) then use` patterns without atomic operations +- `if (authorized) then perform` where authorization can change +- File existence check followed by file operation +- Balance check followed by deduction (financial operations) +- Inventory check followed by order placement + +### Database Concurrency +- Missing optimistic locking (`version` column, `updated_at` checks) +- Missing pessimistic locking (`SELECT FOR UPDATE`) +- Read-modify-write without transaction isolation +- Counter increments without atomic operations (`UPDATE SET count = count + 1`) +- Unique constraint violations in concurrent inserts + +### Distributed Systems +- Missing distributed locks for shared resources +- Leader election race conditions +- Cache invalidation races (stale reads after writes) +- Event ordering dependencies without proper sequencing +- Split-brain scenarios in cluster operations + +### Common Patterns to Flag +``` +# Dangerous patterns: +if not exists(key): # TOCTOU + create(key) + +value = get(key) # Read-modify-write +value += 1 +set(key, value) + +if user.balance >= amount: # Check-then-act + user.balance -= amount +``` + +### Questions to Ask +- "What happens if two requests hit this code simultaneously?" +- "Is this operation atomic or can it be interrupted?" +- "What shared state does this code access?" +- "How does this behave under high concurrency?" + +## Data Integrity + +- Missing transactions, partial writes, or inconsistent state updates +- Weak validation before persistence (type coercion issues) +- Missing idempotency for retryable operations +- Lost updates due to concurrent modifications diff --git a/.claude/skills/code-review-expert/references/solid-checklist.md b/.claude/skills/code-review-expert/references/solid-checklist.md new file mode 100644 index 0000000..0d08c7a --- /dev/null +++ b/.claude/skills/code-review-expert/references/solid-checklist.md @@ -0,0 +1,65 @@ +# SOLID Smell Prompts + +## SRP (Single Responsibility) + +- File owns unrelated concerns (e.g., HTTP + DB + domain rules in one file) +- Large class/module with low cohesion or multiple reasons to change +- Functions that orchestrate many unrelated steps +- God objects that know too much about the system +- **Ask**: "What is the single reason this module would change?" + +## OCP (Open/Closed) + +- Adding a new behavior requires editing many switch/if blocks +- Feature growth requires modifying core logic rather than extending +- No plugin/strategy/hook points for variation +- **Ask**: "Can I add a new variant without touching existing code?" + +## LSP (Liskov Substitution) + +- Subclass checks for concrete type or throws for base method +- Overridden methods weaken preconditions or strengthen postconditions +- Subclass ignores or no-ops parent behavior +- **Ask**: "Can I substitute any subclass without the caller knowing?" + +## ISP (Interface Segregation) + +- Interfaces with many methods, most unused by implementers +- Callers depend on broad interfaces for narrow needs +- Empty/stub implementations of interface methods +- **Ask**: "Do all implementers use all methods?" + +## DIP (Dependency Inversion) + +- High-level logic depends on concrete IO, storage, or network types +- Hard-coded implementations instead of abstractions or injection +- Import chains that couple business logic to infrastructure +- **Ask**: "Can I swap the implementation without changing business logic?" + +--- + +## Common Code Smells (Beyond SOLID) + +| Smell | Signs | +|-------|-------| +| **Long method** | Function > 30 lines, multiple levels of nesting | +| **Feature envy** | Method uses more data from another class than its own | +| **Data clumps** | Same group of parameters passed together repeatedly | +| **Primitive obsession** | Using strings/numbers instead of domain types | +| **Shotgun surgery** | One change requires edits across many files | +| **Divergent change** | One file changes for many unrelated reasons | +| **Dead code** | Unreachable or never-called code | +| **Speculative generality** | Abstractions for hypothetical future needs | +| **Magic numbers/strings** | Hardcoded values without named constants | + +--- + +## Refactor Heuristics + +1. **Split by responsibility, not by size** - A small file can still violate SRP +2. **Introduce abstraction only when needed** - Wait for the second use case +3. **Keep refactors incremental** - Isolate behavior before moving +4. **Preserve behavior first** - Add tests before restructuring +5. **Name things by intent** - If naming is hard, the abstraction might be wrong +6. **Prefer composition over inheritance** - Inheritance creates tight coupling +7. **Make illegal states unrepresentable** - Use types to enforce invariants diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..2d356fb --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "code-review-expert": { + "source": "sanyuan0704/code-review-expert", + "sourceType": "github", + "skillPath": "skills/code-review-expert/SKILL.md", + "computedHash": "6c2fe31851a34e63e033257527eab04eea835ca1cf4c4276d1392a323e36e377" + } + } +} From 0170f947fbc3913162cb14e4d04b0d416c0d3259 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:39:56 +0000 Subject: [PATCH 03/42] fix(strings): reject interior NUL before allocating the wide buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to_wide encoded the input into a Vec and only then scanned for an interior NUL; on the error path that vec (possibly holding a password) was dropped un-wiped, defeating the zeroize guarantee of to_wide_secret. Checking the &str up front is exactly equivalent — a non-NUL char never encodes to a 0 UTF-16 unit — and leaves the error path allocation-free. Co-Authored-By: Claude Fable 5 --- src/strings.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/strings.rs b/src/strings.rs index ed476a5..ae3c50c 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -7,14 +7,17 @@ use crate::{Error, Result}; /// /// The buffer is allocated with its final capacity up front so no intermediate /// heap copy of the data is left behind (relevant for the `zeroize` feature). +/// The interior-NUL check runs on the `&str` before any allocation — a non-NUL +/// char never encodes to a 0 UTF-16 unit, so it is equivalent to scanning the +/// encoded buffer — which keeps the error path free of transient copies too. pub(crate) fn to_wide(s: &str) -> Result> { + if s.contains('\0') { + return Err(Error::InteriorNul); + } // A UTF-16 encoding never has more code units than the UTF-8 encoding has // bytes, so this capacity guarantees a single allocation. let mut wide: Vec = Vec::with_capacity(s.len() + 1); wide.extend(s.encode_utf16()); - if wide.contains(&0) { - return Err(Error::InteriorNul); - } wide.push(0); Ok(wide) } From 394b011da5c249e2028f342e8a19b221fef43722 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:41:19 +0000 Subject: [PATCH 04/42] refactor(error): user-level display messages, FFI detail into rustdoc The #[error] strings were pasted verbatim from the Microsoft docs and referenced FFI internals (lpLocalName, lpNetResource, NETRESOURCE members) that users of a safe wrapper never see. Every variant now displays a concise, user-level message following the Rust convention (lowercase start, no trailing period); the "this error is returned if..." explanations move into /// doc comments, phrased in the crate's own vocabulary. Variant names, from_status, and raw_os_error are unchanged. Co-Authored-By: Claude Fable 5 --- src/error.rs | 145 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 87 insertions(+), 58 deletions(-) diff --git a/src/error.rs b/src/error.rs index d70d8c5..cd1b98b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,76 +35,94 @@ pub enum Error { InvalidDriveLetter(char), // ── WNet connect/disconnect ──────────────────────────────────────────── - #[error("The caller does not have access to the network resource.")] + /// The caller does not have access to the network resource. + #[error("access to the network resource was denied")] AccessDenied, - #[error( - "The local device specified by the lpLocalName member is already connected to a network resource." - )] + /// The local device is already connected to a network resource. + #[error("the local device is already connected to a network resource")] AlreadyAssigned, - #[error("The type of local device and the type of network resource do not match.")] + /// The type of the local device and the type of the network resource do + /// not match (e.g. mounting a printer share on a drive letter). + #[error("the local device type and the network resource type do not match")] BadDevType, - #[error( - "The specified device name is not valid. This error is returned if the lpLocalName member of the NETRESOURCE structure pointed to by the lpNetResource parameter specifies a device that is not redirectable." - )] + /// Returned when the local device name names a device that cannot be + /// redirected to a network resource. + #[error("the specified device name is not valid")] BadDevice, - #[error( - "The network name cannot be found. This value is returned if the lpRemoteName member of the NETRESOURCE structure pointed to by the lpNetResource parameter specifies a resource that is not acceptable to any network resource provider, either because the resource name is empty, not valid, or because the named resource cannot be located." - )] + /// Returned when no network resource provider accepts the remote name: + /// it is empty, malformed, or names a resource that cannot be located. + #[error("the network name cannot be found")] BadNetName, - #[error("The user profile is in an incorrect format.")] + /// The user profile is in an incorrect format. + #[error("the user profile is in an incorrect format")] BadProfile, - #[error( - "The specified network provider name is not valid. This error is returned if the lpProvider member of the NETRESOURCE structure pointed to by the lpNetResource parameter specifies a value that does not match any network provider." - )] + /// Returned when the configured provider name does not match any network + /// provider installed on the system. + #[error("the specified network provider name is not valid")] BadProvider, - #[error("The specified user name is not valid.")] + /// The specified user name is not valid. + #[error("the specified user name is not valid")] BadUsername, - #[error("The router or provider is busy, possibly initializing. The caller should retry.")] + /// The router or provider is busy, possibly still initializing. The + /// caller should retry. + #[error("the router or provider is busy; retry the operation")] Busy, - #[error( - "The attempt to make the connection was canceled by the user through a dialog box from one of the network resource providers, or by a called resource." - )] + /// The connection attempt was canceled by the user through a provider + /// dialog box, or by a called resource. + #[error("the connection attempt was canceled")] Cancelled, - #[error("The system is unable to open the user profile to process persistent connections.")] + /// The system is unable to open the user profile to process persistent + /// connections. + #[error("the user profile cannot be opened to process persistent connections")] CannotOpenProfile, - #[error( - "The local device name has a remembered connection to another network resource. This error is returned if an entry for the device specified by lpLocalName member of the NETRESOURCE structure pointed to by the lpNetResource parameter specifies a value that is already in the user profile for a different connection than that specified in the lpNetResource parameter." - )] + /// Returned when the user profile already remembers a connection for this + /// local device name that points to a different network resource. + #[error("the local device name has a remembered connection to another network resource")] DeviceAlreadyRemembered, - #[error( - "An attempt was made to access an invalid address. This error is returned if the dwFlags parameter specifies a value of CONNECT_REDIRECT, but the lpLocalName member of the NETRESOURCE structure pointed to by the lpNetResource parameter was unspecified." - )] + /// Returned when connecting with redirection (`CONNECT_REDIRECT`, see + /// [`ConnectOptions::redirect`](crate::ConnectOptions::redirect)) without + /// a local device name to redirect to. + #[error("an attempt was made to access an invalid address")] InvalidAddress, - #[error( - "A parameter is incorrect. This error is returned if the dwType member of the NETRESOURCE structure pointed to by the lpNetResource parameter specifies a value other than RESOURCETYPE_DISK, RESOURCETYPE_PRINT, or RESOURCETYPE_ANY. This error is also returned if the dwFlags parameter specifies an incorrect or unknown value." - )] + /// A parameter is incorrect — for `WNet` connections, a resource type + /// other than disk, print, or any, or an incorrect or unknown flag value. + #[error("a parameter is incorrect")] InvalidParameter, - #[error("The specified password is invalid and the CONNECT_INTERACTIVE flag is not set.")] + /// The specified password is invalid (and, for connects, the interactive + /// flag is not set, so Windows could not prompt for a correct one). + #[error("the specified password is invalid")] InvalidPassword, - #[error("A logon failure because of an unknown user name or a bad password.")] + /// Logon failure because of an unknown user name or a bad password. + #[error("logon failed: unknown user name or bad password")] LogonFailure, - #[error( - "No network provider accepted the given network path. This error is returned if no network provider recognized the lpRemoteName member of the NETRESOURCE structure pointed to by the lpNetResource parameter." - )] + /// No network provider accepted the given network path — none of them + /// recognized the remote name. + #[error("no network provider accepted the given network path")] NoNetOrBadPath, - #[error("The network is unavailable.")] + /// The network is unavailable. + #[error("the network is unavailable")] NoNetwork, - #[error( - "Multiple connections to a server or shared resource by the same user, using more than one user name, are not allowed. Disconnect all previous connections to the server or shared resource and try again." - )] + /// Windows does not allow the same user to hold connections to one server + /// or share under more than one user name. Disconnect all previous + /// connections to the server or share and try again. + #[error("a connection to the server or share already exists under different credentials")] SessionCredentialConflict, - #[error("The device is in use by an active process and cannot be disconnected.")] + /// The device is in use by an active process and cannot be disconnected. + #[error("the device is in use by an active process and cannot be disconnected")] DeviceInUse, - #[error( - "The name specified by the lpName parameter is not a redirected device, or the system is not currently connected to the device specified by the parameter." - )] + /// The name is not a redirected device, or the system is not currently + /// connected to it. + #[error("the name is not a redirected device or a currently connected resource")] NotConnected, - #[error("There are open files, and the fForce parameter is FALSE.")] + /// There are open files on the connection and the disconnect was not + /// forced (see + /// [`DisconnectOptions::force`](crate::DisconnectOptions::force)). + #[error("there are open files and the disconnect was not forced")] OpenFiles, /// A network-specific error reported by the network provider, resolved via /// `WNetGetLastErrorW`. `code` is the provider's own error code. #[error( - "A network-specific error occurred (code {code}): {description} [provider: {provider}]" + "a network-specific error occurred (code {code}): {description} [provider: {provider}]" )] ExtendedError { code: u32, @@ -113,31 +131,42 @@ pub enum Error { }, // ── introspection ────────────────────────────────────────────────────── - #[error( - "The device is not currently connected, but it is a remembered (persistent) connection." - )] + /// The device is not currently connected, but it is a remembered + /// (persistent) connection. + #[error("the device is not connected, but it is a remembered connection")] ConnectionUnavailable, - #[error("The request is not supported for this resource.")] + /// The request is not supported for this resource. + #[error("the request is not supported for this resource")] NotSupported, - #[error("The specified device does not exist.")] + /// The specified device does not exist. + #[error("the specified device does not exist")] DeviceNotFound, // ── netapi32 share/session/file administration ───────────────────────── - #[error("The share name does not exist on this server.")] + /// The share name does not exist on the target server. + #[error("the share name does not exist on this server")] NetNameNotFound, - #[error("The share name is already in use on this server.")] + /// The share name is already in use on the target server. + #[error("the share name is already in use on this server")] DuplicateShare, - #[error("The device or directory does not exist.")] + /// The device or directory backing the share does not exist. + #[error("the device or directory does not exist")] UnknownDeviceOrDirectory, - #[error("A session does not exist with that computer name.")] + /// No session exists with the given computer name. + #[error("no session exists with that computer name")] ClientNameNotFound, - #[error("The user name could not be found.")] + /// The user name could not be found. + #[error("the user name could not be found")] UserNotFound, - #[error("There is not an open file with that identification number.")] + /// No open file with the given identification number exists. + #[error("no open file with that identification number exists")] FileIdNotFound, - #[error("The system call level is not correct.")] + /// The system call level is not correct — the requested information level + /// is not supported. + #[error("the requested information level is not supported")] InvalidLevel, - #[error("Not enough memory is available to complete the operation.")] + /// Not enough memory is available to complete the operation. + #[error("not enough memory is available to complete the operation")] NotEnoughMemory, /// Any status code without a dedicated variant. The message is resolved From 173365a3fa6f8c00cf22665974e03985f1843fd1 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:42:39 +0000 Subject: [PATCH 05/42] refactor(share): extract ConnectArgs from connect_raw / connect_auto_raw Both functions duplicated the same FFI setup verbatim: five wide-string conversions plus the NETRESOURCEW construction. ConnectArgs owns the converted buffers and produces the NETRESOURCEW and credential pointers borrowing from itself, making the lifetime discipline explicit: the struct must outlive the FFI call. No behavior change. Co-Authored-By: Claude Fable 5 --- src/share.rs | 114 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 41 deletions(-) diff --git a/src/share.rs b/src/share.rs index a064021..eda7ba9 100644 --- a/src/share.rs +++ b/src/share.rs @@ -1,10 +1,69 @@ use crate::error::{Error, Result, check_wnet}; use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; -use crate::strings::{from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide, to_wide_secret}; +use crate::strings::{ + WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide, to_wide_secret, +}; use crate::trace::{debug, trace}; use windows_sys::Win32::Foundation::ERROR_MORE_DATA; use windows_sys::Win32::NetworkManagement::WNet; +/// The wide-string buffers and resource type for one connect call, converted +/// from an [`SmbShare`]. +/// +/// Owning them in one struct pins down the borrow discipline: the struct must +/// outlive the FFI call, because every pointer produced by [`Self::resource`], +/// [`Self::password_ptr`], and [`Self::username_ptr`] borrows from the +/// buffers owned here. +struct ConnectArgs { + remote: Vec, + local: Option>, + provider: Option>, + username: Option>, + password: Option, + resource_type: ResourceType, +} + +impl ConnectArgs { + fn new(share: &SmbShare) -> Result { + Ok(Self { + remote: to_wide(&share.remote)?, + local: share.local.as_deref().map(to_wide).transpose()?, + provider: share.provider.as_deref().map(to_wide).transpose()?, + username: share.username.as_deref().map(to_wide).transpose()?, + password: share.password.as_deref().map(to_wide_secret).transpose()?, + resource_type: share.resource_type, + }) + } + + /// The `NETRESOURCEW` handed to `WNetAddConnection2W` / + /// `WNetUseConnectionW`. Its pointers borrow from `self`. + // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcew + fn resource(&self) -> WNet::NETRESOURCEW { + WNet::NETRESOURCEW { + dwScope: 0, // ignored by WNetAddConnection2W / WNetUseConnectionW + dwType: self.resource_type.to_dword(), + dwDisplayType: 0, // ignored, as dwScope + dwUsage: 0, // ignored, as dwScope + lpLocalName: opt_ptr(self.local.as_deref()), + lpRemoteName: self.remote.as_ptr().cast_mut(), + lpComment: std::ptr::null_mut(), // ignored, as dwScope + lpProvider: opt_ptr(self.provider.as_deref()), + } + } + + /// Password pointer for the call (null when no password is set); borrows + /// from `self`. + fn password_ptr(&self) -> *const u16 { + secret_ptr(self.password.as_ref()) + } + + /// User-name pointer for the call (null when no user name is set); + /// borrows from `self`. + fn username_ptr(&self) -> *mut u16 { + opt_ptr(self.username.as_deref()) + } +} + /// A remote SMB share, optionally redirected to a local device. /// /// Construct one with [`SmbShare::new`] (deviceless, current-user @@ -127,34 +186,19 @@ impl SmbShare { /// # Errors /// See [`Error`]. pub fn connect_raw(&self, flags: u32) -> Result<()> { - let remote = to_wide(&self.remote)?; - let local = self.local.as_deref().map(to_wide).transpose()?; - let provider = self.provider.as_deref().map(to_wide).transpose()?; - let username = self.username.as_deref().map(to_wide).transpose()?; - let password = self.password.as_deref().map(to_wide_secret).transpose()?; - - // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcew - let resource = WNet::NETRESOURCEW { - dwScope: 0, // ignored by WNetAddConnection2W - dwType: self.resource_type.to_dword(), - dwDisplayType: 0, // ignored by WNetAddConnection2W - dwUsage: 0, // ignored by WNetAddConnection2W - lpLocalName: opt_ptr(local.as_deref()), - lpRemoteName: remote.as_ptr().cast_mut(), - lpComment: std::ptr::null_mut(), // ignored by WNetAddConnection2W - lpProvider: opt_ptr(provider.as_deref()), - }; + let args = ConnectArgs::new(self)?; + let resource = args.resource(); trace!("connecting to {} with flags {flags:#x}", self.remote); // SAFETY: all pointers in `resource` and the credential pointers stay - // valid for the duration of the call — they borrow from the wide - // buffers bound above, which live until the end of this function. + // valid for the duration of the call — they borrow from the buffers + // owned by `args`, which lives until the end of this function. let status = unsafe { WNet::WNetAddConnection2W( &raw const resource, - secret_ptr(password.as_ref()), - opt_ptr(username.as_deref()), + args.password_ptr(), + args.username_ptr(), flags, ) }; @@ -187,22 +231,8 @@ impl SmbShare { /// # Errors /// See [`Error`]. pub fn connect_auto_raw(&self, flags: u32) -> Result { - let remote = to_wide(&self.remote)?; - let local = self.local.as_deref().map(to_wide).transpose()?; - let provider = self.provider.as_deref().map(to_wide).transpose()?; - let username = self.username.as_deref().map(to_wide).transpose()?; - let password = self.password.as_deref().map(to_wide_secret).transpose()?; - - let resource = WNet::NETRESOURCEW { - dwScope: 0, - dwType: self.resource_type.to_dword(), - dwDisplayType: 0, - dwUsage: 0, - lpLocalName: opt_ptr(local.as_deref()), - lpRemoteName: remote.as_ptr().cast_mut(), - lpComment: std::ptr::null_mut(), - lpProvider: opt_ptr(provider.as_deref()), - }; + let args = ConnectArgs::new(self)?; + let resource = args.resource(); trace!("auto-connecting to {} with flags {flags:#x}", self.remote); @@ -211,13 +241,15 @@ impl SmbShare { for _ in 0..2 { let mut size = len_u32(access_name.len()); let mut result = 0u32; - // SAFETY: as in `connect_raw`; `access_name` outlives the call. + // SAFETY: as in `connect_raw` — `args` (which every pointer in + // `resource` and the credential pointers borrow from) and + // `access_name` outlive the call. let status = unsafe { WNet::WNetUseConnectionW( std::ptr::null_mut(), // no owner window for credential dialogs &raw const resource, - secret_ptr(password.as_ref()), - opt_ptr(username.as_deref()), + args.password_ptr(), + args.username_ptr(), flags, access_name.as_mut_ptr(), &raw mut size, From 04b2d2db0cf927a30050ef6f10d34e16eed9bae5 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:43:16 +0000 Subject: [PATCH 06/42] fix(enumerate): bound both retry loops in Resources::fill Two unbounded-loop hazards against a misbehaving provider: a NO_ERROR result with zero entries made next() call fill() forever, and the ERROR_MORE_DATA resize path could grow-and-retry without limit. A zero-entry success now ends the enumeration, and the resize path is capped at 4 attempts (mirroring query::wide_out), returning Error::Other(ERROR_MORE_DATA) when exhausted. Co-Authored-By: Claude Fable 5 --- src/enumerate.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/enumerate.rs b/src/enumerate.rs index 68eb65e..64a0a54 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -105,7 +105,10 @@ impl Resources { fn fill(&mut self) -> Result<()> { // 16 KiB, u64 elements to keep NETRESOURCEW-aligned. let mut buf = vec![0u64; 2048]; - loop { + // Bounded retries as a defensive measure against a misbehaving + // provider that keeps demanding a bigger buffer (mirrors the cap in + // `query::wide_out`). + for _ in 0..4 { let mut count = u32::MAX; // as many entries as fit let mut size = len_u32(buf.len() * size_of::()); // SAFETY: `buf` outlives the call; `size` is its size in bytes. @@ -120,6 +123,15 @@ impl Resources { match status { NO_ERROR => { trace!("WNetEnumResourceW returned {count} entries"); + // A zero-entry success is out of contract (the API + // reports the end via ERROR_NO_MORE_ITEMS); treat it as + // the end of the enumeration rather than re-asking a + // misbehaving provider forever. + if count == 0 { + trace!("zero-entry success; treating as end of enumeration"); + self.finished = true; + return Ok(()); + } // SAFETY: on success the buffer starts with `count` // NETRESOURCEW entries; the strings they point to live in // `buf` and are copied out immediately by `from_raw`. @@ -145,6 +157,7 @@ impl Resources { code => return Err(Error::from_status(code)), } } + Err(Error::Other(ERROR_MORE_DATA)) } } From 2cc5ef0b4d7ae98f4e58761bf871767a836aad9e Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:43:57 +0000 Subject: [PATCH 07/42] feat(server): require a filter in delete_session, add delete_all_sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_session with both client and username as None silently ended every session on the server — a destructive outcome reachable by threading two Nones through. It now returns Error::InvalidParameter in that case; the end-everything operation is the separate, grep-able delete_all_sessions(server). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +++- src/server.rs | 30 ++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a54612c..42e4eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,9 @@ migration table in the README. - `server` module (netapi32): `shares`, `share_info`, `add_share`, `delete_share`, `sessions`, `delete_session`, `open_files`, `close_file`, `connections` — with automatic fallback to lower information levels when - not administrator. + not administrator. `delete_session` requires a client and/or user filter + (`InvalidParameter` otherwise); ending every session on a server is the + separate, explicit `delete_all_sessions`. - `Error::raw_os_error` and `From for std::io::Error`. - Cargo features: `tracing` (now optional!) and `zeroize` (wipe password buffers on drop). diff --git a/src/server.rs b/src/server.rs index cf8e8e4..b85275e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -542,17 +542,39 @@ pub fn sessions( /// /// `client` and `username` scope what is deleted: sessions from that /// computer (in UNC form, `r"\\workstation"` — see [`sessions`]), sessions -/// of that user, or their intersection. **Both `None` ends every session on -/// the server.** +/// of that user, or their intersection. At least one of the two is required; +/// ending every session on the server is deliberately a separate function, +/// [`delete_all_sessions`]. /// /// # Errors -/// [`Error::ClientNameNotFound`] / [`Error::UserNotFound`] when nothing -/// matches. +/// [`Error::InvalidParameter`] when both `client` and `username` are `None` +/// (use [`delete_all_sessions`] for that), [`Error::ClientNameNotFound`] / +/// [`Error::UserNotFound`] when nothing matches. pub fn delete_session( server: Option<&str>, client: Option<&str>, username: Option<&str>, ) -> Result<()> { + if client.is_none() && username.is_none() { + return Err(Error::InvalidParameter); + } + session_del(server, client, username) +} + +/// End **every** SMB session on a server via `NetSessionDel` with no client +/// or user filter. Requires administrative rights on the target server. +/// +/// **This disconnects all clients of the server at once**, without notifying +/// them — open files may lose data. For anything more targeted, use +/// [`delete_session`]. +/// +/// # Errors +/// [`Error::AccessDenied`] without administrative rights. +pub fn delete_all_sessions(server: Option<&str>) -> Result<()> { + session_del(server, None, None) +} + +fn session_del(server: Option<&str>, client: Option<&str>, username: Option<&str>) -> Result<()> { let server_w = server.map(to_wide).transpose()?; let client_w = client.map(to_wide).transpose()?; let user_w = username.map(to_wide).transpose()?; From 3eba4b850d74627d23d82909c123533f2f37e9aa Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:45:30 +0000 Subject: [PATCH 08/42] fix(server): normalize empty strings to None in all optional fields ShareInfo.remark already mapped empty to None but ShareInfo.path did not (IPC$ yielded Some("")), and the SessionInfo / OpenFile / ShareConnection optional strings were inconsistent too. netapi32 uses null and empty interchangeably for "no value", so a from_pwstr_nonempty helper now normalizes both to None across every optional string field of the server info structs. enumerate::NetResource stays untouched: it is an explicit raw snapshot of NETRESOURCEW. Co-Authored-By: Claude Fable 5 --- src/server.rs | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/server.rs b/src/server.rs index b85275e..2c7d3aa 100644 --- a/src/server.rs +++ b/src/server.rs @@ -86,6 +86,19 @@ fn net_enum( } } +/// Owned `String` from a nul-terminated wide pointer; `None` when the pointer +/// is null **or** the string is empty. netapi32 uses null and empty +/// interchangeably for "no value", so every optional string field of the +/// structs below normalizes both to `None`. +/// +/// # Safety +/// As [`from_pwstr`]: `ptr` must either be null or point to a valid +/// nul-terminated UTF-16 string. +unsafe fn from_pwstr_nonempty(ptr: *const u16) -> Option { + // SAFETY: guaranteed by caller. + unsafe { from_pwstr(ptr) }.filter(|s| !s.is_empty()) +} + /// The type of a share, unpacked from the raw `STYPE_*` value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -153,8 +166,10 @@ impl ShareKind { pub struct ShareInfo { pub name: String, pub share_type: ShareType, + /// Comment shown next to the share; `None` when absent or empty. pub remark: Option, - /// Local path backing the share, e.g. `C:\data` (level 2 only). + /// Local path backing the share, e.g. `C:\data` (level 2 only); `None` + /// when absent or empty (`IPC$` has no path). pub path: Option, /// Share-level permission bits, `ACCESS_*` (level 2 only). pub permissions: Option, @@ -173,8 +188,8 @@ impl ShareInfo { Self { name: from_pwstr(info.shi2_netname).unwrap_or_default(), share_type: ShareType::from_raw(info.shi2_type), - remark: from_pwstr(info.shi2_remark).filter(|r| !r.is_empty()), - path: from_pwstr(info.shi2_path), + remark: from_pwstr_nonempty(info.shi2_remark), + path: from_pwstr_nonempty(info.shi2_path), permissions: Some(info.shi2_permissions), max_uses: Some(info.shi2_max_uses), current_uses: Some(info.shi2_current_uses), @@ -190,7 +205,7 @@ impl ShareInfo { Self { name: from_pwstr(info.shi1_netname).unwrap_or_default(), share_type: ShareType::from_raw(info.shi1_type), - remark: from_pwstr(info.shi1_remark).filter(|r| !r.is_empty()), + remark: from_pwstr_nonempty(info.shi1_remark), path: None, permissions: None, max_uses: None, @@ -417,7 +432,8 @@ pub fn delete_share(server: Option<&str>, name: &str) -> Result<()> { pub struct SessionInfo { /// Name of the computer that established the session. pub client: String, - /// Name of the user who established the session. + /// Name of the user who established the session; `None` when absent or + /// empty. pub username: Option, /// Number of files, devices, and pipes opened during the session /// (level 1 only). @@ -438,7 +454,7 @@ impl SessionInfo { unsafe { Self { client: from_pwstr(info.sesi1_cname).unwrap_or_default(), - username: from_pwstr(info.sesi1_username), + username: from_pwstr_nonempty(info.sesi1_username), open_files: Some(info.sesi1_num_opens), active: Duration::from_secs(u64::from(info.sesi1_time)), idle: Duration::from_secs(u64::from(info.sesi1_idle_time)), @@ -454,7 +470,7 @@ impl SessionInfo { unsafe { Self { client: from_pwstr(info.sesi10_cname).unwrap_or_default(), - username: from_pwstr(info.sesi10_username), + username: from_pwstr_nonempty(info.sesi10_username), open_files: None, active: Duration::from_secs(u64::from(info.sesi10_time)), idle: Duration::from_secs(u64::from(info.sesi10_idle_time)), @@ -597,7 +613,8 @@ pub struct OpenFile { /// Server-assigned identifier; pass to [`close_file`]. pub id: u32, pub path: String, - /// The user (or computer, for null sessions) that opened it. + /// The user (or computer, for null sessions) that opened it; `None` when + /// absent or empty. pub username: Option, /// Number of locks held on the file. pub locks: u32, @@ -659,7 +676,7 @@ pub fn open_files( out.push(OpenFile { id: info.fi3_id, path: from_pwstr(info.fi3_pathname).unwrap_or_default(), - username: from_pwstr(info.fi3_username), + username: from_pwstr_nonempty(info.fi3_username), locks: info.fi3_num_locks, permissions: info.fi3_permissions, }); @@ -698,9 +715,10 @@ pub struct ShareConnection { pub users: u32, /// How long the connection has been established. pub active: Duration, + /// User on this connection; `None` when absent or empty. pub username: Option, /// Depending on the qualifier passed to [`connections`]: the share name - /// or the client computer name. + /// or the client computer name. `None` when absent or empty. pub name: Option, } @@ -741,8 +759,8 @@ pub fn connections(server: Option<&str>, qualifier: &str) -> Result Date: Thu, 9 Jul 2026 19:45:50 +0000 Subject: [PATCH 09/42] test(integration): fail loudly when the open file is not enumerated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server_open_files_are_listable_and_closable silently skipped both the assertion and the close_file exercise when NetFileEnum did not list the file — a green run that tested nothing. The find is now unwrapped with a panic message carrying the full listed vec, and close_file always runs. Loopback listing is reliable under SAMBRS_TEST_LOCAL=1, which gates this test to exactly that setup. Co-Authored-By: Claude Fable 5 --- tests/integration.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 511f9f0..0097a0f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -364,10 +364,11 @@ fn server_open_files_are_listable_and_closable() { let file = std::fs::File::create(path).unwrap(); let open = server::open_files(None, None, None).unwrap(); - let ours = open.iter().find(|f| f.path.contains("sambrs-open-file")); - if let Some(ours) = ours { - server::close_file(None, ours.id).unwrap(); - } + let ours = open + .iter() + .find(|f| f.path.contains("sambrs-open-file")) + .unwrap_or_else(|| panic!("our open file not listed by NetFileEnum; listed: {open:?}")); + server::close_file(None, ours.id).unwrap(); drop(file); let _ = std::fs::remove_file(path); From adbea0e5c15dfc69ec47e8546ce4a37612f8a02e Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:46:47 +0000 Subject: [PATCH 10/42] chore(package): allowlist the crates.io package contents cargo package shipped the agent tooling (.agents/, .claude/, skills-lock.json, .github/) in the tarball. An explicit include allowlist now ships only /src, /tests, /Cargo.toml, /README.md, /CHANGELOG.md, and /LICENSE (plus cargo's generated Cargo.toml.orig, .cargo_vcs_info.json, and Cargo.lock). Because .cargo/config.toml does not ship, the test docs now tell packaged-copy users to set RUST_TEST_THREADS=1 themselves. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 9 +++++++++ README.md | 4 ++++ tests/integration.rs | 8 ++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 42d6821..52606ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,15 @@ keywords = ["windows", "smb", "share", "network"] description = """ Safe and flexible bindings for Windows SMB share operations: connect and disconnect network shares (WNet), query and enumerate connections and remote shares, and administer server-side shares, sessions, and open files (netapi32). """ +# Allowlist: ship only the crate itself, not repo/CI/agent tooling. +include = [ + "/src", + "/tests", + "/Cargo.toml", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", +] [features] default = [] diff --git a/README.md b/README.md index 3a703ea..d22599b 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ SAMBRS_TEST_LOCAL=1 # only if the share is local; enables the server:: tests cargo test -- --include-ignored ``` +The tests mount real drive letters and must run single-threaded: a git +checkout sets `RUST_TEST_THREADS=1` via `.cargo/config.toml`; set it yourself +when running from a packaged copy of the crate. + CI provisions a local user plus `\\localhost\sambrs-test` on a Windows runner and runs the whole suite against it on every push. diff --git a/tests/integration.rs b/tests/integration.rs index 0097a0f..6e7ad25 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -9,14 +9,18 @@ //! SAMBRS_TEST_PASSWORD=... //! SAMBRS_TEST_LOCAL=1 # only if the share is on THIS machine and the test //! # process can administer it (enables server:: tests) +//! RUST_TEST_THREADS=1 # the tests mount real drive letters and enumerate +//! # live connections; they must not run concurrently //! //! cargo test -- --include-ignored //! ``` //! //! CI provisions `\\localhost\sambrs-test` with a dedicated local user and //! runs the full suite; see `.github/workflows/ci.yml`. Drive letters S-Z are -//! used by these tests and must be free. Tests run single-threaded (see -//! `.cargo/config.toml`). +//! used by these tests and must be free. In a git checkout, +//! `.cargo/config.toml` sets `RUST_TEST_THREADS=1` for you; that file is not +//! part of the packaged crate, so set it yourself when running from a +//! published copy. #![cfg(windows)] use sambrs::{ From 3ab2e902c9ea8387318124cc7be3d6823435daea Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:56:08 +0000 Subject: [PATCH 11/42] fix(server): treat empty strings as no filter in delete_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-sessions guard only checked for None, so an empty client or user filter (e.g. an unset config value) reached NetSessionDel as a non-null pointer to an empty string — which netapi32 treats like a null, i.e. no filter, exactly the unfiltered delete-all the guard exists to prevent. Normalize empty filters to None before the guard so they are rejected with InvalidParameter, and document that empty strings do not count as a filter. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++++--- src/server.rs | 32 +++++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e4eee..11dc06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,10 @@ migration table in the README. - `server` module (netapi32): `shares`, `share_info`, `add_share`, `delete_share`, `sessions`, `delete_session`, `open_files`, `close_file`, `connections` — with automatic fallback to lower information levels when - not administrator. `delete_session` requires a client and/or user filter - (`InvalidParameter` otherwise); ending every session on a server is the - separate, explicit `delete_all_sessions`. + not administrator. `delete_session` requires a non-empty client and/or + user filter (`InvalidParameter` otherwise — netapi32 treats an empty + string as no filter); ending every session on a server is the separate, + explicit `delete_all_sessions`. - `Error::raw_os_error` and `From for std::io::Error`. - Cargo features: `tracing` (now optional!) and `zeroize` (wipe password buffers on drop). diff --git a/src/server.rs b/src/server.rs index 2c7d3aa..e3fb029 100644 --- a/src/server.rs +++ b/src/server.rs @@ -558,19 +558,25 @@ pub fn sessions( /// /// `client` and `username` scope what is deleted: sessions from that /// computer (in UNC form, `r"\\workstation"` — see [`sessions`]), sessions -/// of that user, or their intersection. At least one of the two is required; -/// ending every session on the server is deliberately a separate function, -/// [`delete_all_sessions`]. +/// of that user, or their intersection. At least one of the two is required, +/// and an empty string does not count — netapi32 treats it like a missing +/// filter. Ending every session on the server is deliberately a separate +/// function, [`delete_all_sessions`]. /// /// # Errors /// [`Error::InvalidParameter`] when both `client` and `username` are `None` -/// (use [`delete_all_sessions`] for that), [`Error::ClientNameNotFound`] / -/// [`Error::UserNotFound`] when nothing matches. +/// or empty (use [`delete_all_sessions`] to end every session), +/// [`Error::ClientNameNotFound`] / [`Error::UserNotFound`] when nothing +/// matches. pub fn delete_session( server: Option<&str>, client: Option<&str>, username: Option<&str>, ) -> Result<()> { + // netapi32 treats an empty filter string like a null one, so empty + // strings must not slip past the all-sessions guard below. + let client = client.filter(|c| !c.is_empty()); + let username = username.filter(|u| !u.is_empty()); if client.is_none() && username.is_none() { return Err(Error::InvalidParameter); } @@ -785,6 +791,22 @@ mod tests { assert!(t.temporary); } + #[test] + fn delete_session_requires_a_nonempty_filter() { + // Rejected before the FFI call, so this cannot touch real sessions. + for (client, username) in [ + (None, None), + (Some(""), None), + (None, Some("")), + (Some(""), Some("")), + ] { + assert_eq!( + delete_session(None, client, username).unwrap_err(), + Error::InvalidParameter + ); + } + } + #[test] fn share_kind_roundtrips() { for kind in [ From 6f63ea0b88a3c49d30ea569009e7256460c42721 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Thu, 9 Jul 2026 19:56:44 +0000 Subject: [PATCH 12/42] docs(error): document the synthesized InvalidParameter code Since delete_session started rejecting a filterless call locally, Error::InvalidParameter can carry ERROR_INVALID_PARAMETER without any Windows call having failed. raw_os_error's rustdoc claimed the code only exists for errors originating from the OS and the variant doc listed only WNet causes; state the synthesized case in both. Co-Authored-By: Claude Fable 5 --- src/error.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/error.rs b/src/error.rs index cd1b98b..e2f86d6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -86,6 +86,9 @@ pub enum Error { InvalidAddress, /// A parameter is incorrect — for `WNet` connections, a resource type /// other than disk, print, or any, or an incorrect or unknown flag value. + /// Also synthesized without a Windows call by + /// [`server::delete_session`](crate::server::delete_session) when neither + /// a client nor a user filter is given. #[error("a parameter is incorrect")] InvalidParameter, /// The specified password is invalid (and, for connects, the interactive @@ -225,11 +228,14 @@ impl Error { } } - /// The underlying Windows error code, when this error originated from a - /// Windows API call. + /// The Windows error code this error corresponds to. /// - /// Returns `None` for input-validation errors that never reached the OS - /// ([`Error::InteriorNul`], [`Error::InvalidDriveLetter`]). For + /// Returns `None` for input-validation errors that have no Windows code + /// ([`Error::InteriorNul`], [`Error::InvalidDriveLetter`]). The code + /// usually comes from a failed Windows API call, but sambrs synthesizes + /// [`Error::InvalidParameter`] for some rejected inputs (see + /// [`server::delete_session`](crate::server::delete_session)); it still + /// reports the matching `ERROR_INVALID_PARAMETER`. For /// [`Error::ExtendedError`] this is `ERROR_EXTENDED_ERROR` (1208); the /// provider-specific code is in the variant itself. #[must_use] From 0df828e29ee629f7a6390541a961135cb606fcf0 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 00:34:39 +0000 Subject: [PATCH 13/42] fix gpt 5.6 findings --- .github/workflows/ci.yml | 13 ++++++++ CHANGELOG.md | 9 ++++-- README.md | 3 +- src/error.rs | 5 +++ src/server.rs | 67 +++++++++++++++++++++++++++++++++++++++- src/share.rs | 31 +++++++++++++++++-- tests/integration.rs | 17 ++++++++++ 7 files changed, 139 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0dc3ab..93b875f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,19 @@ jobs: - name: Unit tests (no default features) run: cargo test + msrv: + name: MSRV check (Rust 1.85, Windows target) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.85' + targets: x86_64-pc-windows-msvc + + - name: crate (incl. tests) compiles on the declared MSRV + run: cargo check --target x86_64-pc-windows-msvc --all-targets --all-features + lint: name: Lint, docs, cross-target checks (Linux) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 11dc06d..4a56051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,9 @@ migration table in the README. - `SmbShare::connect_auto`: let Windows pick a free drive letter (`WNetUseConnectionW`), returning the assigned name. - `SmbShare::connect_guarded`: RAII `Connection` guard that disconnects on - drop, with `leak()` and explicit `disconnect()`. + drop, with `leak()` and explicit `disconnect()`. Windows does not + reference-count connections, so guards to the same deviceless resource + share one underlying connection — see the `Connection` docs. - `cancel_connection`: disconnect any connection by device or remote name. - `query` module: `get_connection`, `get_user`, `get_universal_name`. - `enumerate` module: iterate active connections, remembered connections, and @@ -50,7 +52,10 @@ migration table in the README. - Cargo features: `tracing` (now optional!) and `zeroize` (wipe password buffers on drop). - CI: full integration suite against a real `\\localhost` share on Windows - runners; clippy/rustfmt/rustdoc gates. + runners; clippy/rustfmt/rustdoc gates; MSRV (1.85) build check. +- The `server` enumeration loop fails with `Error::Other(ERROR_MORE_DATA)` + instead of spinning forever when a malformed server keeps reporting + `ERROR_MORE_DATA` without delivering entries or terminating. ### Changed diff --git a/README.md b/README.md index d22599b..31fa05b 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ checkout sets `RUST_TEST_THREADS=1` via `.cargo/config.toml`; set it yourself when running from a packaged copy of the crate. CI provisions a local user plus `\\localhost\sambrs-test` on a Windows runner -and runs the whole suite against it on every push. +and runs the whole suite against it on every push to `main` and on every pull +request, plus a Rust 1.85 build to keep the declared MSRV honest. ## Migrating from 0.1 diff --git a/src/error.rs b/src/error.rs index e2f86d6..8aad187 100644 --- a/src/error.rs +++ b/src/error.rs @@ -174,6 +174,11 @@ pub enum Error { /// Any status code without a dedicated variant. The message is resolved /// through the system message table where possible. + /// + /// sambrs also synthesizes `Other(ERROR_MORE_DATA)` (234) when Windows + /// keeps reporting that more data is available without making progress — + /// a buffer-size retry that never fits, or an enumeration batch that + /// delivers no entries — because retrying would loop forever. #[error("Windows error {code}: {msg}", code = .0, msg = os_message(*.0))] Other(u32), } diff --git a/src/server.rs b/src/server.rs index e3fb029..286560e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -54,11 +54,22 @@ impl Drop for NetBuffer { /// protocol and buffer ownership. `call` receives the out-buffer pointer and /// the entries-read / total-entries out-params; each returned entry is passed /// to `each` while the buffer is still alive. +/// +/// The protocol contract says every `ERROR_MORE_DATA` batch delivers entries +/// and advances the resume handle; a malformed or malicious server can +/// violate that, so the loop fails with `Error::Other(ERROR_MORE_DATA)` +/// instead of spinning forever: immediately when a batch delivers nothing +/// (the next call would repeat the identical request), and after +/// `MAX_BATCHES` batches as a backstop against a resume handle that yields +/// entries but never terminates. fn net_enum( mut call: impl FnMut(*mut *mut u8, *mut u32, *mut u32) -> u32, mut each: impl FnMut(&T), ) -> Result<()> { - loop { + // Batches are MAX_PREFERRED_LENGTH-sized (remotely still tens of KB), so + // thousands of batches are far beyond any real enumeration. + const MAX_BATCHES: u32 = 4096; + for _ in 0..MAX_BATCHES { let mut buf: *mut u8 = std::ptr::null_mut(); let mut read = 0u32; let mut total = 0u32; @@ -78,12 +89,18 @@ fn net_enum( if status == NERR_SUCCESS { return Ok(()); } + if buf.is_null() || read == 0 { + // ERROR_MORE_DATA with an empty batch: no progress was + // made, so looping would repeat the identical call. + return Err(Error::Other(ERROR_MORE_DATA)); + } // ERROR_MORE_DATA: loop again, the resume handle captured by // `call` continues where this batch ended. } code => return Err(Error::from_status(code)), } } + Err(Error::Other(ERROR_MORE_DATA)) } /// Owned `String` from a nul-terminated wide pointer; `None` when the pointer @@ -807,6 +824,54 @@ mod tests { } } + #[test] + fn net_enum_fails_on_an_empty_more_data_batch() { + // ERROR_MORE_DATA with no entries delivered means the next call + // would repeat the identical request; net_enum must error out + // instead of looping forever. + let mut calls = 0; + let result = net_enum::( + |_, _, _| { + calls += 1; + ERROR_MORE_DATA + }, + |_| panic!("no entries were delivered"), + ); + assert_eq!(result, Err(Error::Other(ERROR_MORE_DATA))); + assert_eq!(calls, 1); + } + + #[test] + // netapi32 allocates with alignment suitable for any of its info + // structures, so the u8 -> u32 cast below is sound. + #[allow(clippy::cast_ptr_alignment)] + fn net_enum_gives_up_on_a_never_ending_enumeration() { + use windows_sys::Win32::NetworkManagement::NetManagement::NetApiBufferAllocate; + + // A resume handle that keeps yielding entries without ever reaching + // NERR_SUCCESS must hit the batch backstop, not run unbounded. + let mut entries = 0u32; + let result = net_enum::( + |buf, read, _| { + // SAFETY: `buf` receives a real netapi32 allocation (freed + // by net_enum's NetBuffer guard) holding the one u32 entry + // that `read` reports. + unsafe { + NetApiBufferAllocate(4, buf.cast()); + (*buf).cast::().write(7); + read.write(1); + } + ERROR_MORE_DATA + }, + |&n| { + assert_eq!(n, 7); + entries += 1; + }, + ); + assert_eq!(result, Err(Error::Other(ERROR_MORE_DATA))); + assert!(entries > 0, "delivered entries must still be consumed"); + } + #[test] fn share_kind_roundtrips() { for kind in [ diff --git a/src/share.rs b/src/share.rs index eda7ba9..e43f957 100644 --- a/src/share.rs +++ b/src/share.rs @@ -162,7 +162,10 @@ impl SmbShare { /// Connect with default options: a temporary, non-interactive connection. /// /// Connecting multiple times works fine in deviceless mode but fails with - /// [`Error::AlreadyAssigned`] when a local mount point is set. + /// [`Error::AlreadyAssigned`] when a local mount point is set. Windows + /// does not reference-count connections, though: repeated deviceless + /// connects share one underlying connection, and a single + /// [`disconnect`](Self::disconnect) cancels them all. /// /// # Errors /// See [`Error`] — every documented `WNetAddConnection2W` failure has a @@ -270,6 +273,11 @@ impl SmbShare { /// Connect and return an RAII [`Connection`] guard that disconnects when /// dropped. /// + /// Guards to the same resource are **not** independent: Windows does not + /// reference-count connections, so dropping one guard disconnects the + /// share for every other guard (and any other code in this logon session) + /// using it. See [`Connection`] for the details. + /// /// # Errors /// See [`Error`]. pub fn connect_guarded(&self, options: ConnectOptions) -> Result> { @@ -284,7 +292,10 @@ impl SmbShare { /// Disconnect with default options: non-forced, keeping any persistence. /// /// Disconnects by local device name when this share has one, otherwise by - /// remote name. + /// remote name. Disconnecting by remote name cancels **all** deviceless + /// connections to that resource in this logon session — Windows does not + /// reference-count them, so this undoes every [`connect`](Self::connect) + /// to the resource at once, not just one. /// /// # Errors /// See [`Error`] — every documented `WNetCancelConnection2W` failure has @@ -310,6 +321,10 @@ impl SmbShare { /// disconnect names returned by /// [`SmbShare::connect_auto`] or found via [`crate::enumerate`]. /// +/// A local device name cancels only that redirection; a remote name cancels +/// **all** deviceless connections to that resource in this logon session +/// (Windows does not reference-count them). +/// /// # Errors /// See [`Error`]. pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { @@ -424,6 +439,18 @@ impl SmbShareBuilder { /// /// Use [`Connection::disconnect`] for explicit error handling, or /// [`Connection::leak`] to keep the connection open past the guard. +/// +/// # Guards share one underlying connection +/// +/// The guard owns no Windows handle; dropping it simply cancels the +/// connection by name, and Windows does not reference-count `WNet` +/// connections. For a deviceless share, that cancel tears down **all** +/// deviceless connections to the remote resource in this logon session: two +/// guards for the same resource share one underlying connection, and +/// dropping either disconnects the other (whose own drop then finds nothing +/// to cancel). Hold at most one guard per resource, or [`leak`] the extras. +/// +/// [`leak`]: Connection::leak #[derive(Debug)] #[must_use = "dropping the guard disconnects the share immediately"] pub struct Connection<'a> { diff --git a/tests/integration.rs b/tests/integration.rs index 6e7ad25..3495035 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -190,6 +190,23 @@ fn guard_leak_keeps_the_connection() { share.disconnect().unwrap(); } +// Characterizes the documented Windows behavior that deviceless guards are +// NOT independent: WNet connections are not reference-counted, and canceling +// by remote name cancels all deviceless connections to the resource. +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn deviceless_guards_share_one_underlying_connection() { + let share = share(None); + let first = share.connect_guarded(ConnectOptions::new()).unwrap(); + let second = share.connect_guarded(ConnectOptions::new()).unwrap(); + // Dropping the first guard tears down the second guard's connection too. + drop(first); + assert_eq!( + second.disconnect(DisconnectOptions::new()), + Err(Error::NotConnected) + ); +} + // ── auto-assigned drive letter ────────────────────────────────────────────── #[test] From b7c44915f1f0740497d7ca9fdf71bf0fb9eb83b7 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:19:09 +0000 Subject: [PATCH 14/42] fix(share): make the Connection guard own a unique local device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guard that cancels by remote name can tear down deviceless connections it never made: WNetCancelConnection2W by remote name cancels every deviceless connection to the resource in the logon session, and Windows does not reference-count them. Documenting that footgun was not enough — an RAII drop handler must not be able to destroy state it does not own. The guard now always owns a redirected local device and cancels exactly that device on drop: connect_guarded requires a local device (rejecting deviceless shares with a synthesized InvalidParameter before any Windows call), and the new connect_auto_guarded lets Windows pick a free device and carries the assigned name (Connection::device). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++-- src/error.rs | 9 ++-- src/share.rs | 107 ++++++++++++++++++++++++++++++++----------- tests/integration.rs | 45 ++++++++++++------ 4 files changed, 125 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a56051..609ac08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,10 +33,13 @@ migration table in the README. hatches. - `SmbShare::connect_auto`: let Windows pick a free drive letter (`WNetUseConnectionW`), returning the assigned name. -- `SmbShare::connect_guarded`: RAII `Connection` guard that disconnects on - drop, with `leak()` and explicit `disconnect()`. Windows does not - reference-count connections, so guards to the same deviceless resource - share one underlying connection — see the `Connection` docs. +- `SmbShare::connect_guarded` / `connect_auto_guarded`: RAII `Connection` + guard that disconnects on drop, with `leak()` and explicit `disconnect()`. + A guard always owns a redirected local device and cancels exactly that + device, so dropping it can never tear down a connection it did not create. + Deviceless shares are rejected with `InvalidParameter` (Windows does not + reference-count deviceless connections, so no guard can own one); see the + `Connection` docs. - `cancel_connection`: disconnect any connection by device or remote name. - `query` module: `get_connection`, `get_user`, `get_universal_name`. - `enumerate` module: iterate active connections, remembered connections, and diff --git a/src/error.rs b/src/error.rs index 8aad187..57cbf41 100644 --- a/src/error.rs +++ b/src/error.rs @@ -88,7 +88,9 @@ pub enum Error { /// other than disk, print, or any, or an incorrect or unknown flag value. /// Also synthesized without a Windows call by /// [`server::delete_session`](crate::server::delete_session) when neither - /// a client nor a user filter is given. + /// a client nor a user filter is given, and by + /// [`SmbShare::connect_guarded`](crate::SmbShare::connect_guarded) for a + /// share without a local device. #[error("a parameter is incorrect")] InvalidParameter, /// The specified password is invalid (and, for connects, the interactive @@ -239,8 +241,9 @@ impl Error { /// ([`Error::InteriorNul`], [`Error::InvalidDriveLetter`]). The code /// usually comes from a failed Windows API call, but sambrs synthesizes /// [`Error::InvalidParameter`] for some rejected inputs (see - /// [`server::delete_session`](crate::server::delete_session)); it still - /// reports the matching `ERROR_INVALID_PARAMETER`. For + /// [`server::delete_session`](crate::server::delete_session) and + /// [`SmbShare::connect_guarded`](crate::SmbShare::connect_guarded)); it + /// still reports the matching `ERROR_INVALID_PARAMETER`. For /// [`Error::ExtendedError`] this is `ERROR_EXTENDED_ERROR` (1208); the /// provider-specific code is in the variant itself. #[must_use] diff --git a/src/share.rs b/src/share.rs index e43f957..32f92a8 100644 --- a/src/share.rs +++ b/src/share.rs @@ -216,7 +216,9 @@ impl SmbShare { /// Returns the name through which the share is accessible — the assigned /// device (e.g. `"Z:"`), or the local device configured on this share if /// one was set. Pass the returned name to - /// [`cancel_connection`] to disconnect. + /// [`cancel_connection`] to disconnect, or use + /// [`connect_auto_guarded`](Self::connect_auto_guarded) to have that + /// happen automatically. /// /// The share's resource type must be [`ResourceType::Disk`] or /// [`ResourceType::Print`]: Windows rejects `RESOURCETYPE_ANY` with @@ -273,17 +275,48 @@ impl SmbShare { /// Connect and return an RAII [`Connection`] guard that disconnects when /// dropped. /// - /// Guards to the same resource are **not** independent: Windows does not - /// reference-count connections, so dropping one guard disconnects the - /// share for every other guard (and any other code in this logon session) - /// using it. See [`Connection`] for the details. + /// Requires a local device (set via + /// [`mount_on`](SmbShareBuilder::mount_on) / + /// [`local_device`](SmbShareBuilder::local_device)): the device is the + /// one thing a guard can exclusively own — connecting fails with + /// [`Error::AlreadyAssigned`] if it is taken, and canceling it by name on + /// drop touches no other connection. A deviceless connection offers no + /// such handle: Windows does not reference-count connections, and + /// canceling by remote name tears down **every** deviceless connection + /// to the resource in this logon session, including ones the guard never + /// made — so deviceless shares are rejected here. Use + /// [`connect_auto_guarded`](Self::connect_auto_guarded) to have Windows + /// pick the device instead. /// /// # Errors - /// See [`Error`]. + /// [`Error::InvalidParameter`] (synthesized without a Windows call) when + /// this share has no local device; otherwise see [`Error`]. pub fn connect_guarded(&self, options: ConnectOptions) -> Result> { + let Some(device) = self.local.as_deref() else { + return Err(Error::InvalidParameter); + }; + let device = device.to_string(); self.connect_with(options)?; Ok(Connection { share: self, + device, + on_drop: DisconnectOptions::new(), + armed: true, + }) + } + + /// [`connect_auto`](Self::connect_auto) with an RAII [`Connection`] + /// guard: Windows picks a free local device, and the guard cancels + /// exactly that device when dropped. [`Connection::device`] tells you + /// where the share is mounted. + /// + /// # Errors + /// See [`connect_auto`](Self::connect_auto). + pub fn connect_auto_guarded(&self, options: ConnectOptions) -> Result> { + let device = self.connect_auto(options)?; + Ok(Connection { + share: self, + device, on_drop: DisconnectOptions::new(), armed: true, }) @@ -433,28 +466,27 @@ impl SmbShareBuilder { } } -/// RAII guard returned by [`SmbShare::connect_guarded`]: disconnects the -/// share when dropped (best effort — a failure on drop is only visible as a -/// `tracing` event, with the `tracing` feature enabled). +/// RAII guard returned by [`SmbShare::connect_guarded`] and +/// [`SmbShare::connect_auto_guarded`]: cancels the connection when dropped +/// (best effort — a failure on drop is only visible as a `tracing` event, +/// with the `tracing` feature enabled). +/// +/// A guard always owns a local device redirection ([`Connection::device`]) +/// and cancels exactly that device, never the remote name. The device was +/// free when the guard connected it, so a live guard is its sole owner and +/// dropping it cannot tear down a connection made elsewhere. (This is why +/// deviceless connections cannot be guarded — canceling one means canceling +/// by remote name, which takes every deviceless connection to the resource +/// down with it.) /// /// Use [`Connection::disconnect`] for explicit error handling, or /// [`Connection::leak`] to keep the connection open past the guard. -/// -/// # Guards share one underlying connection -/// -/// The guard owns no Windows handle; dropping it simply cancels the -/// connection by name, and Windows does not reference-count `WNet` -/// connections. For a deviceless share, that cancel tears down **all** -/// deviceless connections to the remote resource in this logon session: two -/// guards for the same resource share one underlying connection, and -/// dropping either disconnects the other (whose own drop then finds nothing -/// to cancel). Hold at most one guard per resource, or [`leak`] the extras. -/// -/// [`leak`]: Connection::leak #[derive(Debug)] #[must_use = "dropping the guard disconnects the share immediately"] pub struct Connection<'a> { share: &'a SmbShare, + /// The redirected local device this guard exclusively owns. + device: String, on_drop: DisconnectOptions, armed: bool, } @@ -473,6 +505,13 @@ impl<'a> Connection<'a> { self.share } + /// The local device this guard owns (e.g. `"Z:"`) — the share is + /// accessible through it for as long as the guard lives. + #[must_use] + pub fn device(&self) -> &str { + &self.device + } + /// Consume the guard without disconnecting, keeping the connection open. pub fn leak(mut self) { self.armed = false; @@ -484,19 +523,33 @@ impl<'a> Connection<'a> { /// See [`Error`]. pub fn disconnect(mut self, options: DisconnectOptions) -> Result<()> { self.armed = false; - self.share.disconnect_with(options) + cancel_connection(&self.device, options) } } impl Drop for Connection<'_> { fn drop(&mut self) { if self.armed { - if let Err(e) = self.share.disconnect_with(self.on_drop) { - debug!( - "failed to disconnect {} on guard drop: {e}", - self.share.remote() - ); + if let Err(e) = cancel_connection(&self.device, self.on_drop) { + debug!("failed to disconnect {} on guard drop: {e}", self.device); } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deviceless_connect_guarded_is_rejected() { + // Rejected before any Windows call: without a local device there is + // nothing a guard can exclusively own, and canceling by remote name + // would tear down deviceless connections the guard never made. + let share = SmbShare::new(r"\\server\share"); + assert_eq!( + share.connect_guarded(ConnectOptions::new()).unwrap_err(), + Error::InvalidParameter + ); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 3495035..7d99561 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -190,21 +190,23 @@ fn guard_leak_keeps_the_connection() { share.disconnect().unwrap(); } -// Characterizes the documented Windows behavior that deviceless guards are -// NOT independent: WNet connections are not reference-counted, and canceling -// by remote name cancels all deviceless connections to the resource. +// The ownership property behind the guard design: a guard cancels only the +// device it owns, so dropping it must not tear down an independent deviceless +// connection to the same resource. #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn deviceless_guards_share_one_underlying_connection() { - let share = share(None); - let first = share.connect_guarded(ConnectOptions::new()).unwrap(); - let second = share.connect_guarded(ConnectOptions::new()).unwrap(); - // Dropping the first guard tears down the second guard's connection too. - drop(first); - assert_eq!( - second.disconnect(DisconnectOptions::new()), - Err(Error::NotConnected) - ); +fn guard_drop_leaves_other_connections_alone() { + let deviceless = share(None); + deviceless.connect().unwrap(); + let mounted = share(Some(DriveLetter::V)); + { + let _guard = mounted.connect_guarded(ConnectOptions::new()).unwrap(); + assert!(drive_exists(DriveLetter::V)); + } + assert!(!drive_exists(DriveLetter::V)); + // The deviceless connection must still be alive; disconnecting it now + // fails with NotConnected if the guard's drop tore it down. + deviceless.disconnect().unwrap(); } // ── auto-assigned drive letter ────────────────────────────────────────────── @@ -222,6 +224,23 @@ fn connect_auto_assigns_a_device() { cancel_connection(&access_name, DisconnectOptions::new()).unwrap(); } +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn connect_auto_guarded_owns_the_assigned_device() { + let share = share(None); + let device; + { + let guard = share.connect_auto_guarded(ConnectOptions::new()).unwrap(); + device = guard.device().to_string(); + assert!( + device.ends_with(':'), + "expected a device name, got {device:?}" + ); + assert!(std::path::Path::new(&format!(r"{device}\")).is_dir()); + } + assert!(!std::path::Path::new(&format!(r"{device}\")).is_dir()); +} + // ── query ─────────────────────────────────────────────────────────────────── #[test] From 075bd31b6ff50f3e0e9fe8f151ee64f18d9c700b Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:20:29 +0000 Subject: [PATCH 15/42] fix(error): keep provider details when converting ExtendedError to io::Error Routing ExtendedError through from_raw_os_error reduced it to the generic ERROR_EXTENDED_ERROR (1208) message, discarding the provider code, description, and name that WNetGetLastErrorW was called to capture. Keep the Error itself as the io::Error payload instead. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 ++++- src/error.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 609ac08..a050b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,10 @@ migration table in the README. user filter (`InvalidParameter` otherwise — netapi32 treats an empty string as no filter); ending every session on a server is the separate, explicit `delete_all_sessions`. -- `Error::raw_os_error` and `From for std::io::Error`. +- `Error::raw_os_error` and `From for std::io::Error`. Converting an + `ExtendedError` keeps the error as the `io::Error` payload, so the + provider's own code, description, and name survive instead of collapsing + into the generic `ERROR_EXTENDED_ERROR` (1208) message. - Cargo features: `tracing` (now optional!) and `zeroize` (wipe password buffers on drop). - CI: full integration suite against a real `\\localhost` share on Windows diff --git a/src/error.rs b/src/error.rs index 57cbf41..db671e4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -289,8 +289,17 @@ impl Error { } } +/// Errors with a Windows code convert via `from_raw_os_error`, keeping the +/// system message and `io::Error::raw_os_error`. The exceptions keep the +/// [`enum@Error`] itself as the payload instead: [`Error::ExtendedError`], whose +/// code would only be the generic `ERROR_EXTENDED_ERROR` (1208) — the +/// provider's own code, description, and name live in the variant — and the +/// input-validation errors, which have no Windows code at all. impl From for std::io::Error { fn from(e: Error) -> Self { + if matches!(e, Error::ExtendedError { .. }) { + return Self::other(e); + } match e.raw_os_error() { #[allow(clippy::cast_possible_wrap)] Some(code) => Self::from_raw_os_error(code as i32), @@ -348,3 +357,43 @@ pub(crate) fn wnet_extended_error() -> Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn io_error_conversion_keeps_extended_error_details() { + let e = Error::ExtendedError { + code: 59, + description: String::from("the provider says no"), + provider: String::from("Test Provider"), + }; + let io: std::io::Error = e.into(); + // Not from_raw_os_error(1208) — the generic message would drop the + // provider details this variant exists to carry. + assert_eq!(io.raw_os_error(), None); + let msg = io.to_string(); + assert!(msg.contains("59"), "provider code lost: {msg}"); + assert!( + msg.contains("the provider says no"), + "description lost: {msg}" + ); + assert!(msg.contains("Test Provider"), "provider name lost: {msg}"); + } + + #[test] + fn io_error_conversion_maps_windows_codes() { + let io: std::io::Error = Error::AccessDenied.into(); + #[allow(clippy::cast_possible_wrap)] + { + assert_eq!(io.raw_os_error(), Some(ERROR_ACCESS_DENIED as i32)); + } + } + + #[test] + fn io_error_conversion_flags_input_validation_errors() { + let io: std::io::Error = Error::InteriorNul.into(); + assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput); + } +} From 7c123b51e0c76583277e5989abcdb3cfdbdbb46d Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:21:50 +0000 Subject: [PATCH 16/42] fix(share): never retry connect_auto on ERROR_MORE_DATA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows does not document whether a WNetUseConnectionW call that failed with ERROR_MORE_DATA left the connection established. Retrying with a bigger buffer could therefore connect twice — under CONNECT_REDIRECT that is a second drive letter, and the first one leaks because its name is never returned to the caller. Size the access-name buffer to fit any real access name up front (a redirected device name is tiny; a deviceless access name is a provider-adjusted remote name, covered by explicit headroom) and surface ERROR_MORE_DATA as an error instead of retrying. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++++- src/error.rs | 11 ++++++---- src/share.rs | 59 ++++++++++++++++++++++++++-------------------------- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a050b4b..02d74c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,11 @@ migration table in the README. runners; clippy/rustfmt/rustdoc gates; MSRV (1.85) build check. - The `server` enumeration loop fails with `Error::Other(ERROR_MORE_DATA)` instead of spinning forever when a malformed server keeps reporting - `ERROR_MORE_DATA` without delivering entries or terminating. + `ERROR_MORE_DATA` without delivering entries or terminating. Similarly, + `connect_auto` sizes its access-name buffer to fit any real access name up + front and fails instead of retrying: Windows leaves undocumented whether a + call that failed with `ERROR_MORE_DATA` already established the + connection, so a retry could create a second one. ### Changed diff --git a/src/error.rs b/src/error.rs index db671e4..8164e1a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -177,10 +177,13 @@ pub enum Error { /// Any status code without a dedicated variant. The message is resolved /// through the system message table where possible. /// - /// sambrs also synthesizes `Other(ERROR_MORE_DATA)` (234) when Windows - /// keeps reporting that more data is available without making progress — - /// a buffer-size retry that never fits, or an enumeration batch that - /// delivers no entries — because retrying would loop forever. + /// sambrs also surfaces `Other(ERROR_MORE_DATA)` (234) when Windows asks + /// for a retry that sambrs refuses to make: a buffer-size retry that + /// never fits, an enumeration batch that delivers no entries (either + /// retry would loop forever), or an access-name buffer in + /// [`connect_auto`](crate::SmbShare::connect_auto) reported as too small + /// even though it fits any real access name (that retry could establish + /// a second connection). #[error("Windows error {code}: {msg}", code = .0, msg = os_message(*.0))] Other(u32), } diff --git a/src/share.rs b/src/share.rs index 32f92a8..49ad605 100644 --- a/src/share.rs +++ b/src/share.rs @@ -4,7 +4,6 @@ use crate::strings::{ WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide, to_wide_secret, }; use crate::trace::{debug, trace}; -use windows_sys::Win32::Foundation::ERROR_MORE_DATA; use windows_sys::Win32::NetworkManagement::WNet; /// The wide-string buffers and resource type for one connect call, converted @@ -241,35 +240,35 @@ impl SmbShare { trace!("auto-connecting to {} with flags {flags:#x}", self.remote); - let mut access_name = vec![0u16; 1024]; - // One retry with the size Windows asked for. - for _ in 0..2 { - let mut size = len_u32(access_name.len()); - let mut result = 0u32; - // SAFETY: as in `connect_raw` — `args` (which every pointer in - // `resource` and the credential pointers borrow from) and - // `access_name` outlive the call. - let status = unsafe { - WNet::WNetUseConnectionW( - std::ptr::null_mut(), // no owner window for credential dialogs - &raw const resource, - args.password_ptr(), - args.username_ptr(), - flags, - access_name.as_mut_ptr(), - &raw mut size, - &raw mut result, - ) - }; - debug!("WNetUseConnectionW returned {status}, result {result:#x}"); - if status == ERROR_MORE_DATA { - access_name = vec![0u16; size as usize]; - continue; - } - check_wnet(status)?; - return Ok(from_wide_buf(&access_name)); - } - Err(Error::Other(ERROR_MORE_DATA)) + // Sized so any real access name fits on the first call: the name is + // either a redirected local device ("Z:", nowhere near 1024) or, for + // a deviceless connection, a provider-adjusted form of the remote + // name — covered by the `remote.len()` headroom. There is + // deliberately no grow-and-retry on ERROR_MORE_DATA: Windows does + // not document whether the failed call left the connection behind, + // so a retry could establish a second one (with CONNECT_REDIRECT, a + // second drive letter whose name is never returned to the caller). + let mut access_name = vec![0u16; 1024 + args.remote.len()]; + let mut size = len_u32(access_name.len()); + let mut result = 0u32; + // SAFETY: as in `connect_raw` — `args` (which every pointer in + // `resource` and the credential pointers borrow from) and + // `access_name` outlive the call. + let status = unsafe { + WNet::WNetUseConnectionW( + std::ptr::null_mut(), // no owner window for credential dialogs + &raw const resource, + args.password_ptr(), + args.username_ptr(), + flags, + access_name.as_mut_ptr(), + &raw mut size, + &raw mut result, + ) + }; + debug!("WNetUseConnectionW returned {status}, result {result:#x}"); + check_wnet(status)?; + Ok(from_wide_buf(&access_name)) } /// Connect and return an RAII [`Connection`] guard that disconnects when From 7fe0c43465401ae083fd1b739edcc665fbd762e3 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:23:52 +0000 Subject: [PATCH 17/42] feat(error): resolve NERR_* messages from netmsg.dll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system message table behind io::Error::from_raw_os_error does not contain the netapi32 NERR_* range (2100-2999) — those messages live in netmsg.dll — so Error::Other displayed an unknown-error placeholder for any undocumented NERR code. Fall back to FormatMessageW with FORMAT_MESSAGE_FROM_HMODULE on netmsg.dll for that range. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ Cargo.toml | 3 +++ src/error.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d74c9..0bab5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ migration table in the README. user filter (`InvalidParameter` otherwise — netapi32 treats an empty string as no filter); ending every session on a server is the separate, explicit `delete_all_sessions`. +- `Error::Other` resolves `NERR_*` codes (2100–2999) through netmsg.dll, so + undocumented netapi32 errors display their real message instead of an + unknown-error placeholder. - `Error::raw_os_error` and `From for std::io::Error`. Converting an `ExtendedError` keeps the error as the `io::Error` payload, so the provider's own code, description, and name survive instead of collapsing diff --git a/Cargo.toml b/Cargo.toml index 52606ef..28139dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ features = [ "Win32_NetworkManagement_WNet", "Win32_NetworkManagement_NetManagement", "Win32_Storage_FileSystem", + # FormatMessageW + LoadLibraryExW, to resolve NERR_* messages from netmsg.dll + "Win32_System_Diagnostics_Debug", + "Win32_System_LibraryLoader", ] [package.metadata.docs.rs] diff --git a/src/error.rs b/src/error.rs index 8164e1a..6843d4b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -189,10 +189,66 @@ pub enum Error { } fn os_message(code: u32) -> String { + use windows_sys::Win32::NetworkManagement::NetManagement::{MAX_NERR, NERR_BASE}; + + // NERR_* messages live in netmsg.dll's message table, which the system + // table behind `from_raw_os_error` cannot see (it would show an + // unknown-error placeholder for them). + if (NERR_BASE..=MAX_NERR).contains(&code) { + if let Some(msg) = netmsg_message(code) { + return msg; + } + } #[allow(clippy::cast_possible_wrap)] std::io::Error::from_raw_os_error(code as i32).to_string() } +/// Look up a message in netmsg.dll's message table; `None` when the module +/// or the message is unavailable (the caller then falls back to the system +/// message table). +fn netmsg_message(code: u32) -> Option { + use windows_sys::Win32::Foundation::FreeLibrary; + use windows_sys::Win32::System::Diagnostics::Debug::{ + FORMAT_MESSAGE_FROM_HMODULE, FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW, + }; + use windows_sys::Win32::System::LibraryLoader::{LOAD_LIBRARY_AS_DATAFILE, LoadLibraryExW}; + + let netmsg = crate::strings::to_wide("netmsg.dll").ok()?; + // SAFETY: `netmsg` is a valid nul-terminated string; the returned module + // handle is freed below, after the message has been copied out of it. + let module = unsafe { + LoadLibraryExW( + netmsg.as_ptr(), + std::ptr::null_mut(), + LOAD_LIBRARY_AS_DATAFILE, + ) + }; + if module.is_null() { + return None; + } + let mut buf = [0u16; 512]; + // SAFETY: `module` stays valid and `buf` outlives the call; + // IGNORE_INSERTS guarantees the null `arguments` is never read. + let len = unsafe { + FormatMessageW( + FORMAT_MESSAGE_FROM_HMODULE | FORMAT_MESSAGE_IGNORE_INSERTS, + module, + code, + 0, // default language search order + buf.as_mut_ptr(), + crate::strings::len_u32(buf.len()), + std::ptr::null(), + ) + }; + // SAFETY: `module` came from LoadLibraryExW and is freed exactly once. + unsafe { + FreeLibrary(module); + } + // Messages end with "\r\n"; io::Error's Display doesn't have trailing + // whitespace either. + (len > 0).then(|| crate::strings::from_wide_buf(&buf).trim_end().to_string()) +} + impl Error { /// Map a raw Windows / `NERR_*` status code to an [`Error`]. /// @@ -399,4 +455,14 @@ mod tests { let io: std::io::Error = Error::InteriorNul.into(); assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput); } + + #[test] + fn other_error_resolves_nerr_messages_from_netmsg_dll() { + // NERR_NetNotStarted (2102) has no system-table message; it must + // come from netmsg.dll. The text is locale-dependent, so assert only + // that a message was found and that Other's Display uses it. + let msg = netmsg_message(2102).expect("netmsg.dll must resolve NERR codes"); + assert!(!msg.is_empty()); + assert!(Error::Other(2102).to_string().contains(&msg)); + } } From 17d6fa7d5af7fa9c5d574b5b2b09f5567ff3c86d Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:24:25 +0000 Subject: [PATCH 18/42] refactor(enumerate): make NetResource::from_raw an unsafe fn Its soundness rests on a caller-upheld contract (the string pointers in the NETRESOURCEW must be null or valid), so express that in the signature with a # Safety section, matching the raw constructors in server.rs. Co-Authored-By: Claude Fable 5 --- src/enumerate.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/enumerate.rs b/src/enumerate.rs index 64a0a54..8786432 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -54,10 +54,13 @@ impl NetResource { self.usage & WNet::RESOURCEUSAGE_CONNECTABLE != 0 } - fn from_raw(raw: &WNet::NETRESOURCEW) -> Self { - // SAFETY: the pointers in a NETRESOURCEW returned by - // WNetEnumResourceW are either null or valid nul-terminated strings - // within the enumeration buffer, which is alive for this call. + /// # Safety + /// The string pointers in `raw` must each be null or point to a valid + /// nul-terminated UTF-16 string — as they are in a `NETRESOURCEW` + /// returned by `WNetEnumResourceW` while the enumeration buffer is + /// alive. + unsafe fn from_raw(raw: &WNet::NETRESOURCEW) -> Self { + // SAFETY: guaranteed by caller. unsafe { Self { scope: raw.dwScope, @@ -141,7 +144,8 @@ impl Resources { count as usize, ) }; - self.batch.extend(entries.iter().map(NetResource::from_raw)); + self.batch + .extend(entries.iter().map(|e| unsafe { NetResource::from_raw(e) })); return Ok(()); } ERROR_NO_MORE_ITEMS => { From a49f36da47400eb4b6bd9cd94b516002b9023c86 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:25:20 +0000 Subject: [PATCH 19/42] perf(enumerate): reuse the enumeration buffer across fill batches A fresh zeroed 16 KiB allocation per batch (re-grown every batch when entries are large) is wasted work on long enumerations; keep the buffer in the Resources struct so its capacity carries over. Entries are copied out of the buffer before the next fill, so reuse is sound. Co-Authored-By: Claude Fable 5 --- src/enumerate.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/enumerate.rs b/src/enumerate.rs index 8786432..099afbc 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -82,6 +82,9 @@ impl NetResource { pub struct Resources { handle: HANDLE, batch: VecDeque, + /// Enumeration buffer, reused (with any growth) across [`Self::fill`] + /// batches; `u64` elements to keep it `NETRESOURCEW`-aligned. + buf: Vec, finished: bool, } @@ -106,20 +109,19 @@ impl Iterator for Resources { impl Resources { fn fill(&mut self) -> Result<()> { - // 16 KiB, u64 elements to keep NETRESOURCEW-aligned. - let mut buf = vec![0u64; 2048]; // Bounded retries as a defensive measure against a misbehaving // provider that keeps demanding a bigger buffer (mirrors the cap in // `query::wide_out`). for _ in 0..4 { let mut count = u32::MAX; // as many entries as fit - let mut size = len_u32(buf.len() * size_of::()); - // SAFETY: `buf` outlives the call; `size` is its size in bytes. + let mut size = len_u32(self.buf.len() * size_of::()); + // SAFETY: `self.buf` outlives the call; `size` is its size in + // bytes. let status = unsafe { WNet::WNetEnumResourceW( self.handle, &raw mut count, - buf.as_mut_ptr().cast(), + self.buf.as_mut_ptr().cast(), &raw mut size, ) }; @@ -137,10 +139,11 @@ impl Resources { } // SAFETY: on success the buffer starts with `count` // NETRESOURCEW entries; the strings they point to live in - // `buf` and are copied out immediately by `from_raw`. + // `self.buf` and are copied out immediately by + // `from_raw`, before the buffer is reused. let entries = unsafe { std::slice::from_raw_parts( - buf.as_ptr().cast::(), + self.buf.as_ptr().cast::(), count as usize, ) }; @@ -155,7 +158,7 @@ impl Resources { // Buffer too small for a single entry; `size` holds the // required size in bytes. ERROR_MORE_DATA => { - buf = vec![0u64; (size as usize).div_ceil(size_of::())]; + self.buf = vec![0u64; (size as usize).div_ceil(size_of::())]; } ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), code => return Err(Error::from_status(code)), @@ -207,6 +210,7 @@ fn open( NO_ERROR => Ok(Resources { handle, batch: VecDeque::new(), + buf: vec![0u64; 2048], // 16 KiB to start; grows on demand finished: false, }), ERROR_EXTENDED_ERROR => Err(wnet_extended_error()), From 8465b06bf3f07e16e2db7974f0c8ab429e9f5466 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Fri, 10 Jul 2026 01:27:17 +0000 Subject: [PATCH 20/42] ci: pin actions to commit SHAs Mutable tags and branches (@v4, @stable, @master) are a supply-chain risk for a workflow that runs on every fork PR; pin both actions to commit SHAs. dtolnay/rust-toolchain selected the toolchain via the ref, so the pinned form passes it through the toolchain input instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93b875f..3da8a37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,10 @@ jobs: name: Integration tests (Windows, real SMB share) runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # 2026-07 + with: + toolchain: stable - name: Provision a local user and SMB share shell: pwsh @@ -40,8 +42,8 @@ jobs: name: MSRV check (Rust 1.85, Windows target) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # 2026-07 with: toolchain: '1.85' targets: x86_64-pc-windows-msvc @@ -53,9 +55,10 @@ jobs: name: Lint, docs, cross-target checks (Linux) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # 2026-07 with: + toolchain: stable targets: x86_64-pc-windows-msvc - name: rustfmt From be8371757831eadc8f3b7aebfa5fe5d1f11c740b Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 12:49:14 +0000 Subject: [PATCH 21/42] add matt pocock agent guides --- AGENTS.md | 9 ++++++++ docs/agents/domain.md | 29 +++++++++++++++++++++++ docs/agents/issue-tracker.md | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..680f472 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +## Agent skills + +### Issue tracker + +Issues and PRDs are tracked in GitHub Issues. See `docs/agents/issue-tracker.md`. + +### Domain docs + +This is a single-context repository. See `docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..eedd2df --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,29 @@ +# Domain Docs + +How engineering skills should consume this repo's domain documentation. + +## Before exploring, read these + +- `CONTEXT.md` at the repository root. +- Relevant ADRs under `docs/adr/`. + +If these files don't exist, proceed silently. Create them lazily through the domain-modeling workflow when terminology or architectural decisions are resolved. + +## File structure + +This is a single-context repository: + +/ +├── CONTEXT.md +├── docs/adr/ +└── src/ + +## Use the glossary's vocabulary + +When output names a domain concept, use the term defined in `CONTEXT.md`. Avoid synonyms the glossary explicitly rejects. + +If a needed concept is absent, reconsider whether it belongs or note the gap for domain modeling. + +## Flag ADR conflicts + +If output contradicts an existing ADR, surface the conflict explicitly rather than silently overriding it. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..be504ea --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff `. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments`, retaining only external contributors. +- **Comment / label / close**: `gh pr comment`, `gh pr edit`, and `gh pr close`. + +GitHub shares one number space across issues and PRs. Resolve a bare `#42` with `gh pr view 42`, falling back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: an issue labelled `wayfinder:map`. +- **Child ticket**: a GitHub sub-issue, falling back to a task list and `Part of #` when sub-issues are unavailable. +- **Blocking**: GitHub native issue dependencies, falling back to a `Blocked by: #` line. +- **Frontier query**: the first open, unblocked, unassigned child in map order. +- **Claim**: `gh issue edit --add-assignee @me`. +- **Resolve**: comment with the answer, close the issue, and append a context pointer to the map. From 6cb88c469399091052fddcbadd419891037d4f5c Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 12:49:26 +0000 Subject: [PATCH 22/42] remove project local skills --- .agents/skills/code-review-expert/README.md | 74 --------- .agents/skills/code-review-expert/SKILL.md | 156 ------------------ .../code-review-expert/agents/agent.yaml | 7 - .../references/code-quality-checklist.md | 130 --------------- .../references/removal-plan.md | 52 ------ .../references/security-checklist.md | 118 ------------- .../references/solid-checklist.md | 65 -------- .claude/skills/code-review-expert/README.md | 74 --------- .claude/skills/code-review-expert/SKILL.md | 156 ------------------ .../code-review-expert/agents/agent.yaml | 7 - .../references/code-quality-checklist.md | 130 --------------- .../references/removal-plan.md | 52 ------ .../references/security-checklist.md | 118 ------------- .../references/solid-checklist.md | 65 -------- 14 files changed, 1204 deletions(-) delete mode 100644 .agents/skills/code-review-expert/README.md delete mode 100644 .agents/skills/code-review-expert/SKILL.md delete mode 100644 .agents/skills/code-review-expert/agents/agent.yaml delete mode 100644 .agents/skills/code-review-expert/references/code-quality-checklist.md delete mode 100644 .agents/skills/code-review-expert/references/removal-plan.md delete mode 100644 .agents/skills/code-review-expert/references/security-checklist.md delete mode 100644 .agents/skills/code-review-expert/references/solid-checklist.md delete mode 100644 .claude/skills/code-review-expert/README.md delete mode 100644 .claude/skills/code-review-expert/SKILL.md delete mode 100644 .claude/skills/code-review-expert/agents/agent.yaml delete mode 100644 .claude/skills/code-review-expert/references/code-quality-checklist.md delete mode 100644 .claude/skills/code-review-expert/references/removal-plan.md delete mode 100644 .claude/skills/code-review-expert/references/security-checklist.md delete mode 100644 .claude/skills/code-review-expert/references/solid-checklist.md diff --git a/.agents/skills/code-review-expert/README.md b/.agents/skills/code-review-expert/README.md deleted file mode 100644 index 138b876..0000000 --- a/.agents/skills/code-review-expert/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Code Review Expert - -A comprehensive code review skill for AI agents. Performs structured reviews with a senior engineer lens, covering architecture, security, performance, and code quality. - -## Installation - -```bash -npx skills add sanyuan0704/sanyuan-skills --path skills/code-review-expert -``` - -## Features - -- **SOLID Principles** - Detect SRP, OCP, LSP, ISP, DIP violations -- **Security Scan** - XSS, injection, SSRF, race conditions, auth gaps, secrets leakage -- **Performance** - N+1 queries, CPU hotspots, missing cache, memory issues -- **Error Handling** - Swallowed exceptions, async errors, missing boundaries -- **Boundary Conditions** - Null handling, empty collections, off-by-one, numeric limits -- **Removal Planning** - Identify dead code with safe deletion plans - -## Usage - -After installation, simply run: - -``` -/code-review-expert -``` - -The skill will automatically review your current git changes. - -## Workflow - -1. **Preflight** - Scope changes via `git diff` -2. **SOLID + Architecture** - Check design principles -3. **Removal Candidates** - Find dead/unused code -4. **Security Scan** - Vulnerability detection -5. **Code Quality** - Error handling, performance, boundaries -6. **Output** - Findings by severity (P0-P3) -7. **Confirmation** - Ask user before implementing fixes - -## Severity Levels - -| Level | Name | Action | -|-------|------|--------| -| P0 | Critical | Must block merge | -| P1 | High | Should fix before merge | -| P2 | Medium | Fix or create follow-up | -| P3 | Low | Optional improvement | - -## Structure - -``` -code-review-expert/ -├── SKILL.md # Main skill definition -├── agents/ -│ └── agent.yaml # Agent interface config -└── references/ - ├── solid-checklist.md # SOLID smell prompts - ├── security-checklist.md # Security & reliability - ├── code-quality-checklist.md # Error, perf, boundaries - └── removal-plan.md # Deletion planning template -``` - -## References - -Each checklist provides detailed prompts and anti-patterns: - -- **solid-checklist.md** - SOLID violations + common code smells -- **security-checklist.md** - OWASP risks, race conditions, crypto, supply chain -- **code-quality-checklist.md** - Error handling, caching, N+1, null safety -- **removal-plan.md** - Safe vs deferred deletion with rollback plans - -## License - -MIT diff --git a/.agents/skills/code-review-expert/SKILL.md b/.agents/skills/code-review-expert/SKILL.md deleted file mode 100644 index ea17fe0..0000000 --- a/.agents/skills/code-review-expert/SKILL.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -name: code-review-expert -description: "Expert code review of current git changes with a senior engineer lens. Detects SOLID violations, security risks, and proposes actionable improvements." ---- - -# Code Review Expert - -## Overview - -Perform a structured review of the current git changes with focus on SOLID, architecture, removal candidates, and security risks. Default to review-only output unless the user asks to implement changes. - -## Severity Levels - -| Level | Name | Description | Action | -|-------|------|-------------|--------| -| **P0** | Critical | Security vulnerability, data loss risk, correctness bug | Must block merge | -| **P1** | High | Logic error, significant SOLID violation, performance regression | Should fix before merge | -| **P2** | Medium | Code smell, maintainability concern, minor SOLID violation | Fix in this PR or create follow-up | -| **P3** | Low | Style, naming, minor suggestion | Optional improvement | - -## Workflow - -### 1) Preflight context - -- Use `git status -sb`, `git diff --stat`, and `git diff` to scope changes. -- If needed, use `rg` or `grep` to find related modules, usages, and contracts. -- Identify entry points, ownership boundaries, and critical paths (auth, payments, data writes, network). - -**Edge cases:** -- **No changes**: If `git diff` is empty, inform user and ask if they want to review staged changes or a specific commit range. -- **Large diff (>500 lines)**: Summarize by file first, then review in batches by module/feature area. -- **Mixed concerns**: Group findings by logical feature, not just file order. - -### 2) SOLID + architecture smells - -- Load `references/solid-checklist.md` for specific prompts. -- Look for: - - **SRP**: Overloaded modules with unrelated responsibilities. - - **OCP**: Frequent edits to add behavior instead of extension points. - - **LSP**: Subclasses that break expectations or require type checks. - - **ISP**: Wide interfaces with unused methods. - - **DIP**: High-level logic tied to low-level implementations. -- When you propose a refactor, explain *why* it improves cohesion/coupling and outline a minimal, safe split. -- If refactor is non-trivial, propose an incremental plan instead of a large rewrite. - -### 3) Removal candidates + iteration plan - -- Load `references/removal-plan.md` for template. -- Identify code that is unused, redundant, or feature-flagged off. -- Distinguish **safe delete now** vs **defer with plan**. -- Provide a follow-up plan with concrete steps and checkpoints (tests/metrics). - -### 4) Security and reliability scan - -- Load `references/security-checklist.md` for coverage. -- Check for: - - XSS, injection (SQL/NoSQL/command), SSRF, path traversal - - AuthZ/AuthN gaps, missing tenancy checks - - Secret leakage or API keys in logs/env/files - - Rate limits, unbounded loops, CPU/memory hotspots - - Unsafe deserialization, weak crypto, insecure defaults - - **Race conditions**: concurrent access, check-then-act, TOCTOU, missing locks -- Call out both **exploitability** and **impact**. - -### 5) Code quality scan - -- Load `references/code-quality-checklist.md` for coverage. -- Check for: - - **Error handling**: swallowed exceptions, overly broad catch, missing error handling, async errors - - **Performance**: N+1 queries, CPU-intensive ops in hot paths, missing cache, unbounded memory - - **Boundary conditions**: null/undefined handling, empty collections, numeric boundaries, off-by-one -- Flag issues that may cause silent failures or production incidents. - -### 6) Output format - -Structure your review as follows: - -```markdown -## Code Review Summary - -**Files reviewed**: X files, Y lines changed -**Overall assessment**: [APPROVE / REQUEST_CHANGES / COMMENT] - ---- - -## Findings - -### P0 - Critical -(none or list) - -### P1 - High -1. **[file:line]** Brief title - - Description of issue - - Suggested fix - -### P2 - Medium -2. (continue numbering across sections) - - ... - -### P3 - Low -... - ---- - -## Removal/Iteration Plan -(if applicable) - -## Additional Suggestions -(optional improvements, not blocking) -``` - -**Inline comments**: Use this format for file-specific findings: -``` -::code-comment{file="path/to/file.ts" line="42" severity="P1"} -Description of the issue and suggested fix. -:: -``` - -**Clean review**: If no issues found, explicitly state: -- What was checked -- Any areas not covered (e.g., "Did not verify database migrations") -- Residual risks or recommended follow-up tests - -### 7) Next steps confirmation - -After presenting findings, ask user how to proceed: - -```markdown ---- - -## Next Steps - -I found X issues (P0: _, P1: _, P2: _, P3: _). - -**How would you like to proceed?** - -1. **Fix all** - I'll implement all suggested fixes -2. **Fix P0/P1 only** - Address critical and high priority issues -3. **Fix specific items** - Tell me which issues to fix -4. **No changes** - Review complete, no implementation needed - -Please choose an option or provide specific instructions. -``` - -**Important**: Do NOT implement any changes until user explicitly confirms. This is a review-first workflow. - -## Resources - -### references/ - -| File | Purpose | -|------|---------| -| `solid-checklist.md` | SOLID smell prompts and refactor heuristics | -| `security-checklist.md` | Web/app security and runtime risk checklist | -| `code-quality-checklist.md` | Error handling, performance, boundary conditions | -| `removal-plan.md` | Template for deletion candidates and follow-up plan | diff --git a/.agents/skills/code-review-expert/agents/agent.yaml b/.agents/skills/code-review-expert/agents/agent.yaml deleted file mode 100644 index 70be2aa..0000000 --- a/.agents/skills/code-review-expert/agents/agent.yaml +++ /dev/null @@ -1,7 +0,0 @@ -interface: - display_name: "Code Review Expert" - short_description: "Senior engineer code review: SOLID, security, performance, error handling" - default_prompt: "Review current git changes for SOLID violations, security risks, race conditions, error handling issues, performance problems, and boundary condition bugs." - -# Agent-agnostic skill - works with any LLM provider. -# No provider-specific configuration required. diff --git a/.agents/skills/code-review-expert/references/code-quality-checklist.md b/.agents/skills/code-review-expert/references/code-quality-checklist.md deleted file mode 100644 index 0dbfb87..0000000 --- a/.agents/skills/code-review-expert/references/code-quality-checklist.md +++ /dev/null @@ -1,130 +0,0 @@ -# Code Quality Checklist - -## Error Handling - -### Anti-patterns to Flag - -- **Swallowed exceptions**: Empty catch blocks or catch with only logging - ```javascript - try { ... } catch (e) { } // Silent failure - try { ... } catch (e) { console.log(e) } // Log and forget - ``` -- **Overly broad catch**: Catching `Exception`/`Error` base class instead of specific types -- **Error information leakage**: Stack traces or internal details exposed to users -- **Missing error handling**: No try-catch around fallible operations (I/O, network, parsing) -- **Async error handling**: Unhandled promise rejections, missing `.catch()`, no error boundary - -### Best Practices to Check - -- [ ] Errors are caught at appropriate boundaries -- [ ] Error messages are user-friendly (no internal details exposed) -- [ ] Errors are logged with sufficient context for debugging -- [ ] Async errors are properly propagated or handled -- [ ] Fallback behavior is defined for recoverable errors -- [ ] Critical errors trigger alerts/monitoring - -### Questions to Ask -- "What happens when this operation fails?" -- "Will the caller know something went wrong?" -- "Is there enough context to debug this error?" - ---- - -## Performance & Caching - -### CPU-Intensive Operations - -- **Expensive operations in hot paths**: Regex compilation, JSON parsing, crypto in loops -- **Blocking main thread**: Sync I/O, heavy computation without worker/async -- **Unnecessary recomputation**: Same calculation done multiple times -- **Missing memoization**: Pure functions called repeatedly with same inputs - -### Database & I/O - -- **N+1 queries**: Loop that makes a query per item instead of batch - ```javascript - // Bad: N+1 - for (const id of ids) { - const user = await db.query(`SELECT * FROM users WHERE id = ?`, id) - } - // Good: Batch - const users = await db.query(`SELECT * FROM users WHERE id IN (?)`, ids) - ``` -- **Missing indexes**: Queries on unindexed columns -- **Over-fetching**: SELECT * when only few columns needed -- **No pagination**: Loading entire dataset into memory - -### Caching Issues - -- **Missing cache for expensive operations**: Repeated API calls, DB queries, computations -- **Cache without TTL**: Stale data served indefinitely -- **Cache without invalidation strategy**: Data updated but cache not cleared -- **Cache key collisions**: Insufficient key uniqueness -- **Caching user-specific data globally**: Security/privacy issue - -### Memory - -- **Unbounded collections**: Arrays/maps that grow without limit -- **Large object retention**: Holding references preventing GC -- **String concatenation in loops**: Use StringBuilder/join instead -- **Loading large files entirely**: Use streaming instead - -### Questions to Ask -- "What's the time complexity of this operation?" -- "How does this behave with 10x/100x data?" -- "Is this result cacheable? Should it be?" -- "Can this be batched instead of one-by-one?" - ---- - -## Boundary Conditions - -### Null/Undefined Handling - -- **Missing null checks**: Accessing properties on potentially null objects -- **Truthy/falsy confusion**: `if (value)` when `0` or `""` are valid -- **Optional chaining overuse**: `a?.b?.c?.d` hiding structural issues -- **Null vs undefined inconsistency**: Mixed usage without clear convention - -### Empty Collections - -- **Empty array not handled**: Code assumes array has items -- **Empty object edge case**: `for...in` or `Object.keys` on empty object -- **First/last element access**: `arr[0]` or `arr[arr.length-1]` without length check - -### Numeric Boundaries - -- **Division by zero**: Missing check before division -- **Integer overflow**: Large numbers exceeding safe integer range -- **Floating point comparison**: Using `===` instead of epsilon comparison -- **Negative values**: Index or count that shouldn't be negative -- **Off-by-one errors**: Loop bounds, array slicing, pagination - -### String Boundaries - -- **Empty string**: Not handled as edge case -- **Whitespace-only string**: Passes truthy check but is effectively empty -- **Very long strings**: No length limits causing memory/display issues -- **Unicode edge cases**: Emoji, RTL text, combining characters - -### Common Patterns to Flag - -```javascript -// Dangerous: no null check -const name = user.profile.name - -// Dangerous: array access without check -const first = items[0] - -// Dangerous: division without check -const avg = total / count - -// Dangerous: truthy check excludes valid values -if (value) { ... } // fails for 0, "", false -``` - -### Questions to Ask -- "What if this is null/undefined?" -- "What if this collection is empty?" -- "What's the valid range for this number?" -- "What happens at the boundaries (0, -1, MAX_INT)?" diff --git a/.agents/skills/code-review-expert/references/removal-plan.md b/.agents/skills/code-review-expert/references/removal-plan.md deleted file mode 100644 index 33f2383..0000000 --- a/.agents/skills/code-review-expert/references/removal-plan.md +++ /dev/null @@ -1,52 +0,0 @@ -# Removal and Iteration Plan Template - -## Priority Levels - -- [ ] **P0**: Immediate removal needed (security risk, significant cost, blocking other work) -- [ ] **P1**: Remove in current sprint -- [ ] **P2**: Backlog / next iteration - ---- - -## Safe to Remove Now - -### Item: [Name/Description] - -| Field | Details | -|-------|---------| -| **Location** | `path/to/file.ts:line` | -| **Rationale** | Why this should be removed | -| **Evidence** | Unused (no references), dead feature flag, deprecated API | -| **Impact** | None / Low - no active consumers | -| **Deletion steps** | 1. Remove code 2. Remove tests 3. Remove config | -| **Verification** | Run tests, check no runtime errors, monitor logs | - ---- - -## Defer Removal (Plan Required) - -### Item: [Name/Description] - -| Field | Details | -|-------|---------| -| **Location** | `path/to/file.ts:line` | -| **Why defer** | Active consumers, needs migration, stakeholder sign-off | -| **Preconditions** | Feature flag off for 2 weeks, telemetry shows 0 usage | -| **Breaking changes** | List any API/contract changes | -| **Migration plan** | Steps for consumers to migrate | -| **Timeline** | Target date or sprint | -| **Owner** | Person/team responsible | -| **Validation** | Metrics to confirm safe removal (error rates, usage counts) | -| **Rollback plan** | How to restore if issues found | - ---- - -## Checklist Before Removal - -- [ ] Searched codebase for all references (`rg`, `grep`) -- [ ] Checked for dynamic/reflection-based usage -- [ ] Verified no external consumers (APIs, SDKs, docs) -- [ ] Feature flag telemetry reviewed (if applicable) -- [ ] Tests updated/removed -- [ ] Documentation updated -- [ ] Team notified (if shared code) diff --git a/.agents/skills/code-review-expert/references/security-checklist.md b/.agents/skills/code-review-expert/references/security-checklist.md deleted file mode 100644 index 193469e..0000000 --- a/.agents/skills/code-review-expert/references/security-checklist.md +++ /dev/null @@ -1,118 +0,0 @@ -# Security and Reliability Checklist - -## Input/Output Safety - -- **XSS**: Unsafe HTML injection, `dangerouslySetInnerHTML`, unescaped templates, innerHTML assignments -- **Injection**: SQL/NoSQL/command/GraphQL injection via string concatenation or template literals -- **SSRF**: User-controlled URLs reaching internal services without allowlist validation -- **Path traversal**: User input in file paths without sanitization (`../` attacks) -- **Prototype pollution**: Unsafe object merging in JavaScript (`Object.assign`, spread with user input) - -## AuthN/AuthZ - -- Missing tenant or ownership checks for read/write operations -- New endpoints without auth guards or RBAC enforcement -- Trusting client-provided roles/flags/IDs -- Broken access control (IDOR - Insecure Direct Object Reference) -- Session fixation or weak session management - -## JWT & Token Security - -- Algorithm confusion attacks (accepting `none` or `HS256` when expecting `RS256`) -- Weak or hardcoded secrets -- Missing expiration (`exp`) or not validating it -- Sensitive data in JWT payload (tokens are base64, not encrypted) -- Not validating `iss` (issuer) or `aud` (audience) - -## Secrets and PII - -- API keys, tokens, or credentials in code/config/logs -- Secrets in git history or environment variables exposed to client -- Excessive logging of PII or sensitive payloads -- Missing data masking in error messages - -## Supply Chain & Dependencies - -- Unpinned dependencies allowing malicious updates -- Dependency confusion (private package name collision) -- Importing from untrusted sources or CDNs without integrity checks -- Outdated dependencies with known CVEs - -## CORS & Headers - -- Overly permissive CORS (`Access-Control-Allow-Origin: *` with credentials) -- Missing security headers (CSP, X-Frame-Options, X-Content-Type-Options) -- Exposed internal headers or stack traces - -## Runtime Risks - -- Unbounded loops, recursive calls, or large in-memory buffers -- Missing timeouts, retries, or rate limiting on external calls -- Blocking operations on request path (sync I/O in async context) -- Resource exhaustion (file handles, connections, memory) -- ReDoS (Regular Expression Denial of Service) - -## Cryptography - -- Weak algorithms (MD5, SHA1 for security purposes) -- Hardcoded IVs or salts -- Using encryption without authentication (ECB mode, no HMAC) -- Insufficient key length - -## Race Conditions - -Race conditions are subtle bugs that cause intermittent failures and security vulnerabilities. Pay special attention to: - -### Shared State Access -- Multiple threads/goroutines/async tasks accessing shared variables without synchronization -- Global state or singletons modified concurrently -- Lazy initialization without proper locking (double-checked locking issues) -- Non-thread-safe collections used in concurrent context - -### Check-Then-Act (TOCTOU) -- `if (exists) then use` patterns without atomic operations -- `if (authorized) then perform` where authorization can change -- File existence check followed by file operation -- Balance check followed by deduction (financial operations) -- Inventory check followed by order placement - -### Database Concurrency -- Missing optimistic locking (`version` column, `updated_at` checks) -- Missing pessimistic locking (`SELECT FOR UPDATE`) -- Read-modify-write without transaction isolation -- Counter increments without atomic operations (`UPDATE SET count = count + 1`) -- Unique constraint violations in concurrent inserts - -### Distributed Systems -- Missing distributed locks for shared resources -- Leader election race conditions -- Cache invalidation races (stale reads after writes) -- Event ordering dependencies without proper sequencing -- Split-brain scenarios in cluster operations - -### Common Patterns to Flag -``` -# Dangerous patterns: -if not exists(key): # TOCTOU - create(key) - -value = get(key) # Read-modify-write -value += 1 -set(key, value) - -if user.balance >= amount: # Check-then-act - user.balance -= amount -``` - -### Questions to Ask -- "What happens if two requests hit this code simultaneously?" -- "Is this operation atomic or can it be interrupted?" -- "What shared state does this code access?" -- "How does this behave under high concurrency?" - -## Data Integrity - -- Missing transactions, partial writes, or inconsistent state updates -- Weak validation before persistence (type coercion issues) -- Missing idempotency for retryable operations -- Lost updates due to concurrent modifications diff --git a/.agents/skills/code-review-expert/references/solid-checklist.md b/.agents/skills/code-review-expert/references/solid-checklist.md deleted file mode 100644 index 0d08c7a..0000000 --- a/.agents/skills/code-review-expert/references/solid-checklist.md +++ /dev/null @@ -1,65 +0,0 @@ -# SOLID Smell Prompts - -## SRP (Single Responsibility) - -- File owns unrelated concerns (e.g., HTTP + DB + domain rules in one file) -- Large class/module with low cohesion or multiple reasons to change -- Functions that orchestrate many unrelated steps -- God objects that know too much about the system -- **Ask**: "What is the single reason this module would change?" - -## OCP (Open/Closed) - -- Adding a new behavior requires editing many switch/if blocks -- Feature growth requires modifying core logic rather than extending -- No plugin/strategy/hook points for variation -- **Ask**: "Can I add a new variant without touching existing code?" - -## LSP (Liskov Substitution) - -- Subclass checks for concrete type or throws for base method -- Overridden methods weaken preconditions or strengthen postconditions -- Subclass ignores or no-ops parent behavior -- **Ask**: "Can I substitute any subclass without the caller knowing?" - -## ISP (Interface Segregation) - -- Interfaces with many methods, most unused by implementers -- Callers depend on broad interfaces for narrow needs -- Empty/stub implementations of interface methods -- **Ask**: "Do all implementers use all methods?" - -## DIP (Dependency Inversion) - -- High-level logic depends on concrete IO, storage, or network types -- Hard-coded implementations instead of abstractions or injection -- Import chains that couple business logic to infrastructure -- **Ask**: "Can I swap the implementation without changing business logic?" - ---- - -## Common Code Smells (Beyond SOLID) - -| Smell | Signs | -|-------|-------| -| **Long method** | Function > 30 lines, multiple levels of nesting | -| **Feature envy** | Method uses more data from another class than its own | -| **Data clumps** | Same group of parameters passed together repeatedly | -| **Primitive obsession** | Using strings/numbers instead of domain types | -| **Shotgun surgery** | One change requires edits across many files | -| **Divergent change** | One file changes for many unrelated reasons | -| **Dead code** | Unreachable or never-called code | -| **Speculative generality** | Abstractions for hypothetical future needs | -| **Magic numbers/strings** | Hardcoded values without named constants | - ---- - -## Refactor Heuristics - -1. **Split by responsibility, not by size** - A small file can still violate SRP -2. **Introduce abstraction only when needed** - Wait for the second use case -3. **Keep refactors incremental** - Isolate behavior before moving -4. **Preserve behavior first** - Add tests before restructuring -5. **Name things by intent** - If naming is hard, the abstraction might be wrong -6. **Prefer composition over inheritance** - Inheritance creates tight coupling -7. **Make illegal states unrepresentable** - Use types to enforce invariants diff --git a/.claude/skills/code-review-expert/README.md b/.claude/skills/code-review-expert/README.md deleted file mode 100644 index 138b876..0000000 --- a/.claude/skills/code-review-expert/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Code Review Expert - -A comprehensive code review skill for AI agents. Performs structured reviews with a senior engineer lens, covering architecture, security, performance, and code quality. - -## Installation - -```bash -npx skills add sanyuan0704/sanyuan-skills --path skills/code-review-expert -``` - -## Features - -- **SOLID Principles** - Detect SRP, OCP, LSP, ISP, DIP violations -- **Security Scan** - XSS, injection, SSRF, race conditions, auth gaps, secrets leakage -- **Performance** - N+1 queries, CPU hotspots, missing cache, memory issues -- **Error Handling** - Swallowed exceptions, async errors, missing boundaries -- **Boundary Conditions** - Null handling, empty collections, off-by-one, numeric limits -- **Removal Planning** - Identify dead code with safe deletion plans - -## Usage - -After installation, simply run: - -``` -/code-review-expert -``` - -The skill will automatically review your current git changes. - -## Workflow - -1. **Preflight** - Scope changes via `git diff` -2. **SOLID + Architecture** - Check design principles -3. **Removal Candidates** - Find dead/unused code -4. **Security Scan** - Vulnerability detection -5. **Code Quality** - Error handling, performance, boundaries -6. **Output** - Findings by severity (P0-P3) -7. **Confirmation** - Ask user before implementing fixes - -## Severity Levels - -| Level | Name | Action | -|-------|------|--------| -| P0 | Critical | Must block merge | -| P1 | High | Should fix before merge | -| P2 | Medium | Fix or create follow-up | -| P3 | Low | Optional improvement | - -## Structure - -``` -code-review-expert/ -├── SKILL.md # Main skill definition -├── agents/ -│ └── agent.yaml # Agent interface config -└── references/ - ├── solid-checklist.md # SOLID smell prompts - ├── security-checklist.md # Security & reliability - ├── code-quality-checklist.md # Error, perf, boundaries - └── removal-plan.md # Deletion planning template -``` - -## References - -Each checklist provides detailed prompts and anti-patterns: - -- **solid-checklist.md** - SOLID violations + common code smells -- **security-checklist.md** - OWASP risks, race conditions, crypto, supply chain -- **code-quality-checklist.md** - Error handling, caching, N+1, null safety -- **removal-plan.md** - Safe vs deferred deletion with rollback plans - -## License - -MIT diff --git a/.claude/skills/code-review-expert/SKILL.md b/.claude/skills/code-review-expert/SKILL.md deleted file mode 100644 index ea17fe0..0000000 --- a/.claude/skills/code-review-expert/SKILL.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -name: code-review-expert -description: "Expert code review of current git changes with a senior engineer lens. Detects SOLID violations, security risks, and proposes actionable improvements." ---- - -# Code Review Expert - -## Overview - -Perform a structured review of the current git changes with focus on SOLID, architecture, removal candidates, and security risks. Default to review-only output unless the user asks to implement changes. - -## Severity Levels - -| Level | Name | Description | Action | -|-------|------|-------------|--------| -| **P0** | Critical | Security vulnerability, data loss risk, correctness bug | Must block merge | -| **P1** | High | Logic error, significant SOLID violation, performance regression | Should fix before merge | -| **P2** | Medium | Code smell, maintainability concern, minor SOLID violation | Fix in this PR or create follow-up | -| **P3** | Low | Style, naming, minor suggestion | Optional improvement | - -## Workflow - -### 1) Preflight context - -- Use `git status -sb`, `git diff --stat`, and `git diff` to scope changes. -- If needed, use `rg` or `grep` to find related modules, usages, and contracts. -- Identify entry points, ownership boundaries, and critical paths (auth, payments, data writes, network). - -**Edge cases:** -- **No changes**: If `git diff` is empty, inform user and ask if they want to review staged changes or a specific commit range. -- **Large diff (>500 lines)**: Summarize by file first, then review in batches by module/feature area. -- **Mixed concerns**: Group findings by logical feature, not just file order. - -### 2) SOLID + architecture smells - -- Load `references/solid-checklist.md` for specific prompts. -- Look for: - - **SRP**: Overloaded modules with unrelated responsibilities. - - **OCP**: Frequent edits to add behavior instead of extension points. - - **LSP**: Subclasses that break expectations or require type checks. - - **ISP**: Wide interfaces with unused methods. - - **DIP**: High-level logic tied to low-level implementations. -- When you propose a refactor, explain *why* it improves cohesion/coupling and outline a minimal, safe split. -- If refactor is non-trivial, propose an incremental plan instead of a large rewrite. - -### 3) Removal candidates + iteration plan - -- Load `references/removal-plan.md` for template. -- Identify code that is unused, redundant, or feature-flagged off. -- Distinguish **safe delete now** vs **defer with plan**. -- Provide a follow-up plan with concrete steps and checkpoints (tests/metrics). - -### 4) Security and reliability scan - -- Load `references/security-checklist.md` for coverage. -- Check for: - - XSS, injection (SQL/NoSQL/command), SSRF, path traversal - - AuthZ/AuthN gaps, missing tenancy checks - - Secret leakage or API keys in logs/env/files - - Rate limits, unbounded loops, CPU/memory hotspots - - Unsafe deserialization, weak crypto, insecure defaults - - **Race conditions**: concurrent access, check-then-act, TOCTOU, missing locks -- Call out both **exploitability** and **impact**. - -### 5) Code quality scan - -- Load `references/code-quality-checklist.md` for coverage. -- Check for: - - **Error handling**: swallowed exceptions, overly broad catch, missing error handling, async errors - - **Performance**: N+1 queries, CPU-intensive ops in hot paths, missing cache, unbounded memory - - **Boundary conditions**: null/undefined handling, empty collections, numeric boundaries, off-by-one -- Flag issues that may cause silent failures or production incidents. - -### 6) Output format - -Structure your review as follows: - -```markdown -## Code Review Summary - -**Files reviewed**: X files, Y lines changed -**Overall assessment**: [APPROVE / REQUEST_CHANGES / COMMENT] - ---- - -## Findings - -### P0 - Critical -(none or list) - -### P1 - High -1. **[file:line]** Brief title - - Description of issue - - Suggested fix - -### P2 - Medium -2. (continue numbering across sections) - - ... - -### P3 - Low -... - ---- - -## Removal/Iteration Plan -(if applicable) - -## Additional Suggestions -(optional improvements, not blocking) -``` - -**Inline comments**: Use this format for file-specific findings: -``` -::code-comment{file="path/to/file.ts" line="42" severity="P1"} -Description of the issue and suggested fix. -:: -``` - -**Clean review**: If no issues found, explicitly state: -- What was checked -- Any areas not covered (e.g., "Did not verify database migrations") -- Residual risks or recommended follow-up tests - -### 7) Next steps confirmation - -After presenting findings, ask user how to proceed: - -```markdown ---- - -## Next Steps - -I found X issues (P0: _, P1: _, P2: _, P3: _). - -**How would you like to proceed?** - -1. **Fix all** - I'll implement all suggested fixes -2. **Fix P0/P1 only** - Address critical and high priority issues -3. **Fix specific items** - Tell me which issues to fix -4. **No changes** - Review complete, no implementation needed - -Please choose an option or provide specific instructions. -``` - -**Important**: Do NOT implement any changes until user explicitly confirms. This is a review-first workflow. - -## Resources - -### references/ - -| File | Purpose | -|------|---------| -| `solid-checklist.md` | SOLID smell prompts and refactor heuristics | -| `security-checklist.md` | Web/app security and runtime risk checklist | -| `code-quality-checklist.md` | Error handling, performance, boundary conditions | -| `removal-plan.md` | Template for deletion candidates and follow-up plan | diff --git a/.claude/skills/code-review-expert/agents/agent.yaml b/.claude/skills/code-review-expert/agents/agent.yaml deleted file mode 100644 index 70be2aa..0000000 --- a/.claude/skills/code-review-expert/agents/agent.yaml +++ /dev/null @@ -1,7 +0,0 @@ -interface: - display_name: "Code Review Expert" - short_description: "Senior engineer code review: SOLID, security, performance, error handling" - default_prompt: "Review current git changes for SOLID violations, security risks, race conditions, error handling issues, performance problems, and boundary condition bugs." - -# Agent-agnostic skill - works with any LLM provider. -# No provider-specific configuration required. diff --git a/.claude/skills/code-review-expert/references/code-quality-checklist.md b/.claude/skills/code-review-expert/references/code-quality-checklist.md deleted file mode 100644 index 0dbfb87..0000000 --- a/.claude/skills/code-review-expert/references/code-quality-checklist.md +++ /dev/null @@ -1,130 +0,0 @@ -# Code Quality Checklist - -## Error Handling - -### Anti-patterns to Flag - -- **Swallowed exceptions**: Empty catch blocks or catch with only logging - ```javascript - try { ... } catch (e) { } // Silent failure - try { ... } catch (e) { console.log(e) } // Log and forget - ``` -- **Overly broad catch**: Catching `Exception`/`Error` base class instead of specific types -- **Error information leakage**: Stack traces or internal details exposed to users -- **Missing error handling**: No try-catch around fallible operations (I/O, network, parsing) -- **Async error handling**: Unhandled promise rejections, missing `.catch()`, no error boundary - -### Best Practices to Check - -- [ ] Errors are caught at appropriate boundaries -- [ ] Error messages are user-friendly (no internal details exposed) -- [ ] Errors are logged with sufficient context for debugging -- [ ] Async errors are properly propagated or handled -- [ ] Fallback behavior is defined for recoverable errors -- [ ] Critical errors trigger alerts/monitoring - -### Questions to Ask -- "What happens when this operation fails?" -- "Will the caller know something went wrong?" -- "Is there enough context to debug this error?" - ---- - -## Performance & Caching - -### CPU-Intensive Operations - -- **Expensive operations in hot paths**: Regex compilation, JSON parsing, crypto in loops -- **Blocking main thread**: Sync I/O, heavy computation without worker/async -- **Unnecessary recomputation**: Same calculation done multiple times -- **Missing memoization**: Pure functions called repeatedly with same inputs - -### Database & I/O - -- **N+1 queries**: Loop that makes a query per item instead of batch - ```javascript - // Bad: N+1 - for (const id of ids) { - const user = await db.query(`SELECT * FROM users WHERE id = ?`, id) - } - // Good: Batch - const users = await db.query(`SELECT * FROM users WHERE id IN (?)`, ids) - ``` -- **Missing indexes**: Queries on unindexed columns -- **Over-fetching**: SELECT * when only few columns needed -- **No pagination**: Loading entire dataset into memory - -### Caching Issues - -- **Missing cache for expensive operations**: Repeated API calls, DB queries, computations -- **Cache without TTL**: Stale data served indefinitely -- **Cache without invalidation strategy**: Data updated but cache not cleared -- **Cache key collisions**: Insufficient key uniqueness -- **Caching user-specific data globally**: Security/privacy issue - -### Memory - -- **Unbounded collections**: Arrays/maps that grow without limit -- **Large object retention**: Holding references preventing GC -- **String concatenation in loops**: Use StringBuilder/join instead -- **Loading large files entirely**: Use streaming instead - -### Questions to Ask -- "What's the time complexity of this operation?" -- "How does this behave with 10x/100x data?" -- "Is this result cacheable? Should it be?" -- "Can this be batched instead of one-by-one?" - ---- - -## Boundary Conditions - -### Null/Undefined Handling - -- **Missing null checks**: Accessing properties on potentially null objects -- **Truthy/falsy confusion**: `if (value)` when `0` or `""` are valid -- **Optional chaining overuse**: `a?.b?.c?.d` hiding structural issues -- **Null vs undefined inconsistency**: Mixed usage without clear convention - -### Empty Collections - -- **Empty array not handled**: Code assumes array has items -- **Empty object edge case**: `for...in` or `Object.keys` on empty object -- **First/last element access**: `arr[0]` or `arr[arr.length-1]` without length check - -### Numeric Boundaries - -- **Division by zero**: Missing check before division -- **Integer overflow**: Large numbers exceeding safe integer range -- **Floating point comparison**: Using `===` instead of epsilon comparison -- **Negative values**: Index or count that shouldn't be negative -- **Off-by-one errors**: Loop bounds, array slicing, pagination - -### String Boundaries - -- **Empty string**: Not handled as edge case -- **Whitespace-only string**: Passes truthy check but is effectively empty -- **Very long strings**: No length limits causing memory/display issues -- **Unicode edge cases**: Emoji, RTL text, combining characters - -### Common Patterns to Flag - -```javascript -// Dangerous: no null check -const name = user.profile.name - -// Dangerous: array access without check -const first = items[0] - -// Dangerous: division without check -const avg = total / count - -// Dangerous: truthy check excludes valid values -if (value) { ... } // fails for 0, "", false -``` - -### Questions to Ask -- "What if this is null/undefined?" -- "What if this collection is empty?" -- "What's the valid range for this number?" -- "What happens at the boundaries (0, -1, MAX_INT)?" diff --git a/.claude/skills/code-review-expert/references/removal-plan.md b/.claude/skills/code-review-expert/references/removal-plan.md deleted file mode 100644 index 33f2383..0000000 --- a/.claude/skills/code-review-expert/references/removal-plan.md +++ /dev/null @@ -1,52 +0,0 @@ -# Removal and Iteration Plan Template - -## Priority Levels - -- [ ] **P0**: Immediate removal needed (security risk, significant cost, blocking other work) -- [ ] **P1**: Remove in current sprint -- [ ] **P2**: Backlog / next iteration - ---- - -## Safe to Remove Now - -### Item: [Name/Description] - -| Field | Details | -|-------|---------| -| **Location** | `path/to/file.ts:line` | -| **Rationale** | Why this should be removed | -| **Evidence** | Unused (no references), dead feature flag, deprecated API | -| **Impact** | None / Low - no active consumers | -| **Deletion steps** | 1. Remove code 2. Remove tests 3. Remove config | -| **Verification** | Run tests, check no runtime errors, monitor logs | - ---- - -## Defer Removal (Plan Required) - -### Item: [Name/Description] - -| Field | Details | -|-------|---------| -| **Location** | `path/to/file.ts:line` | -| **Why defer** | Active consumers, needs migration, stakeholder sign-off | -| **Preconditions** | Feature flag off for 2 weeks, telemetry shows 0 usage | -| **Breaking changes** | List any API/contract changes | -| **Migration plan** | Steps for consumers to migrate | -| **Timeline** | Target date or sprint | -| **Owner** | Person/team responsible | -| **Validation** | Metrics to confirm safe removal (error rates, usage counts) | -| **Rollback plan** | How to restore if issues found | - ---- - -## Checklist Before Removal - -- [ ] Searched codebase for all references (`rg`, `grep`) -- [ ] Checked for dynamic/reflection-based usage -- [ ] Verified no external consumers (APIs, SDKs, docs) -- [ ] Feature flag telemetry reviewed (if applicable) -- [ ] Tests updated/removed -- [ ] Documentation updated -- [ ] Team notified (if shared code) diff --git a/.claude/skills/code-review-expert/references/security-checklist.md b/.claude/skills/code-review-expert/references/security-checklist.md deleted file mode 100644 index 193469e..0000000 --- a/.claude/skills/code-review-expert/references/security-checklist.md +++ /dev/null @@ -1,118 +0,0 @@ -# Security and Reliability Checklist - -## Input/Output Safety - -- **XSS**: Unsafe HTML injection, `dangerouslySetInnerHTML`, unescaped templates, innerHTML assignments -- **Injection**: SQL/NoSQL/command/GraphQL injection via string concatenation or template literals -- **SSRF**: User-controlled URLs reaching internal services without allowlist validation -- **Path traversal**: User input in file paths without sanitization (`../` attacks) -- **Prototype pollution**: Unsafe object merging in JavaScript (`Object.assign`, spread with user input) - -## AuthN/AuthZ - -- Missing tenant or ownership checks for read/write operations -- New endpoints without auth guards or RBAC enforcement -- Trusting client-provided roles/flags/IDs -- Broken access control (IDOR - Insecure Direct Object Reference) -- Session fixation or weak session management - -## JWT & Token Security - -- Algorithm confusion attacks (accepting `none` or `HS256` when expecting `RS256`) -- Weak or hardcoded secrets -- Missing expiration (`exp`) or not validating it -- Sensitive data in JWT payload (tokens are base64, not encrypted) -- Not validating `iss` (issuer) or `aud` (audience) - -## Secrets and PII - -- API keys, tokens, or credentials in code/config/logs -- Secrets in git history or environment variables exposed to client -- Excessive logging of PII or sensitive payloads -- Missing data masking in error messages - -## Supply Chain & Dependencies - -- Unpinned dependencies allowing malicious updates -- Dependency confusion (private package name collision) -- Importing from untrusted sources or CDNs without integrity checks -- Outdated dependencies with known CVEs - -## CORS & Headers - -- Overly permissive CORS (`Access-Control-Allow-Origin: *` with credentials) -- Missing security headers (CSP, X-Frame-Options, X-Content-Type-Options) -- Exposed internal headers or stack traces - -## Runtime Risks - -- Unbounded loops, recursive calls, or large in-memory buffers -- Missing timeouts, retries, or rate limiting on external calls -- Blocking operations on request path (sync I/O in async context) -- Resource exhaustion (file handles, connections, memory) -- ReDoS (Regular Expression Denial of Service) - -## Cryptography - -- Weak algorithms (MD5, SHA1 for security purposes) -- Hardcoded IVs or salts -- Using encryption without authentication (ECB mode, no HMAC) -- Insufficient key length - -## Race Conditions - -Race conditions are subtle bugs that cause intermittent failures and security vulnerabilities. Pay special attention to: - -### Shared State Access -- Multiple threads/goroutines/async tasks accessing shared variables without synchronization -- Global state or singletons modified concurrently -- Lazy initialization without proper locking (double-checked locking issues) -- Non-thread-safe collections used in concurrent context - -### Check-Then-Act (TOCTOU) -- `if (exists) then use` patterns without atomic operations -- `if (authorized) then perform` where authorization can change -- File existence check followed by file operation -- Balance check followed by deduction (financial operations) -- Inventory check followed by order placement - -### Database Concurrency -- Missing optimistic locking (`version` column, `updated_at` checks) -- Missing pessimistic locking (`SELECT FOR UPDATE`) -- Read-modify-write without transaction isolation -- Counter increments without atomic operations (`UPDATE SET count = count + 1`) -- Unique constraint violations in concurrent inserts - -### Distributed Systems -- Missing distributed locks for shared resources -- Leader election race conditions -- Cache invalidation races (stale reads after writes) -- Event ordering dependencies without proper sequencing -- Split-brain scenarios in cluster operations - -### Common Patterns to Flag -``` -# Dangerous patterns: -if not exists(key): # TOCTOU - create(key) - -value = get(key) # Read-modify-write -value += 1 -set(key, value) - -if user.balance >= amount: # Check-then-act - user.balance -= amount -``` - -### Questions to Ask -- "What happens if two requests hit this code simultaneously?" -- "Is this operation atomic or can it be interrupted?" -- "What shared state does this code access?" -- "How does this behave under high concurrency?" - -## Data Integrity - -- Missing transactions, partial writes, or inconsistent state updates -- Weak validation before persistence (type coercion issues) -- Missing idempotency for retryable operations -- Lost updates due to concurrent modifications diff --git a/.claude/skills/code-review-expert/references/solid-checklist.md b/.claude/skills/code-review-expert/references/solid-checklist.md deleted file mode 100644 index 0d08c7a..0000000 --- a/.claude/skills/code-review-expert/references/solid-checklist.md +++ /dev/null @@ -1,65 +0,0 @@ -# SOLID Smell Prompts - -## SRP (Single Responsibility) - -- File owns unrelated concerns (e.g., HTTP + DB + domain rules in one file) -- Large class/module with low cohesion or multiple reasons to change -- Functions that orchestrate many unrelated steps -- God objects that know too much about the system -- **Ask**: "What is the single reason this module would change?" - -## OCP (Open/Closed) - -- Adding a new behavior requires editing many switch/if blocks -- Feature growth requires modifying core logic rather than extending -- No plugin/strategy/hook points for variation -- **Ask**: "Can I add a new variant without touching existing code?" - -## LSP (Liskov Substitution) - -- Subclass checks for concrete type or throws for base method -- Overridden methods weaken preconditions or strengthen postconditions -- Subclass ignores or no-ops parent behavior -- **Ask**: "Can I substitute any subclass without the caller knowing?" - -## ISP (Interface Segregation) - -- Interfaces with many methods, most unused by implementers -- Callers depend on broad interfaces for narrow needs -- Empty/stub implementations of interface methods -- **Ask**: "Do all implementers use all methods?" - -## DIP (Dependency Inversion) - -- High-level logic depends on concrete IO, storage, or network types -- Hard-coded implementations instead of abstractions or injection -- Import chains that couple business logic to infrastructure -- **Ask**: "Can I swap the implementation without changing business logic?" - ---- - -## Common Code Smells (Beyond SOLID) - -| Smell | Signs | -|-------|-------| -| **Long method** | Function > 30 lines, multiple levels of nesting | -| **Feature envy** | Method uses more data from another class than its own | -| **Data clumps** | Same group of parameters passed together repeatedly | -| **Primitive obsession** | Using strings/numbers instead of domain types | -| **Shotgun surgery** | One change requires edits across many files | -| **Divergent change** | One file changes for many unrelated reasons | -| **Dead code** | Unreachable or never-called code | -| **Speculative generality** | Abstractions for hypothetical future needs | -| **Magic numbers/strings** | Hardcoded values without named constants | - ---- - -## Refactor Heuristics - -1. **Split by responsibility, not by size** - A small file can still violate SRP -2. **Introduce abstraction only when needed** - Wait for the second use case -3. **Keep refactors incremental** - Isolate behavior before moving -4. **Preserve behavior first** - Add tests before restructuring -5. **Name things by intent** - If naming is hard, the abstraction might be wrong -6. **Prefer composition over inheritance** - Inheritance creates tight coupling -7. **Make illegal states unrepresentable** - Use types to enforce invariants From 66c0970bb33c091fd00a813f70a57d7af24f0542 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 13:47:03 +0000 Subject: [PATCH 23/42] refactor(connect): simplify options and guards --- CHANGELOG.md | 5 +- README.md | 4 +- src/options.rs | 192 ++++++++++++++++--------------------------------- src/share.rs | 35 +++------ 4 files changed, 74 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bab5d8..7b81a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,8 @@ migration table in the README. - `ConnectOptions` covering every documented `CONNECT_*` flag: `persist`, `update_recent`, `interactive`, `prompt`, `commandline`, `redirect`, `current_media`, `save_credentials`, `reset_credentials`, - `require_integrity` (SMB signing), `require_privacy` (SMB encryption), - `write_through` — plus `raw_flags` and `SmbShare::connect_raw` as escape - hatches. + `require_integrity` (SMB signing), `require_privacy` (SMB encryption), and + `write_through`, plus `SmbShare::connect_raw` as an escape hatch. - `SmbShare::connect_auto`: let Windows pick a free drive letter (`WNetUseConnectionW`), returning the assigned name. - `SmbShare::connect_guarded` / `connect_auto_guarded`: RAII `Connection` diff --git a/README.md b/README.md index 31fa05b..a7474b0 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ flexibility of the underlying calls behind a safe, unopinionated interface: letter of your choice, or on a letter Windows picks. Every documented `CONNECT_*` flag is available through `ConnectOptions`, including per-connection SMB signing (`require_integrity`) and encryption - (`require_privacy`) enforcement, plus a raw-flags escape hatch. An optional - RAII `Connection` guard disconnects on drop. + (`require_privacy`) enforcement. An optional RAII `Connection` guard + disconnects on drop. - **Querying and enumerating** — `WNetGetConnectionW`, `WNetGetUserW`, `WNetGetUniversalNameW`, and the `WNetOpenEnumW` family: inspect existing connections, list remembered ones, and enumerate the shares a server diff --git a/src/options.rs b/src/options.rs index 3207d5d..c9c107f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -27,11 +27,6 @@ impl DriveLetter { pub const fn as_char(self) -> char { (b'A' + self as u8) as char } - - /// The device name Windows expects, e.g. `"D:"`. - pub(crate) fn device(self) -> String { - format!("{}:", self.as_char()) - } } impl TryFrom for DriveLetter { @@ -67,29 +62,20 @@ impl std::fmt::Display for DriveLetter { /// The type of network resource to connect to (`dwType`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] +#[repr(u32)] pub enum ResourceType { /// A disk share (`RESOURCETYPE_DISK`). The default. #[default] - Disk, + Disk = WNet::RESOURCETYPE_DISK, /// A shared printer (`RESOURCETYPE_PRINT`). - Print, + Print = WNet::RESOURCETYPE_PRINT, /// Any resource type (`RESOURCETYPE_ANY`). /// /// Only valid for deviceless connections: Windows rejects it with /// [`Error::InvalidParameter`] when a local device is redirected — /// whether configured explicitly or chosen automatically by /// [`SmbShare::connect_auto`](crate::SmbShare::connect_auto). - Any, -} - -impl ResourceType { - pub(crate) fn to_dword(self) -> u32 { - match self { - Self::Disk => WNet::RESOURCETYPE_DISK, - Self::Print => WNet::RESOURCETYPE_PRINT, - Self::Any => WNet::RESOURCETYPE_ANY, - } - } + Any = WNet::RESOURCETYPE_ANY, } /// Options for [`SmbShare::connect_with`](crate::SmbShare::connect_with), @@ -107,30 +93,32 @@ impl ResourceType { /// .persist(true) /// .require_privacy(true); // enforce SMB encryption /// ``` -// One independent boolean per CONNECT_* flag is exactly the shape of the -// underlying API; a state machine would misrepresent it. -#[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[must_use] pub struct ConnectOptions { - persist: bool, - update_recent: bool, - interactive: bool, - prompt: bool, - commandline: bool, - redirect: bool, - current_media: bool, - save_credentials: bool, - reset_credentials: bool, - require_integrity: bool, - require_privacy: bool, - write_through: bool, - raw_flags: u32, + flags: u32, +} + +impl Default for ConnectOptions { + fn default() -> Self { + Self::new() + } } impl ConnectOptions { - pub fn new() -> Self { - Self::default() + pub const fn new() -> Self { + Self { + flags: WNet::CONNECT_TEMPORARY, + } + } + + fn flag(mut self, flag: u32, yes: bool) -> Self { + if yes { + self.flags |= flag; + } else { + self.flags &= !flag; + } + self } /// `CONNECT_UPDATE_PROFILE`: remember the connection and restore it when @@ -142,32 +130,34 @@ impl ConnectOptions { /// When this is `false` (the default), `CONNECT_TEMPORARY` is passed /// instead. pub fn persist(mut self, yes: bool) -> Self { - self.persist = yes; + self.flags &= !(WNet::CONNECT_UPDATE_PROFILE | WNet::CONNECT_TEMPORARY); + self.flags |= if yes { + WNet::CONNECT_UPDATE_PROFILE + } else { + WNet::CONNECT_TEMPORARY + }; self } /// `CONNECT_UPDATE_RECENT`: keep the connection out of the recent /// connection list unless it has a redirected local device associated /// with it. - pub fn update_recent(mut self, yes: bool) -> Self { - self.update_recent = yes; - self + pub fn update_recent(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_UPDATE_RECENT, yes) } /// `CONNECT_INTERACTIVE`: the operating system may interact with the user /// for authentication purposes, e.g. by showing a password prompt instead /// of failing with [`Error::InvalidPassword`]. - pub fn interactive(mut self, yes: bool) -> Self { - self.interactive = yes; - self + pub fn interactive(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_INTERACTIVE, yes) } /// `CONNECT_PROMPT`: always prompt for the user name and password instead /// of first trying supplied or remembered credentials. Only valid /// together with [`interactive`](Self::interactive). - pub fn prompt(mut self, yes: bool) -> Self { - self.prompt = yes; - self + pub fn prompt(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_PROMPT, yes) } /// `CONNECT_COMMANDLINE`: prompt for authentication on the command line @@ -175,23 +165,20 @@ impl ConnectOptions { /// [`interactive`](Self::interactive); also a prerequisite for /// [`save_credentials`](Self::save_credentials) and /// [`reset_credentials`](Self::reset_credentials) to take effect. - pub fn commandline(mut self, yes: bool) -> Self { - self.commandline = yes; - self + pub fn commandline(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_COMMANDLINE, yes) } /// `CONNECT_REDIRECT`: force the redirection of a local device even if a /// local device name was not specified. - pub fn redirect(mut self, yes: bool) -> Self { - self.redirect = yes; - self + pub fn redirect(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_REDIRECT, yes) } /// `CONNECT_CURRENT_MEDIA`: do not start a new media to establish the /// connection (e.g. do not initiate a new dial-up connection). - pub fn current_media(mut self, yes: bool) -> Self { - self.current_media = yes; - self + pub fn current_media(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_CURRENT_MEDIA, yes) } /// `CONNECT_CMD_SAVECRED`: if the operating system prompts for a @@ -199,9 +186,8 @@ impl ConnectOptions { /// /// Windows ignores this flag unless [`interactive`](Self::interactive) /// and [`commandline`](Self::commandline) are also set. - pub fn save_credentials(mut self, yes: bool) -> Self { - self.save_credentials = yes; - self + pub fn save_credentials(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_CMD_SAVECRED, yes) } /// `CONNECT_CRED_RESET`: reset the credentials for this connection that @@ -209,83 +195,30 @@ impl ConnectOptions { /// /// Windows ignores this flag unless [`commandline`](Self::commandline) /// is also set. - pub fn reset_credentials(mut self, yes: bool) -> Self { - self.reset_credentials = yes; - self + pub fn reset_credentials(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_CRED_RESET, yes) } /// `CONNECT_REQUIRE_INTEGRITY`: fail the connection if SMB signing cannot /// be enforced. - pub fn require_integrity(mut self, yes: bool) -> Self { - self.require_integrity = yes; - self + pub fn require_integrity(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_REQUIRE_INTEGRITY, yes) } /// `CONNECT_REQUIRE_PRIVACY`: fail the connection if SMB encryption /// cannot be enforced. - pub fn require_privacy(mut self, yes: bool) -> Self { - self.require_privacy = yes; - self + pub fn require_privacy(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_REQUIRE_PRIVACY, yes) } /// `CONNECT_WRITE_THROUGH_SEMANTICS`: writes go through to the server /// before the operation completes (no write caching). - pub fn write_through(mut self, yes: bool) -> Self { - self.write_through = yes; - self - } - - /// OR arbitrary raw `CONNECT_*` bits into the final flag value — an - /// escape hatch for flags this crate has no named setter for. The bits - /// are passed through verbatim; when they already contain - /// `CONNECT_UPDATE_PROFILE` or `CONNECT_TEMPORARY`, the automatic - /// `CONNECT_TEMPORARY` default is suppressed. - pub fn raw_flags(mut self, flags: u32) -> Self { - self.raw_flags = flags; - self + pub fn write_through(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_WRITE_THROUGH_SEMANTICS, yes) } pub(crate) fn to_flags(self) -> u32 { - let mut flags = self.raw_flags; - if self.persist { - flags |= WNet::CONNECT_UPDATE_PROFILE; - } else if flags & (WNet::CONNECT_UPDATE_PROFILE | WNet::CONNECT_TEMPORARY) == 0 { - flags |= WNet::CONNECT_TEMPORARY; - } - if self.update_recent { - flags |= WNet::CONNECT_UPDATE_RECENT; - } - if self.interactive { - flags |= WNet::CONNECT_INTERACTIVE; - } - if self.prompt { - flags |= WNet::CONNECT_PROMPT; - } - if self.commandline { - flags |= WNet::CONNECT_COMMANDLINE; - } - if self.redirect { - flags |= WNet::CONNECT_REDIRECT; - } - if self.current_media { - flags |= WNet::CONNECT_CURRENT_MEDIA; - } - if self.save_credentials { - flags |= WNet::CONNECT_CMD_SAVECRED; - } - if self.reset_credentials { - flags |= WNet::CONNECT_CRED_RESET; - } - if self.require_integrity { - flags |= WNet::CONNECT_REQUIRE_INTEGRITY; - } - if self.require_privacy { - flags |= WNet::CONNECT_REQUIRE_PRIVACY; - } - if self.write_through { - flags |= WNet::CONNECT_WRITE_THROUGH_SEMANTICS; - } - flags + self.flags } } @@ -356,13 +289,20 @@ mod tests { #[test] fn drive_letter_formats_as_device() { - assert_eq!(DriveLetter::A.device(), "A:"); + assert_eq!(DriveLetter::A.to_string(), "A:"); assert_eq!(DriveLetter::Z.to_string(), "Z:"); } #[test] fn default_options_are_temporary() { assert_eq!(ConnectOptions::new().to_flags(), WNet::CONNECT_TEMPORARY); + assert_eq!( + ConnectOptions::new() + .interactive(true) + .interactive(false) + .to_flags(), + WNet::CONNECT_TEMPORARY + ); } #[test] @@ -403,14 +343,6 @@ mod tests { ); } - #[test] - fn raw_flags_suppress_automatic_temporary() { - let flags = ConnectOptions::new() - .raw_flags(WNet::CONNECT_UPDATE_PROFILE) - .to_flags(); - assert_eq!(flags, WNet::CONNECT_UPDATE_PROFILE); - } - #[test] fn disconnect_options_map() { assert_eq!(DisconnectOptions::new().flags(), 0); diff --git a/src/share.rs b/src/share.rs index 49ad605..b684d20 100644 --- a/src/share.rs +++ b/src/share.rs @@ -40,7 +40,7 @@ impl ConnectArgs { fn resource(&self) -> WNet::NETRESOURCEW { WNet::NETRESOURCEW { dwScope: 0, // ignored by WNetAddConnection2W / WNetUseConnectionW - dwType: self.resource_type.to_dword(), + dwType: self.resource_type as u32, dwDisplayType: 0, // ignored, as dwScope dwUsage: 0, // ignored, as dwScope lpLocalName: opt_ptr(self.local.as_deref()), @@ -290,16 +290,14 @@ impl SmbShare { /// # Errors /// [`Error::InvalidParameter`] (synthesized without a Windows call) when /// this share has no local device; otherwise see [`Error`]. - pub fn connect_guarded(&self, options: ConnectOptions) -> Result> { + pub fn connect_guarded(&self, options: ConnectOptions) -> Result { let Some(device) = self.local.as_deref() else { return Err(Error::InvalidParameter); }; let device = device.to_string(); self.connect_with(options)?; Ok(Connection { - share: self, device, - on_drop: DisconnectOptions::new(), armed: true, }) } @@ -311,12 +309,10 @@ impl SmbShare { /// /// # Errors /// See [`connect_auto`](Self::connect_auto). - pub fn connect_auto_guarded(&self, options: ConnectOptions) -> Result> { + pub fn connect_auto_guarded(&self, options: ConnectOptions) -> Result { let device = self.connect_auto(options)?; Ok(Connection { - share: self, device, - on_drop: DisconnectOptions::new(), armed: true, }) } @@ -412,7 +408,7 @@ impl SmbShareBuilder { /// Redirect the share to a local drive letter. #[must_use] pub fn mount_on(mut self, letter: DriveLetter) -> Self { - self.share.local = Some(letter.device()); + self.share.local = Some(letter.to_string()); self } @@ -482,28 +478,13 @@ impl SmbShareBuilder { /// [`Connection::leak`] to keep the connection open past the guard. #[derive(Debug)] #[must_use = "dropping the guard disconnects the share immediately"] -pub struct Connection<'a> { - share: &'a SmbShare, +pub struct Connection { /// The redirected local device this guard exclusively owns. device: String, - on_drop: DisconnectOptions, armed: bool, } -impl<'a> Connection<'a> { - /// Configure the [`DisconnectOptions`] used when this guard drops (e.g. - /// force-close open files). - pub fn on_drop(mut self, options: DisconnectOptions) -> Self { - self.on_drop = options; - self - } - - /// The share this guard belongs to. - #[must_use] - pub fn share(&self) -> &'a SmbShare { - self.share - } - +impl Connection { /// The local device this guard owns (e.g. `"Z:"`) — the share is /// accessible through it for as long as the guard lives. #[must_use] @@ -526,10 +507,10 @@ impl<'a> Connection<'a> { } } -impl Drop for Connection<'_> { +impl Drop for Connection { fn drop(&mut self) { if self.armed { - if let Err(e) = cancel_connection(&self.device, self.on_drop) { + if let Err(e) = cancel_connection(&self.device, DisconnectOptions::new()) { debug!("failed to disconnect {} on guard drop: {e}", self.device); } } From 131bf4d6f67c80a71f0ee3a99e4f2975d154932f Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 13:47:06 +0000 Subject: [PATCH 24/42] refactor: simplify enumeration and internal helpers --- src/server.rs | 81 +++++++++++++++++++++----------------------------- src/strings.rs | 8 ++--- src/trace.rs | 11 ++----- 3 files changed, 38 insertions(+), 62 deletions(-) diff --git a/src/server.rs b/src/server.rs index 286560e..6f5497b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -52,8 +52,8 @@ impl Drop for NetBuffer { /// Run a `Net*Enum` call to completion, handling the resume/`ERROR_MORE_DATA` /// protocol and buffer ownership. `call` receives the out-buffer pointer and -/// the entries-read / total-entries out-params; each returned entry is passed -/// to `each` while the buffer is still alive. +/// the entries-read / total-entries out-params; `map` converts each returned +/// entry while the buffer is still alive. /// /// The protocol contract says every `ERROR_MORE_DATA` batch delivers entries /// and advances the resume handle; a malformed or malicious server can @@ -62,13 +62,14 @@ impl Drop for NetBuffer { /// (the next call would repeat the identical request), and after /// `MAX_BATCHES` batches as a backstop against a resume handle that yields /// entries but never terminates. -fn net_enum( +fn net_enum( mut call: impl FnMut(*mut *mut u8, *mut u32, *mut u32) -> u32, - mut each: impl FnMut(&T), -) -> Result<()> { + mut map: impl FnMut(&T) -> U, +) -> Result> { // Batches are MAX_PREFERRED_LENGTH-sized (remotely still tens of KB), so // thousands of batches are far beyond any real enumeration. const MAX_BATCHES: u32 = 4096; + let mut out = Vec::new(); for _ in 0..MAX_BATCHES { let mut buf: *mut u8 = std::ptr::null_mut(); let mut read = 0u32; @@ -82,12 +83,10 @@ fn net_enum( // properly aligned buffer owned by `_guard`. let entries = unsafe { std::slice::from_raw_parts(buf.cast::(), read as usize) }; - for entry in entries { - each(entry); - } + out.extend(entries.iter().map(&mut map)); } if status == NERR_SUCCESS { - return Ok(()); + return Ok(out); } if buf.is_null() || read == 0 { // ERROR_MORE_DATA with an empty batch: no progress was @@ -243,9 +242,8 @@ pub fn shares(server: Option<&str>) -> Result> { let server_w = server.map(to_wide).transpose()?; let server_ptr = opt_ptr(server_w.as_deref()); - let mut out = Vec::new(); let mut resume = 0u32; - let level2 = net_enum::( + match net_enum::( |buf, read, total| unsafe { NetShareEnum( server_ptr, @@ -257,15 +255,13 @@ pub fn shares(server: Option<&str>) -> Result> { &raw mut resume, ) }, - |info| out.push(unsafe { ShareInfo::from_level2(info) }), - ); - match level2 { - Ok(()) => Ok(out), + |info| unsafe { ShareInfo::from_level2(info) }, + ) { + Ok(out) => Ok(out), Err(Error::AccessDenied) => { debug!("NetShareEnum level 2 denied, falling back to level 1"); - out.clear(); let mut resume = 0u32; - net_enum::( + net_enum::( |buf, read, total| unsafe { NetShareEnum( server_ptr, @@ -277,9 +273,8 @@ pub fn shares(server: Option<&str>) -> Result> { &raw mut resume, ) }, - |info| out.push(unsafe { ShareInfo::from_level1(info) }), - )?; - Ok(out) + |info| unsafe { ShareInfo::from_level1(info) }, + ) } Err(e) => Err(e), } @@ -524,9 +519,8 @@ pub fn sessions( opt_ptr(user_w.as_deref()), ); - let mut out = Vec::new(); let mut resume = 0u32; - let level1 = net_enum::( + match net_enum::( |buf, read, total| unsafe { NetSessionEnum( server_ptr, @@ -540,15 +534,13 @@ pub fn sessions( &raw mut resume, ) }, - |info| out.push(unsafe { SessionInfo::from_level1(info) }), - ); - match level1 { - Ok(()) => Ok(out), + |info| unsafe { SessionInfo::from_level1(info) }, + ) { + Ok(out) => Ok(out), Err(Error::AccessDenied) => { debug!("NetSessionEnum level 1 denied, falling back to level 10"); - out.clear(); let mut resume = 0u32; - net_enum::( + net_enum::( |buf, read, total| unsafe { NetSessionEnum( server_ptr, @@ -562,9 +554,8 @@ pub fn sessions( &raw mut resume, ) }, - |info| out.push(unsafe { SessionInfo::from_level10(info) }), - )?; - Ok(out) + |info| unsafe { SessionInfo::from_level10(info) }, + ) } Err(e) => Err(e), } @@ -677,9 +668,8 @@ pub fn open_files( let path_w = base_path.map(to_wide).transpose()?; let user_w = username.map(to_wide).transpose()?; - let mut out = Vec::new(); let mut resume = 0usize; // NetFileEnum's resume handle is pointer-sized - net_enum::( + net_enum::( |buf, read, total| unsafe { NetFileEnum( opt_ptr(server_w.as_deref()), @@ -693,20 +683,19 @@ pub fn open_files( &raw mut resume, ) }, - |info: &FILE_INFO_3| { + |info| { // SAFETY: strings live in the enumeration buffer held by net_enum. unsafe { - out.push(OpenFile { + OpenFile { id: info.fi3_id, path: from_pwstr(info.fi3_pathname).unwrap_or_default(), username: from_pwstr_nonempty(info.fi3_username), locks: info.fi3_num_locks, permissions: info.fi3_permissions, - }); + } } }, - )?; - Ok(out) + ) } /// Force-close an open file/device/pipe by its [`OpenFile::id`] via @@ -758,9 +747,8 @@ pub fn connections(server: Option<&str>, qualifier: &str) -> Result( + net_enum::( |buf, read, total| unsafe { NetConnectionEnum( opt_ptr(server_w.as_deref()), @@ -773,10 +761,10 @@ pub fn connections(server: Option<&str>, qualifier: &str) -> Result, qualifier: &str) -> Result( + let result = net_enum::( |_, _, _| { calls += 1; ERROR_MORE_DATA @@ -851,7 +838,7 @@ mod tests { // A resume handle that keeps yielding entries without ever reaching // NERR_SUCCESS must hit the batch backstop, not run unbounded. let mut entries = 0u32; - let result = net_enum::( + let result = net_enum::( |buf, read, _| { // SAFETY: `buf` receives a real netapi32 allocation (freed // by net_enum's NetBuffer guard) holding the one u32 entry diff --git a/src/strings.rs b/src/strings.rs index ae3c50c..bde8e12 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -79,13 +79,9 @@ pub(crate) unsafe fn from_pwstr(ptr: *const u16) -> Option { /// Pointer to an optional secret wide string, or null when absent. /// /// The caller must keep the owning buffer alive for as long as the returned -/// pointer is in use. (A `match` rather than a closure so the body works for -/// both `Vec` and `Zeroizing>` via auto-deref.) +/// pointer is in use. pub(crate) fn secret_ptr(buf: Option<&WideSecret>) -> *const u16 { - match buf { - Some(b) => b.as_ptr(), - None => std::ptr::null(), - } + buf.map_or(std::ptr::null(), |b| b.as_ptr()) } /// Buffer length as `u32` for Windows APIs; saturates instead of panicking. diff --git a/src/trace.rs b/src/trace.rs index c05a26b..95d824b 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -8,18 +8,11 @@ pub(crate) use tracing::{debug, trace}; // The disabled variants still type-check their arguments (at zero runtime // cost) so code compiles identically with and without the feature. #[cfg(not(feature = "tracing"))] -macro_rules! debug { +macro_rules! disabled { ($($arg:tt)*) => {{ let _ = format_args!($($arg)*); }}; } #[cfg(not(feature = "tracing"))] -macro_rules! trace { - ($($arg:tt)*) => {{ - let _ = format_args!($($arg)*); - }}; -} - -#[cfg(not(feature = "tracing"))] -pub(crate) use {debug, trace}; +pub(crate) use {disabled as debug, disabled as trace}; From cd08f39c7b4002029c709f5d011123c090b87361 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 13:47:09 +0000 Subject: [PATCH 25/42] chore: remove stale metadata and duplicate test docs --- skills-lock.json | 11 ----------- tests/integration.rs | 24 +----------------------- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 skills-lock.json diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 2d356fb..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 1, - "skills": { - "code-review-expert": { - "source": "sanyuan0704/code-review-expert", - "sourceType": "github", - "skillPath": "skills/code-review-expert/SKILL.md", - "computedHash": "6c2fe31851a34e63e033257527eab04eea835ca1cf4c4276d1392a323e36e377" - } - } -} diff --git a/tests/integration.rs b/tests/integration.rs index 7d99561..7823b4e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,26 +1,4 @@ -//! Integration tests against a real SMB share. -//! -//! These tests are `#[ignore]`d by default because they need a live share. -//! Provide one via environment variables and run them explicitly: -//! -//! ```text -//! SAMBRS_TEST_SHARE=\\server\share -//! SAMBRS_TEST_USERNAME=DOMAIN\user -//! SAMBRS_TEST_PASSWORD=... -//! SAMBRS_TEST_LOCAL=1 # only if the share is on THIS machine and the test -//! # process can administer it (enables server:: tests) -//! RUST_TEST_THREADS=1 # the tests mount real drive letters and enumerate -//! # live connections; they must not run concurrently -//! -//! cargo test -- --include-ignored -//! ``` -//! -//! CI provisions `\\localhost\sambrs-test` with a dedicated local user and -//! runs the full suite; see `.github/workflows/ci.yml`. Drive letters S-Z are -//! used by these tests and must be free. In a git checkout, -//! `.cargo/config.toml` sets `RUST_TEST_THREADS=1` for you; that file is not -//! part of the packaged crate, so set it yourself when running from a -//! published copy. +//! Live SMB integration tests; see the README's Testing section. #![cfg(windows)] use sambrs::{ From 0afe79f0cbd57aa31d47b006e4c9c3cd5fcc19c8 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 14:33:44 +0000 Subject: [PATCH 26/42] refactor(share): inline credential pointer helpers --- src/share.rs | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/share.rs b/src/share.rs index b684d20..33a0fd3 100644 --- a/src/share.rs +++ b/src/share.rs @@ -10,8 +10,7 @@ use windows_sys::Win32::NetworkManagement::WNet; /// from an [`SmbShare`]. /// /// Owning them in one struct pins down the borrow discipline: the struct must -/// outlive the FFI call, because every pointer produced by [`Self::resource`], -/// [`Self::password_ptr`], and [`Self::username_ptr`] borrows from the +/// outlive the FFI call because every pointer passed to it borrows from the /// buffers owned here. struct ConnectArgs { remote: Vec, @@ -49,18 +48,6 @@ impl ConnectArgs { lpProvider: opt_ptr(self.provider.as_deref()), } } - - /// Password pointer for the call (null when no password is set); borrows - /// from `self`. - fn password_ptr(&self) -> *const u16 { - secret_ptr(self.password.as_ref()) - } - - /// User-name pointer for the call (null when no user name is set); - /// borrows from `self`. - fn username_ptr(&self) -> *mut u16 { - opt_ptr(self.username.as_deref()) - } } /// A remote SMB share, optionally redirected to a local device. @@ -199,8 +186,8 @@ impl SmbShare { let status = unsafe { WNet::WNetAddConnection2W( &raw const resource, - args.password_ptr(), - args.username_ptr(), + secret_ptr(args.password.as_ref()), + opt_ptr(args.username.as_deref()), flags, ) }; @@ -258,8 +245,8 @@ impl SmbShare { WNet::WNetUseConnectionW( std::ptr::null_mut(), // no owner window for credential dialogs &raw const resource, - args.password_ptr(), - args.username_ptr(), + secret_ptr(args.password.as_ref()), + opt_ptr(args.username.as_deref()), flags, access_name.as_mut_ptr(), &raw mut size, From ad983231592a6d674ce437f496bf71287f18ec2b Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 18:19:56 +0000 Subject: [PATCH 27/42] refactor: remove redundant complexity --- CHANGELOG.md | 2 -- Cargo.toml | 2 -- README.md | 4 ---- src/options.rs | 7 +++---- src/share.rs | 20 +++++++++----------- tests/integration.rs | 8 ++------ 6 files changed, 14 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b81a1d..3f928ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,8 +59,6 @@ migration table in the README. into the generic `ERROR_EXTENDED_ERROR` (1208) message. - Cargo features: `tracing` (now optional!) and `zeroize` (wipe password buffers on drop). -- CI: full integration suite against a real `\\localhost` share on Windows - runners; clippy/rustfmt/rustdoc gates; MSRV (1.85) build check. - The `server` enumeration loop fails with `Error::Other(ERROR_MORE_DATA)` instead of spinning forever when a malformed server keeps reporting `ERROR_MORE_DATA` without delivering entries or terminating. Similarly, diff --git a/Cargo.toml b/Cargo.toml index 28139dc..a6e47ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,14 +17,12 @@ Safe and flexible bindings for Windows SMB share operations: connect and disconn include = [ "/src", "/tests", - "/Cargo.toml", "/README.md", "/CHANGELOG.md", "/LICENSE", ] [features] -default = [] # Emit `tracing` debug/trace events for every Windows API call. tracing = ["dep:tracing"] # Wipe password buffers (both the stored String and transient UTF-16 copies) after use. diff --git a/README.md b/README.md index a7474b0..61dc717 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,6 @@ The tests mount real drive letters and must run single-threaded: a git checkout sets `RUST_TEST_THREADS=1` via `.cargo/config.toml`; set it yourself when running from a packaged copy of the crate. -CI provisions a local user plus `\\localhost\sambrs-test` on a Windows runner -and runs the whole suite against it on every push to `main` and on every pull -request, plus a Rust 1.85 build to keep the declared MSRV honest. - ## Migrating from 0.1 `0.2` is a rework of the whole API; the most important changes: diff --git a/src/options.rs b/src/options.rs index c9c107f..5122d05 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,5 +1,4 @@ use crate::error::Error; -use windows_sys::Win32::Foundation::{FALSE, TRUE}; use windows_sys::Win32::NetworkManagement::WNet; /// A local Windows drive letter (`A:` – `Z:`). @@ -265,7 +264,7 @@ impl DisconnectOptions { } pub(crate) fn force_bool(self) -> i32 { - if self.force { TRUE } else { FALSE } + i32::from(self.force) } } @@ -346,11 +345,11 @@ mod tests { #[test] fn disconnect_options_map() { assert_eq!(DisconnectOptions::new().flags(), 0); - assert_eq!(DisconnectOptions::new().force_bool(), FALSE); + assert_eq!(DisconnectOptions::new().force_bool(), 0); assert_eq!( DisconnectOptions::new().forget(true).flags(), WNet::CONNECT_UPDATE_PROFILE ); - assert_eq!(DisconnectOptions::new().force(true).force_bool(), TRUE); + assert_eq!(DisconnectOptions::new().force(true).force_bool(), 1); } } diff --git a/src/share.rs b/src/share.rs index 33a0fd3..b958f29 100644 --- a/src/share.rs +++ b/src/share.rs @@ -432,18 +432,16 @@ impl SmbShareBuilder { /// # Errors /// [`Error::InteriorNul`] if any string contains a NUL character. pub fn build(self) -> Result { - fn validate(s: Option<&str>) -> Result<()> { - if s.is_some_and(|s| s.contains('\0')) { - Err(Error::InteriorNul) - } else { - Ok(()) - } + let strings = [ + Some(self.share.remote.as_str()), + self.share.username.as_deref(), + self.share.password.as_deref(), + self.share.local.as_deref(), + self.share.provider.as_deref(), + ]; + if strings.into_iter().flatten().any(|s| s.contains('\0')) { + return Err(Error::InteriorNul); } - validate(Some(&self.share.remote))?; - validate(self.share.username.as_deref())?; - validate(self.share.password.as_deref())?; - validate(self.share.local.as_deref())?; - validate(self.share.provider.as_deref())?; Ok(self.share) } } diff --git a/tests/integration.rs b/tests/integration.rs index 7823b4e..a6385d7 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -283,12 +283,8 @@ fn enumerate_connections_contains_the_share() { fn enumerate_server_shares_contains_the_share() { // \\server\share -> \\server let full = share_name(); - let server_root = full - .trim_start_matches('\\') - .split('\\') - .next() - .map(|s| format!(r"\\{s}")) - .unwrap(); + let server = full.trim_start_matches('\\').split('\\').next().unwrap(); + let server_root = format!(r"\\{server}"); // Authenticate first: servers may refuse anonymous enumeration. let share = share(None); share.connect().unwrap(); From c25e688ebc63002406a41e31a795560346d48051 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sat, 11 Jul 2026 20:23:09 +0000 Subject: [PATCH 28/42] refactor: deduplicate crate documentation --- Cargo.toml | 8 +----- README.md | 16 +++++++---- src/lib.rs | 74 +------------------------------------------------- src/share.rs | 7 ++--- src/strings.rs | 12 -------- 5 files changed, 16 insertions(+), 101 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a6e47ec..7d69728 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,7 @@ description = """ Safe and flexible bindings for Windows SMB share operations: connect and disconnect network shares (WNet), query and enumerate connections and remote shares, and administer server-side shares, sessions, and open files (netapi32). """ # Allowlist: ship only the crate itself, not repo/CI/agent tooling. -include = [ - "/src", - "/tests", - "/README.md", - "/CHANGELOG.md", - "/LICENSE", -] +include = ["/src", "/tests", "/README.md", "/CHANGELOG.md", "/LICENSE"] [features] # Emit `tracing` debug/trace events for every Windows API call. diff --git a/README.md b/README.md index 61dc717..38a5263 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ sambrs = "0.2" Instantiate an `SmbShare` and establish a connection. Once connected (mounted or deviceless), `std::fs` works on it like on any local path: -```rust +```no_run use sambrs::{ConnectOptions, DriveLetter, SmbShare}; fn main() -> Result<(), Box> { @@ -65,9 +65,12 @@ fn main() -> Result<(), Box> { Without credentials, the connection authenticates as the logged-on user: -```rust +```no_run +# fn main() -> Result<(), sambrs::Error> { let share = sambrs::SmbShare::new(r"\\server.local\share"); share.connect()?; +# Ok(()) +# } ``` Enumerate what a server offers, or administer shares (see the @@ -75,7 +78,8 @@ Enumerate what a server offers, or administer shares (see the [`server`](https://docs.rs/sambrs/latest/sambrs/server/) module docs for the full API): -```rust +```no_run +# fn main() -> Result<(), sambrs::Error> { for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { println!("{:?}", resource?.remote_name); } @@ -84,6 +88,8 @@ sambrs::server::add_share( None, // local machine &sambrs::server::NewShare::disk("scratch", r"C:\scratch"), )?; +# Ok(()) +# } ``` ## Cargo features @@ -138,8 +144,8 @@ empty password now, matching the Windows API). ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) -file for more details. +This project is licensed under the MIT License. See the +[LICENSE](https://github.com/samvdst/sambrs/blob/main/LICENSE) for details. ## Special Thanks diff --git a/src/lib.rs b/src/lib.rs index 6960dee..35a684e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,78 +1,6 @@ #![cfg(windows)] #![warn(clippy::pedantic)] - -//! Safe and flexible bindings for Windows SMB share operations. -//! -//! Sam -> SMB -> Rust -> Samba is taken!? -> sambrs -//! -//! The crate wraps three areas of the Windows API, aiming to expose the full -//! flexibility of the underlying calls behind a safe interface: -//! -//! - **Connecting** ([`SmbShare`]): `WNetAddConnection2W`, -//! `WNetUseConnectionW`, and `WNetCancelConnection2W` — connect to a share -//! (deviceless, mounted on a drive letter of your choice, or a letter -//! Windows picks), with every documented `CONNECT_*` flag available through -//! [`ConnectOptions`], including per-connection SMB signing/encryption -//! enforcement. An optional RAII [`Connection`] guard disconnects on drop. -//! - **Querying and enumerating** ([`query`], [`enumerate`]): -//! `WNetGetConnectionW`, `WNetGetUserW`, `WNetGetUniversalNameW`, and the -//! `WNetOpenEnumW` family — inspect existing connections, list remembered -//! ones, and enumerate the shares a server exposes. -//! - **Administering** ([`server`]): the netapi32 `NetShare*`, `NetSession*`, -//! `NetFile*`, and `NetConnectionEnum` functions — create and delete -//! shares, and manage sessions and open files, locally or on a remote -//! server. -//! -//! All strings cross the FFI boundary as UTF-16 (the `W` API variants), so -//! share names, user names, and passwords with any Unicode content work -//! correctly. -//! -//! # Connecting to a share -//! -//! Instantiate an [`SmbShare`] and establish a connection. Once connected -//! (mounted or deviceless), `std::fs` works on it like on any local path: -//! -//! ```no_run -//! use sambrs::{ConnectOptions, DriveLetter, SmbShare}; -//! -//! let share = SmbShare::builder(r"\\server.local\share") -//! .credentials(r"LOGONDOMAIN\user", "pass") -//! .mount_on(DriveLetter::D) -//! .build()?; -//! -//! share.connect_with( -//! ConnectOptions::new() -//! .persist(true) // restore the mapping at logon -//! .require_privacy(true), // enforce SMB encryption -//! )?; -//! -//! // use std::fs as if D:\ was a local directory -//! println!("{}", std::fs::metadata(r"D:\")?.is_dir()); -//! # Ok::<(), Box>(()) -//! ``` -//! -//! Without credentials, the connection authenticates as the logged-on user; -//! [`SmbShare::new`] is the shortest path to that: -//! -//! ```no_run -//! use sambrs::SmbShare; -//! -//! let share = SmbShare::new(r"\\server.local\share"); -//! share.connect()?; -//! # Ok::<(), sambrs::Error>(()) -//! ``` -//! -//! # Cargo features -//! -//! - `tracing` — emit [`tracing`](https://docs.rs/tracing) debug/trace events -//! for every Windows API call. -//! - `zeroize` — wipe password buffers (the stored `String` and the transient -//! UTF-16 copies) when they are dropped. -//! -//! # Platform support -//! -//! This crate is Windows-only; on other targets it compiles to nothing. Gate -//! usage behind `#[cfg(windows)]` in cross-platform projects. +#![doc = include_str!("../README.md")] mod error; mod options; diff --git a/src/share.rs b/src/share.rs index b958f29..753567e 100644 --- a/src/share.rs +++ b/src/share.rs @@ -1,8 +1,6 @@ use crate::error::{Error, Result, check_wnet}; use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; -use crate::strings::{ - WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide, to_wide_secret, -}; +use crate::strings::{WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide}; use crate::trace::{debug, trace}; use windows_sys::Win32::NetworkManagement::WNet; @@ -23,12 +21,13 @@ struct ConnectArgs { impl ConnectArgs { fn new(share: &SmbShare) -> Result { + let password = share.password.as_deref().map(to_wide).transpose()?; Ok(Self { remote: to_wide(&share.remote)?, local: share.local.as_deref().map(to_wide).transpose()?, provider: share.provider.as_deref().map(to_wide).transpose()?, username: share.username.as_deref().map(to_wide).transpose()?, - password: share.password.as_deref().map(to_wide_secret).transpose()?, + password: password.map(WideSecret::from), resource_type: share.resource_type, }) } diff --git a/src/strings.rs b/src/strings.rs index bde8e12..d84fa3a 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -29,18 +29,6 @@ pub(crate) type WideSecret = zeroize::Zeroizing>; #[cfg(not(feature = "zeroize"))] pub(crate) type WideSecret = Vec; -/// Encode a secret (password) as a nul-terminated UTF-16 buffer. -pub(crate) fn to_wide_secret(s: &str) -> Result { - #[cfg(feature = "zeroize")] - { - to_wide(s).map(zeroize::Zeroizing::new) - } - #[cfg(not(feature = "zeroize"))] - { - to_wide(s) - } -} - /// Pointer to an optional wide string, or null when absent. /// /// The caller must keep the owning buffer alive for as long as the returned From 41d1e53c2f9b7f1c084998e33736ba4a65fb0cde Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 13:20:33 +0000 Subject: [PATCH 29/42] refactor: simplify share configuration --- CHANGELOG.md | 8 +- README.md | 7 +- src/lib.rs | 2 +- src/share.rs | 224 ++++++++++++++++--------------------------- tests/integration.rs | 20 ++-- 5 files changed, 99 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f928ea..ed55b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,14 +22,14 @@ migration table in the README. - Authenticate as the logged-on user by omitting credentials (0.1 always passed non-null credentials, making SSO unreachable). -- `SmbShare::builder` with `credentials`, `username`, `password`, `mount_on` - (type-safe `DriveLetter`), `local_device`, `resource_type` (disk/printer), - and `provider`. +- Fluent `SmbShare` configuration with `credentials`, `username`, `password`, + `mount_on` (type-safe `DriveLetter`), `local_device`, `resource_type` + (disk/printer), and `provider`. - `ConnectOptions` covering every documented `CONNECT_*` flag: `persist`, `update_recent`, `interactive`, `prompt`, `commandline`, `redirect`, `current_media`, `save_credentials`, `reset_credentials`, `require_integrity` (SMB signing), `require_privacy` (SMB encryption), and - `write_through`, plus `SmbShare::connect_raw` as an escape hatch. + `write_through`. - `SmbShare::connect_auto`: let Windows pick a free drive letter (`WNetUseConnectionW`), returning the assigned name. - `SmbShare::connect_guarded` / `connect_auto_guarded`: RAII `Connection` diff --git a/README.md b/README.md index 38a5263..7a42a19 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,9 @@ or deviceless), `std::fs` works on it like on any local path: use sambrs::{ConnectOptions, DriveLetter, SmbShare}; fn main() -> Result<(), Box> { - let share = SmbShare::builder(r"\\server.local\share") + let share = SmbShare::new(r"\\server.local\share") .credentials(r"LOGONDOMAIN\user", "pass") - .mount_on(DriveLetter::D) - .build()?; + .mount_on(DriveLetter::D); share.connect_with( ConnectOptions::new() @@ -131,7 +130,7 @@ when running from a packaged copy of the crate. | 0.1 | 0.2 | | --- | --- | -| `SmbShare::new(share, user, pass, Some('d'))` | `SmbShare::builder(share).credentials(user, pass).mount_on(DriveLetter::D).build()?` | +| `SmbShare::new(share, user, pass, Some('d'))` | `SmbShare::new(share).credentials(user, pass).mount_on(DriveLetter::D)` | | `share.connect(persist, interactive)` | `share.connect_with(ConnectOptions::new().persist(persist).interactive(interactive))` | | `share.disconnect(persist, force)` | `share.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` — note the rename: the old `persist: true` *removed* the persistence | | `Error::CStringConversion` | `Error::InteriorNul` | diff --git a/src/lib.rs b/src/lib.rs index 35a684e..8abea2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,4 +14,4 @@ pub mod server; pub use error::{Error, Result}; pub use options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; -pub use share::{Connection, SmbShare, SmbShareBuilder, cancel_connection}; +pub use share::{Connection, SmbShare, cancel_connection}; diff --git a/src/share.rs b/src/share.rs index 753567e..275462a 100644 --- a/src/share.rs +++ b/src/share.rs @@ -51,17 +51,15 @@ impl ConnectArgs { /// A remote SMB share, optionally redirected to a local device. /// -/// Construct one with [`SmbShare::new`] (deviceless, current-user -/// credentials) or [`SmbShare::builder`] for full control, then establish the -/// connection with one of the `connect*` methods. +/// Construct one with [`SmbShare::new`], configure it with the fluent setters, +/// then establish the connection with one of the `connect*` methods. /// /// ```no_run /// use sambrs::{DriveLetter, SmbShare}; /// -/// let share = SmbShare::builder(r"\\server\share") +/// let share = SmbShare::new(r"\\server\share") /// .credentials("user", "pass") -/// .mount_on(DriveLetter::D) -/// .build()?; +/// .mount_on(DriveLetter::D); /// /// share.connect()?; /// // use std::fs as if D:\ was a local directory @@ -103,9 +101,6 @@ impl Drop for SmbShare { impl SmbShare { /// A deviceless connection to `remote` (e.g. `\\server\share`) using the /// credentials of the currently logged-on user. - /// - /// Use [`SmbShare::builder`] to set credentials, a local mount point, a - /// resource type, or a network provider. pub fn new(remote: impl Into) -> Self { Self { remote: remote.into(), @@ -117,31 +112,77 @@ impl SmbShare { } } - /// Start building a share representation with full control over - /// credentials, mount point, resource type, and provider. - pub fn builder(remote: impl Into) -> SmbShareBuilder { - SmbShareBuilder { - share: Self::new(remote), + /// Authenticate with an explicit user name and password. + /// + /// The user name can carry a domain (`DOMAIN\user` or `user@domain`). + /// Without credentials, the connection uses the logged-on user's + /// credentials. An empty password string is a real (empty) password, not + /// "no password". + #[must_use] + pub fn credentials(self, username: impl Into, password: impl Into) -> Self { + self.username(username).password(password) + } + + /// Set only the user name; Windows will use the default password + /// associated with that user. + #[must_use] + pub fn username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + + /// Set only the password; Windows will use the default user name. + #[must_use] + pub fn password(mut self, password: impl Into) -> Self { + // Wipe any previously set password before the assignment drops it. + #[cfg(feature = "zeroize")] + { + use zeroize::Zeroize; + self.password.zeroize(); } + self.password = Some(password.into()); + self } - /// The remote name, e.g. `\\server\share`. + /// Redirect the share to a local drive letter. #[must_use] - pub fn remote(&self) -> &str { - &self.remote + pub fn mount_on(mut self, letter: DriveLetter) -> Self { + self.local = Some(letter.to_string()); + self + } + + /// Redirect to an arbitrary local device name (e.g. `"LPT1"` for a + /// printer share). Prefer [`mount_on`](Self::mount_on) for drive letters; + /// this escape hatch is passed to Windows unvalidated. + #[must_use] + pub fn local_device(mut self, device: impl Into) -> Self { + self.local = Some(device.into()); + self } - /// The local device this share is redirected to (e.g. `"D:"`), if any. + /// The resource type to connect to. Defaults to [`ResourceType::Disk`]. + /// + /// [`ResourceType::Any`] is only valid for deviceless connections; see + /// its documentation. #[must_use] - pub fn local_device(&self) -> Option<&str> { - self.local.as_deref() + pub fn resource_type(mut self, resource_type: ResourceType) -> Self { + self.resource_type = resource_type; + self } - /// The user name used to authenticate; `None` means the credentials of - /// the currently logged-on user. + /// The network provider to use (`lpProvider`). Microsoft: set this only + /// if you know the network provider you want; otherwise let the operating + /// system determine which provider the network name maps to. #[must_use] - pub fn username(&self) -> Option<&str> { - self.username.as_deref() + pub fn provider(mut self, provider: impl Into) -> Self { + self.provider = Some(provider.into()); + self + } + + /// The remote name, e.g. `\\server\share`. + #[must_use] + pub fn remote(&self) -> &str { + &self.remote } /// Connect with default options: a temporary, non-interactive connection. @@ -165,15 +206,7 @@ impl SmbShare { /// See [`Error`] — every documented `WNetAddConnection2W` failure has a /// dedicated variant. pub fn connect_with(&self, options: ConnectOptions) -> Result<()> { - self.connect_raw(options.to_flags()) - } - - /// Connect passing `dwFlags` verbatim to `WNetAddConnection2W` — an - /// escape hatch when [`ConnectOptions`] doesn't expose a flag you need. - /// - /// # Errors - /// See [`Error`]. - pub fn connect_raw(&self, flags: u32) -> Result<()> { + let flags = options.to_flags(); let args = ConnectArgs::new(self)?; let resource = args.resource(); @@ -212,15 +245,7 @@ impl SmbShare { /// # Errors /// See [`Error`]. pub fn connect_auto(&self, options: ConnectOptions) -> Result { - self.connect_auto_raw(options.to_flags() | WNet::CONNECT_REDIRECT) - } - - /// [`connect_auto`](Self::connect_auto) with verbatim `dwFlags` (note: - /// `CONNECT_REDIRECT` is *not* added for you here). - /// - /// # Errors - /// See [`Error`]. - pub fn connect_auto_raw(&self, flags: u32) -> Result { + let flags = options.to_flags() | WNet::CONNECT_REDIRECT; let args = ConnectArgs::new(self)?; let resource = args.resource(); @@ -237,9 +262,8 @@ impl SmbShare { let mut access_name = vec![0u16; 1024 + args.remote.len()]; let mut size = len_u32(access_name.len()); let mut result = 0u32; - // SAFETY: as in `connect_raw` — `args` (which every pointer in - // `resource` and the credential pointers borrow from) and - // `access_name` outlive the call. + // SAFETY: `args` (which every pointer in `resource` and the + // credential pointers borrow from) and `access_name` outlive the call. let status = unsafe { WNet::WNetUseConnectionW( std::ptr::null_mut(), // no owner window for credential dialogs @@ -260,9 +284,8 @@ impl SmbShare { /// Connect and return an RAII [`Connection`] guard that disconnects when /// dropped. /// - /// Requires a local device (set via - /// [`mount_on`](SmbShareBuilder::mount_on) / - /// [`local_device`](SmbShareBuilder::local_device)): the device is the + /// Requires a local device (set via [`mount_on`](Self::mount_on) or + /// [`local_device`](Self::local_device)): the device is the /// one thing a guard can exclusively own — connecting fails with /// [`Error::AlreadyAssigned`] if it is taken, and canceling it by name on /// drop touches no other connection. A deviceless connection offers no @@ -352,99 +375,6 @@ pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { check_wnet(status) } -/// Builder for [`SmbShare`], created via [`SmbShare::builder`]. -#[derive(Debug)] -pub struct SmbShareBuilder { - share: SmbShare, -} - -impl SmbShareBuilder { - /// Authenticate with an explicit user name and password. - /// - /// The user name can carry a domain (`DOMAIN\user` or `user@domain`). - /// Without credentials, the connection uses the logged-on user's - /// credentials. An empty password string is a real (empty) password, not - /// "no password". - #[must_use] - pub fn credentials(self, username: impl Into, password: impl Into) -> Self { - self.username(username).password(password) - } - - /// Set only the user name; Windows will use the default password - /// associated with that user. - #[must_use] - pub fn username(mut self, username: impl Into) -> Self { - self.share.username = Some(username.into()); - self - } - - /// Set only the password; Windows will use the default user name. - #[must_use] - pub fn password(mut self, password: impl Into) -> Self { - // Wipe any previously set password before the assignment drops it. - #[cfg(feature = "zeroize")] - { - use zeroize::Zeroize; - self.share.password.zeroize(); - } - self.share.password = Some(password.into()); - self - } - - /// Redirect the share to a local drive letter. - #[must_use] - pub fn mount_on(mut self, letter: DriveLetter) -> Self { - self.share.local = Some(letter.to_string()); - self - } - - /// Redirect to an arbitrary local device name (e.g. `"LPT1"` for a - /// printer share). Prefer [`mount_on`](Self::mount_on) for drive letters; - /// this escape hatch is passed to Windows unvalidated. - #[must_use] - pub fn local_device(mut self, device: impl Into) -> Self { - self.share.local = Some(device.into()); - self - } - - /// The resource type to connect to. Defaults to [`ResourceType::Disk`]. - /// - /// [`ResourceType::Any`] is only valid for deviceless connections; see - /// its documentation. - #[must_use] - pub fn resource_type(mut self, resource_type: ResourceType) -> Self { - self.share.resource_type = resource_type; - self - } - - /// The network provider to use (`lpProvider`). Microsoft: set this only - /// if you know the network provider you want; otherwise let the operating - /// system determine which provider the network name maps to. - #[must_use] - pub fn provider(mut self, provider: impl Into) -> Self { - self.share.provider = Some(provider.into()); - self - } - - /// Validate the configuration and build the [`SmbShare`]. - /// - /// # Errors - /// [`Error::InteriorNul`] if any string contains a NUL character. - pub fn build(self) -> Result { - let strings = [ - Some(self.share.remote.as_str()), - self.share.username.as_deref(), - self.share.password.as_deref(), - self.share.local.as_deref(), - self.share.provider.as_deref(), - ]; - if strings.into_iter().flatten().any(|s| s.contains('\0')) { - return Err(Error::InteriorNul); - } - Ok(self.share) - } -} - /// RAII guard returned by [`SmbShare::connect_guarded`] and /// [`SmbShare::connect_auto_guarded`]: cancels the connection when dropped /// (best effort — a failure on drop is only visible as a `tracing` event, @@ -505,6 +435,18 @@ impl Drop for Connection { mod tests { use super::*; + #[test] + fn configures_share_fluently() { + let share = SmbShare::new(r"\\server\share") + .credentials("user", "pass") + .mount_on(DriveLetter::D) + .provider("provider"); + assert_eq!(share.username.as_deref(), Some("user")); + assert_eq!(share.password.as_deref(), Some("pass")); + assert_eq!(share.local.as_deref(), Some("D:")); + assert_eq!(share.provider.as_deref(), Some("provider")); + } + #[test] fn deviceless_connect_guarded_is_rejected() { // Rejected before any Windows call: without a local device there is diff --git a/tests/integration.rs b/tests/integration.rs index a6385d7..8dbfe0f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -25,11 +25,11 @@ fn local_admin() -> bool { } fn share(mount: Option) -> SmbShare { - let mut builder = SmbShare::builder(share_name()).credentials(username(), password()); - if let Some(letter) = mount { - builder = builder.mount_on(letter); + let share = SmbShare::new(share_name()).credentials(username(), password()); + match mount { + Some(letter) => share.mount_on(letter), + None => share, } - builder.build().expect("test credentials contain no NUL") } fn drive_exists(letter: DriveLetter) -> bool { @@ -43,10 +43,8 @@ fn drive_exists(letter: DriveLetter) -> bool { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn wrong_password_fails_without_prompting() { - let share = SmbShare::builder(share_name()) - .credentials(username(), "definitely-the-wrong-password-1") - .build() - .unwrap(); + let share = + SmbShare::new(share_name()).credentials(username(), "definitely-the-wrong-password-1"); let result = share.connect(); assert!( matches!( @@ -60,10 +58,8 @@ fn wrong_password_fails_without_prompting() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn nonexistent_share_fails() { - let share = SmbShare::builder(r"\\thisisnotashare.local\Share-Name") - .credentials(username(), password()) - .build() - .unwrap(); + let share = + SmbShare::new(r"\\thisisnotashare.local\Share-Name").credentials(username(), password()); let result = share.connect(); // The documented WNetAddConnection2W error list is not exhaustive // ("Other: use FormatMessage"): unresolvable hosts commonly surface as From eb40c17cb746660988defac01b51d5a7ad3d84e7 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 13:39:49 +0000 Subject: [PATCH 30/42] refactor: rename SMB share to target --- CHANGELOG.md | 8 +-- CONTEXT.md | 17 +++++ README.md | 20 +++--- src/error.rs | 8 +-- src/lib.rs | 4 +- src/options.rs | 8 +-- src/{share.rs => target.rs} | 83 +++++++++++------------ tests/integration.rs | 129 ++++++++++++++++++------------------ 8 files changed, 149 insertions(+), 128 deletions(-) create mode 100644 CONTEXT.md rename src/{share.rs => target.rs} (87%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed55b9f..dd15781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ migration table in the README. - Authenticate as the logged-on user by omitting credentials (0.1 always passed non-null credentials, making SSO unreachable). -- Fluent `SmbShare` configuration with `credentials`, `username`, `password`, +- Fluent `SmbTarget` configuration with `credentials`, `username`, `password`, `mount_on` (type-safe `DriveLetter`), `local_device`, `resource_type` (disk/printer), and `provider`. - `ConnectOptions` covering every documented `CONNECT_*` flag: `persist`, @@ -30,13 +30,13 @@ migration table in the README. `current_media`, `save_credentials`, `reset_credentials`, `require_integrity` (SMB signing), `require_privacy` (SMB encryption), and `write_through`. -- `SmbShare::connect_auto`: let Windows pick a free drive letter +- `SmbTarget::connect_auto`: let Windows pick a free drive letter (`WNetUseConnectionW`), returning the assigned name. -- `SmbShare::connect_guarded` / `connect_auto_guarded`: RAII `Connection` +- `SmbTarget::connect_guarded` / `connect_auto_guarded`: RAII `Connection` guard that disconnects on drop, with `leak()` and explicit `disconnect()`. A guard always owns a redirected local device and cancels exactly that device, so dropping it can never tear down a connection it did not create. - Deviceless shares are rejected with `InvalidParameter` (Windows does not + Deviceless targets are rejected with `InvalidParameter` (Windows does not reference-count deviceless connections, so no guard can own one); see the `Connection` docs. - `cancel_connection`: disconnect any connection by device or remote name. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f5107d2 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,17 @@ +# SMB Resource Management + +This context describes connecting to SMB resources and inspecting shares hosted by SMB servers. + +## Language + +**SMB target**: +A reusable description of a remote SMB resource, its credentials, and its desired local redirection. It exists independently of whether a connection is active. +_Avoid_: SMB share, connection + +**Connection**: +An active, owned local-device redirection to an SMB target. Its lifetime determines when that redirection is disconnected. +_Avoid_: SMB target, share + +**Server share info**: +A snapshot describing an SMB share hosted by a server, including its type and available administrative details. +_Avoid_: SMB target, connection diff --git a/README.md b/README.md index 7a42a19..721ff26 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,18 @@ sambrs = "0.2" ## Usage -Instantiate an `SmbShare` and establish a connection. Once connected (mounted +Configure an `SmbTarget` and establish a connection. Once connected (mounted or deviceless), `std::fs` works on it like on any local path: ```no_run -use sambrs::{ConnectOptions, DriveLetter, SmbShare}; +use sambrs::{ConnectOptions, DriveLetter, SmbTarget}; fn main() -> Result<(), Box> { - let share = SmbShare::new(r"\\server.local\share") + let target = SmbTarget::new(r"\\server.local\share") .credentials(r"LOGONDOMAIN\user", "pass") .mount_on(DriveLetter::D); - share.connect_with( + target.connect_with( ConnectOptions::new() .persist(true) // restore the mapping at logon .require_privacy(true), // enforce SMB encryption @@ -57,7 +57,7 @@ fn main() -> Result<(), Box> { // use std::fs as if D:\ was a local directory println!("{}", std::fs::metadata(r"D:\")?.is_dir()); - share.disconnect()?; + target.disconnect()?; Ok(()) } ``` @@ -66,8 +66,8 @@ Without credentials, the connection authenticates as the logged-on user: ```no_run # fn main() -> Result<(), sambrs::Error> { -let share = sambrs::SmbShare::new(r"\\server.local\share"); -share.connect()?; +let target = sambrs::SmbTarget::new(r"\\server.local\share"); +target.connect()?; # Ok(()) # } ``` @@ -130,9 +130,9 @@ when running from a packaged copy of the crate. | 0.1 | 0.2 | | --- | --- | -| `SmbShare::new(share, user, pass, Some('d'))` | `SmbShare::new(share).credentials(user, pass).mount_on(DriveLetter::D)` | -| `share.connect(persist, interactive)` | `share.connect_with(ConnectOptions::new().persist(persist).interactive(interactive))` | -| `share.disconnect(persist, force)` | `share.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` — note the rename: the old `persist: true` *removed* the persistence | +| `SmbShare::new(share, user, pass, Some('d'))` | `SmbTarget::new(share).credentials(user, pass).mount_on(DriveLetter::D)` | +| `share.connect(persist, interactive)` | `target.connect_with(ConnectOptions::new().persist(persist).interactive(interactive))` | +| `share.disconnect(persist, force)` | `target.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` — note the rename: the old `persist: true` *removed* the persistence | | `Error::CStringConversion` | `Error::InteriorNul` | Under the hood, 0.1 used the ANSI (`A`) API variants, which silently mangled diff --git a/src/error.rs b/src/error.rs index 6843d4b..6b85880 100644 --- a/src/error.rs +++ b/src/error.rs @@ -89,8 +89,8 @@ pub enum Error { /// Also synthesized without a Windows call by /// [`server::delete_session`](crate::server::delete_session) when neither /// a client nor a user filter is given, and by - /// [`SmbShare::connect_guarded`](crate::SmbShare::connect_guarded) for a - /// share without a local device. + /// [`SmbTarget::connect_guarded`](crate::SmbTarget::connect_guarded) for a + /// target without a local device. #[error("a parameter is incorrect")] InvalidParameter, /// The specified password is invalid (and, for connects, the interactive @@ -181,7 +181,7 @@ pub enum Error { /// for a retry that sambrs refuses to make: a buffer-size retry that /// never fits, an enumeration batch that delivers no entries (either /// retry would loop forever), or an access-name buffer in - /// [`connect_auto`](crate::SmbShare::connect_auto) reported as too small + /// [`connect_auto`](crate::SmbTarget::connect_auto) reported as too small /// even though it fits any real access name (that retry could establish /// a second connection). #[error("Windows error {code}: {msg}", code = .0, msg = os_message(*.0))] @@ -301,7 +301,7 @@ impl Error { /// usually comes from a failed Windows API call, but sambrs synthesizes /// [`Error::InvalidParameter`] for some rejected inputs (see /// [`server::delete_session`](crate::server::delete_session) and - /// [`SmbShare::connect_guarded`](crate::SmbShare::connect_guarded)); it + /// [`SmbTarget::connect_guarded`](crate::SmbTarget::connect_guarded)); it /// still reports the matching `ERROR_INVALID_PARAMETER`. For /// [`Error::ExtendedError`] this is `ERROR_EXTENDED_ERROR` (1208); the /// provider-specific code is in the variant itself. diff --git a/src/lib.rs b/src/lib.rs index 8abea2f..7e60dcc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,8 @@ mod error; mod options; -mod share; mod strings; +mod target; mod trace; pub mod enumerate; @@ -14,4 +14,4 @@ pub mod server; pub use error::{Error, Result}; pub use options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; -pub use share::{Connection, SmbShare, cancel_connection}; +pub use target::{Connection, SmbTarget, cancel_connection}; diff --git a/src/options.rs b/src/options.rs index 5122d05..730054b 100644 --- a/src/options.rs +++ b/src/options.rs @@ -73,17 +73,17 @@ pub enum ResourceType { /// Only valid for deviceless connections: Windows rejects it with /// [`Error::InvalidParameter`] when a local device is redirected — /// whether configured explicitly or chosen automatically by - /// [`SmbShare::connect_auto`](crate::SmbShare::connect_auto). + /// [`SmbTarget::connect_auto`](crate::SmbTarget::connect_auto). Any = WNet::RESOURCETYPE_ANY, } -/// Options for [`SmbShare::connect_with`](crate::SmbShare::connect_with), +/// Options for [`SmbTarget::connect_with`](crate::SmbTarget::connect_with), /// covering every documented `CONNECT_*` flag of `WNetAddConnection2W` / /// `WNetUseConnectionW`. /// /// The default (`ConnectOptions::new()`) is a temporary, non-interactive /// connection — the same behavior as -/// [`SmbShare::connect`](crate::SmbShare::connect). +/// [`SmbTarget::connect`](crate::SmbTarget::connect). /// /// ``` /// use sambrs::ConnectOptions; @@ -221,7 +221,7 @@ impl ConnectOptions { } } -/// Options for [`SmbShare::disconnect_with`](crate::SmbShare::disconnect_with) +/// Options for [`SmbTarget::disconnect_with`](crate::SmbTarget::disconnect_with) /// and [`cancel_connection`](crate::cancel_connection). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[must_use] diff --git a/src/share.rs b/src/target.rs similarity index 87% rename from src/share.rs rename to src/target.rs index 275462a..6e74f77 100644 --- a/src/share.rs +++ b/src/target.rs @@ -5,7 +5,7 @@ use crate::trace::{debug, trace}; use windows_sys::Win32::NetworkManagement::WNet; /// The wide-string buffers and resource type for one connect call, converted -/// from an [`SmbShare`]. +/// from an [`SmbTarget`]. /// /// Owning them in one struct pins down the borrow discipline: the struct must /// outlive the FFI call because every pointer passed to it borrows from the @@ -20,15 +20,15 @@ struct ConnectArgs { } impl ConnectArgs { - fn new(share: &SmbShare) -> Result { - let password = share.password.as_deref().map(to_wide).transpose()?; + fn new(target: &SmbTarget) -> Result { + let password = target.password.as_deref().map(to_wide).transpose()?; Ok(Self { - remote: to_wide(&share.remote)?, - local: share.local.as_deref().map(to_wide).transpose()?, - provider: share.provider.as_deref().map(to_wide).transpose()?, - username: share.username.as_deref().map(to_wide).transpose()?, + remote: to_wide(&target.remote)?, + local: target.local.as_deref().map(to_wide).transpose()?, + provider: target.provider.as_deref().map(to_wide).transpose()?, + username: target.username.as_deref().map(to_wide).transpose()?, password: password.map(WideSecret::from), - resource_type: share.resource_type, + resource_type: target.resource_type, }) } @@ -49,25 +49,26 @@ impl ConnectArgs { } } -/// A remote SMB share, optionally redirected to a local device. +/// A reusable target for SMB connections, optionally redirected to a local +/// device. /// -/// Construct one with [`SmbShare::new`], configure it with the fluent setters, +/// Construct one with [`SmbTarget::new`], configure it with the fluent setters, /// then establish the connection with one of the `connect*` methods. /// /// ```no_run -/// use sambrs::{DriveLetter, SmbShare}; +/// use sambrs::{DriveLetter, SmbTarget}; /// -/// let share = SmbShare::new(r"\\server\share") +/// let target = SmbTarget::new(r"\\server\share") /// .credentials("user", "pass") /// .mount_on(DriveLetter::D); /// -/// share.connect()?; +/// target.connect()?; /// // use std::fs as if D:\ was a local directory /// assert!(std::fs::metadata(r"D:\")?.is_dir()); -/// share.disconnect()?; +/// target.disconnect()?; /// # Ok::<(), Box>(()) /// ``` -pub struct SmbShare { +pub struct SmbTarget { remote: String, username: Option, password: Option, @@ -77,9 +78,9 @@ pub struct SmbShare { } // Deliberately manual: must never leak the password. -impl std::fmt::Debug for SmbShare { +impl std::fmt::Debug for SmbTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SmbShare") + f.debug_struct("SmbTarget") .field("remote", &self.remote) .field("username", &self.username) .field("password", &self.password.as_ref().map(|_| "")) @@ -91,16 +92,16 @@ impl std::fmt::Debug for SmbShare { } #[cfg(feature = "zeroize")] -impl Drop for SmbShare { +impl Drop for SmbTarget { fn drop(&mut self) { use zeroize::Zeroize; self.password.zeroize(); } } -impl SmbShare { - /// A deviceless connection to `remote` (e.g. `\\server\share`) using the - /// credentials of the currently logged-on user. +impl SmbTarget { + /// An SMB target at `remote` (e.g. `\\server\share`), initially configured + /// for a deviceless connection using the logged-on user's credentials. pub fn new(remote: impl Into) -> Self { Self { remote: remote.into(), @@ -144,7 +145,7 @@ impl SmbShare { self } - /// Redirect the share to a local drive letter. + /// Redirect this target to a local drive letter. #[must_use] pub fn mount_on(mut self, letter: DriveLetter) -> Self { self.local = Some(letter.to_string()); @@ -232,13 +233,13 @@ impl SmbShare { /// `WNetUseConnectionW` with `CONNECT_REDIRECT`. /// /// Returns the name through which the share is accessible — the assigned - /// device (e.g. `"Z:"`), or the local device configured on this share if + /// device (e.g. `"Z:"`), or the local device configured on this target if /// one was set. Pass the returned name to /// [`cancel_connection`] to disconnect, or use /// [`connect_auto_guarded`](Self::connect_auto_guarded) to have that /// happen automatically. /// - /// The share's resource type must be [`ResourceType::Disk`] or + /// The target's resource type must be [`ResourceType::Disk`] or /// [`ResourceType::Print`]: Windows rejects `RESOURCETYPE_ANY` with /// [`Error::InvalidParameter`] when it chooses the device itself. /// @@ -292,13 +293,13 @@ impl SmbShare { /// such handle: Windows does not reference-count connections, and /// canceling by remote name tears down **every** deviceless connection /// to the resource in this logon session, including ones the guard never - /// made — so deviceless shares are rejected here. Use + /// made — so deviceless targets are rejected here. Use /// [`connect_auto_guarded`](Self::connect_auto_guarded) to have Windows /// pick the device instead. /// /// # Errors /// [`Error::InvalidParameter`] (synthesized without a Windows call) when - /// this share has no local device; otherwise see [`Error`]. + /// this target has no local device; otherwise see [`Error`]. pub fn connect_guarded(&self, options: ConnectOptions) -> Result { let Some(device) = self.local.as_deref() else { return Err(Error::InvalidParameter); @@ -314,7 +315,7 @@ impl SmbShare { /// [`connect_auto`](Self::connect_auto) with an RAII [`Connection`] /// guard: Windows picks a free local device, and the guard cancels /// exactly that device when dropped. [`Connection::device`] tells you - /// where the share is mounted. + /// where the target is mounted. /// /// # Errors /// See [`connect_auto`](Self::connect_auto). @@ -328,7 +329,7 @@ impl SmbShare { /// Disconnect with default options: non-forced, keeping any persistence. /// - /// Disconnects by local device name when this share has one, otherwise by + /// Disconnects by local device name when this target has one, otherwise by /// remote name. Disconnecting by remote name cancels **all** deviceless /// connections to that resource in this logon session — Windows does not /// reference-count them, so this undoes every [`connect`](Self::connect) @@ -356,7 +357,7 @@ impl SmbShare { /// /// This is the direct wrapper around `WNetCancelConnection2W`; use it to /// disconnect names returned by -/// [`SmbShare::connect_auto`] or found via [`crate::enumerate`]. +/// [`SmbTarget::connect_auto`] or found via [`crate::enumerate`]. /// /// A local device name cancels only that redirection; a remote name cancels /// **all** deviceless connections to that resource in this logon session @@ -375,8 +376,8 @@ pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { check_wnet(status) } -/// RAII guard returned by [`SmbShare::connect_guarded`] and -/// [`SmbShare::connect_auto_guarded`]: cancels the connection when dropped +/// RAII guard returned by [`SmbTarget::connect_guarded`] and +/// [`SmbTarget::connect_auto_guarded`]: cancels the connection when dropped /// (best effort — a failure on drop is only visible as a `tracing` event, /// with the `tracing` feature enabled). /// @@ -391,7 +392,7 @@ pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { /// Use [`Connection::disconnect`] for explicit error handling, or /// [`Connection::leak`] to keep the connection open past the guard. #[derive(Debug)] -#[must_use = "dropping the guard disconnects the share immediately"] +#[must_use = "dropping the guard disconnects the connection immediately"] pub struct Connection { /// The redirected local device this guard exclusively owns. device: String, @@ -399,7 +400,7 @@ pub struct Connection { } impl Connection { - /// The local device this guard owns (e.g. `"Z:"`) — the share is + /// The local device this guard owns (e.g. `"Z:"`) — the target is /// accessible through it for as long as the guard lives. #[must_use] pub fn device(&self) -> &str { @@ -436,15 +437,15 @@ mod tests { use super::*; #[test] - fn configures_share_fluently() { - let share = SmbShare::new(r"\\server\share") + fn configures_target_fluently() { + let target = SmbTarget::new(r"\\server\share") .credentials("user", "pass") .mount_on(DriveLetter::D) .provider("provider"); - assert_eq!(share.username.as_deref(), Some("user")); - assert_eq!(share.password.as_deref(), Some("pass")); - assert_eq!(share.local.as_deref(), Some("D:")); - assert_eq!(share.provider.as_deref(), Some("provider")); + assert_eq!(target.username.as_deref(), Some("user")); + assert_eq!(target.password.as_deref(), Some("pass")); + assert_eq!(target.local.as_deref(), Some("D:")); + assert_eq!(target.provider.as_deref(), Some("provider")); } #[test] @@ -452,9 +453,9 @@ mod tests { // Rejected before any Windows call: without a local device there is // nothing a guard can exclusively own, and canceling by remote name // would tear down deviceless connections the guard never made. - let share = SmbShare::new(r"\\server\share"); + let target = SmbTarget::new(r"\\server\share"); assert_eq!( - share.connect_guarded(ConnectOptions::new()).unwrap_err(), + target.connect_guarded(ConnectOptions::new()).unwrap_err(), Error::InvalidParameter ); } diff --git a/tests/integration.rs b/tests/integration.rs index 8dbfe0f..e4de42c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,7 +2,7 @@ #![cfg(windows)] use sambrs::{ - ConnectOptions, DisconnectOptions, DriveLetter, Error, SmbShare, cancel_connection, enumerate, + ConnectOptions, DisconnectOptions, DriveLetter, Error, SmbTarget, cancel_connection, enumerate, query, server, }; @@ -24,11 +24,11 @@ fn local_admin() -> bool { std::env::var("SAMBRS_TEST_LOCAL").is_ok_and(|v| v == "1") } -fn share(mount: Option) -> SmbShare { - let share = SmbShare::new(share_name()).credentials(username(), password()); +fn target(mount: Option) -> SmbTarget { + let target = SmbTarget::new(share_name()).credentials(username(), password()); match mount { - Some(letter) => share.mount_on(letter), - None => share, + Some(letter) => target.mount_on(letter), + None => target, } } @@ -43,9 +43,9 @@ fn drive_exists(letter: DriveLetter) -> bool { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn wrong_password_fails_without_prompting() { - let share = - SmbShare::new(share_name()).credentials(username(), "definitely-the-wrong-password-1"); - let result = share.connect(); + let target = + SmbTarget::new(share_name()).credentials(username(), "definitely-the-wrong-password-1"); + let result = target.connect(); assert!( matches!( result, @@ -58,9 +58,9 @@ fn wrong_password_fails_without_prompting() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn nonexistent_share_fails() { - let share = - SmbShare::new(r"\\thisisnotashare.local\Share-Name").credentials(username(), password()); - let result = share.connect(); + let target = + SmbTarget::new(r"\\thisisnotashare.local\Share-Name").credentials(username(), password()); + let result = target.connect(); // The documented WNetAddConnection2W error list is not exhaustive // ("Other: use FormatMessage"): unresolvable hosts commonly surface as // ERROR_BAD_NETPATH (53) or ERROR_SEM_TIMEOUT (121), which have no @@ -80,38 +80,38 @@ fn nonexistent_share_fails() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn deviceless_connect_and_reconnect_works() { - let share = share(None); - share.connect().unwrap(); + let target = target(None); + target.connect().unwrap(); // Reconnecting a deviceless connection is fine. - share.connect().unwrap(); + target.connect().unwrap(); assert!(std::path::Path::new(&share_name()).is_dir()); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn mount_on_drive_letter_works_and_does_not_persist() { - let share = share(Some(DriveLetter::S)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::S)); + target.connect().unwrap(); assert!(drive_exists(DriveLetter::S)); - share.disconnect().unwrap(); + target.disconnect().unwrap(); assert!(!drive_exists(DriveLetter::S)); } #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn mounted_reconnect_fails_with_already_assigned() { - let share = share(Some(DriveLetter::S)); - share.connect().unwrap(); - assert_eq!(share.connect(), Err(Error::AlreadyAssigned)); - share.disconnect().unwrap(); + let target = target(Some(DriveLetter::S)); + target.connect().unwrap(); + assert_eq!(target.connect(), Err(Error::AlreadyAssigned)); + target.disconnect().unwrap(); } #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn two_letters_to_the_same_share_work() { - let one = share(Some(DriveLetter::S)); - let two = share(Some(DriveLetter::T)); + let one = target(Some(DriveLetter::S)); + let two = target(Some(DriveLetter::T)); one.connect().unwrap(); two.connect().unwrap(); assert!(drive_exists(DriveLetter::S)); @@ -125,21 +125,21 @@ fn two_letters_to_the_same_share_work() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn force_disconnect_with_open_file_works() { - let share = share(Some(DriveLetter::U)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::U)); + target.connect().unwrap(); let file = std::fs::File::create(r"U:\sambrs-force-disconnect.txt").unwrap(); // Non-forced disconnect must refuse while a file is open. - assert_eq!(share.disconnect(), Err(Error::OpenFiles)); - share + assert_eq!(target.disconnect(), Err(Error::OpenFiles)); + target .disconnect_with(DisconnectOptions::new().force(true)) .unwrap(); drop(file); assert!(!drive_exists(DriveLetter::U)); // Clean up the file via a fresh connection. - let share = self::share(Some(DriveLetter::U)); - share.connect().unwrap(); + let target = self::target(Some(DriveLetter::U)); + target.connect().unwrap(); let _ = std::fs::remove_file(r"U:\sambrs-force-disconnect.txt"); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } // ── RAII guard ────────────────────────────────────────────────────────────── @@ -147,9 +147,9 @@ fn force_disconnect_with_open_file_works() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn guard_disconnects_on_drop() { - let share = share(Some(DriveLetter::V)); + let target = target(Some(DriveLetter::V)); { - let _guard = share.connect_guarded(ConnectOptions::new()).unwrap(); + let _guard = target.connect_guarded(ConnectOptions::new()).unwrap(); assert!(drive_exists(DriveLetter::V)); } assert!(!drive_exists(DriveLetter::V)); @@ -158,10 +158,13 @@ fn guard_disconnects_on_drop() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn guard_leak_keeps_the_connection() { - let share = share(Some(DriveLetter::V)); - share.connect_guarded(ConnectOptions::new()).unwrap().leak(); + let target = target(Some(DriveLetter::V)); + target + .connect_guarded(ConnectOptions::new()) + .unwrap() + .leak(); assert!(drive_exists(DriveLetter::V)); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } // The ownership property behind the guard design: a guard cancels only the @@ -170,9 +173,9 @@ fn guard_leak_keeps_the_connection() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn guard_drop_leaves_other_connections_alone() { - let deviceless = share(None); + let deviceless = target(None); deviceless.connect().unwrap(); - let mounted = share(Some(DriveLetter::V)); + let mounted = target(Some(DriveLetter::V)); { let _guard = mounted.connect_guarded(ConnectOptions::new()).unwrap(); assert!(drive_exists(DriveLetter::V)); @@ -188,8 +191,8 @@ fn guard_drop_leaves_other_connections_alone() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn connect_auto_assigns_a_device() { - let share = share(None); - let access_name = share.connect_auto(ConnectOptions::new()).unwrap(); + let target = target(None); + let access_name = target.connect_auto(ConnectOptions::new()).unwrap(); assert!( access_name.ends_with(':'), "expected a device name, got {access_name:?}" @@ -201,10 +204,10 @@ fn connect_auto_assigns_a_device() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn connect_auto_guarded_owns_the_assigned_device() { - let share = share(None); + let target = target(None); let device; { - let guard = share.connect_auto_guarded(ConnectOptions::new()).unwrap(); + let guard = target.connect_auto_guarded(ConnectOptions::new()).unwrap(); device = guard.device().to_string(); assert!( device.ends_with(':'), @@ -220,32 +223,32 @@ fn connect_auto_guarded_owns_the_assigned_device() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn get_connection_returns_the_remote_name() { - let share = share(Some(DriveLetter::W)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::W)); + target.connect().unwrap(); let remote = query::get_connection("W:").unwrap(); assert!( remote.eq_ignore_ascii_case(&share_name()), "{remote} != {}", share_name() ); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn get_user_returns_a_user() { - let share = share(Some(DriveLetter::W)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::W)); + target.connect().unwrap(); let user = query::get_user(Some("W:")).unwrap(); assert!(!user.is_empty()); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn get_universal_name_resolves_a_mounted_path() { - let share = share(Some(DriveLetter::W)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::W)); + target.connect().unwrap(); let unc = query::get_universal_name(r"W:\").unwrap(); assert!( unc.to_ascii_lowercase() @@ -253,7 +256,7 @@ fn get_universal_name_resolves_a_mounted_path() { "{unc} does not start with {}", share_name() ); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } // ── enumerate ─────────────────────────────────────────────────────────────── @@ -261,8 +264,8 @@ fn get_universal_name_resolves_a_mounted_path() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn enumerate_connections_contains_the_share() { - let share = share(Some(DriveLetter::X)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::X)); + target.connect().unwrap(); let found = enumerate::connections() .unwrap() .filter_map(Result::ok) @@ -270,7 +273,7 @@ fn enumerate_connections_contains_the_share() { r.remote_name .is_some_and(|n| n.eq_ignore_ascii_case(&share_name())) }); - share.disconnect().unwrap(); + target.disconnect().unwrap(); assert!(found, "active connection to the test share not enumerated"); } @@ -282,13 +285,13 @@ fn enumerate_server_shares_contains_the_share() { let server = full.trim_start_matches('\\').split('\\').next().unwrap(); let server_root = format!(r"\\{server}"); // Authenticate first: servers may refuse anonymous enumeration. - let share = share(None); - share.connect().unwrap(); + let target = target(None); + target.connect().unwrap(); let found = enumerate::server_shares(&server_root) .unwrap() .filter_map(Result::ok) .any(|r| r.remote_name.is_some_and(|n| n.eq_ignore_ascii_case(&full))); - share.disconnect().unwrap(); + target.disconnect().unwrap(); assert!(found, "test share not found in {server_root}'s share list"); } @@ -347,8 +350,8 @@ fn server_sessions_and_connections_are_listable() { return; } let leaf = share_name().rsplit('\\').next().unwrap().to_string(); - let share = share(None); - share.connect().unwrap(); + let target = target(None); + target.connect().unwrap(); // Establishing the connection above means at least one session and one // connection must be visible. @@ -358,7 +361,7 @@ fn server_sessions_and_connections_are_listable() { let connections = server::connections(None, &leaf).unwrap(); assert!(!connections.is_empty(), "no connections to {leaf} listed"); - share.disconnect().unwrap(); + target.disconnect().unwrap(); } #[test] @@ -368,8 +371,8 @@ fn server_open_files_are_listable_and_closable() { eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); return; } - let share = share(Some(DriveLetter::Y)); - share.connect().unwrap(); + let target = target(Some(DriveLetter::Y)); + target.connect().unwrap(); let path = r"Y:\sambrs-open-file.txt"; let file = std::fs::File::create(path).unwrap(); @@ -382,7 +385,7 @@ fn server_open_files_are_listable_and_closable() { drop(file); let _ = std::fs::remove_file(path); - share + target .disconnect_with(DisconnectOptions::new().force(true)) .unwrap(); } From 262e18e0769717fbd3d2416caecab4dbcb0e69f2 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 14:16:44 +0000 Subject: [PATCH 31/42] test: remove redundant mounted-drive smoke test --- tests/integration.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index e4de42c..60df2ee 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -88,16 +88,6 @@ fn deviceless_connect_and_reconnect_works() { target.disconnect().unwrap(); } -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn mount_on_drive_letter_works_and_does_not_persist() { - let target = target(Some(DriveLetter::S)); - target.connect().unwrap(); - assert!(drive_exists(DriveLetter::S)); - target.disconnect().unwrap(); - assert!(!drive_exists(DriveLetter::S)); -} - #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn mounted_reconnect_fails_with_already_assigned() { From 11a966f5d65e32ae4b92a846df2ca4ce8d57f873 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 14:16:50 +0000 Subject: [PATCH 32/42] test: remove redundant guard-drop smoke test --- tests/integration.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 60df2ee..ced54b6 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -134,17 +134,6 @@ fn force_disconnect_with_open_file_works() { // ── RAII guard ────────────────────────────────────────────────────────────── -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn guard_disconnects_on_drop() { - let target = target(Some(DriveLetter::V)); - { - let _guard = target.connect_guarded(ConnectOptions::new()).unwrap(); - assert!(drive_exists(DriveLetter::V)); - } - assert!(!drive_exists(DriveLetter::V)); -} - #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn guard_leak_keeps_the_connection() { From 0f0cf8f3ebee02df45faf2469c1cd028b7ff47c7 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 15:20:12 +0000 Subject: [PATCH 33/42] refactor: collapse Windows error variants --- CHANGELOG.md | 20 +-- Cargo.lock | 21 --- Cargo.toml | 1 - src/enumerate.rs | 6 +- src/error.rs | 326 +++++++------------------------------------ src/options.rs | 8 +- src/query.rs | 16 +-- src/server.rs | 57 ++++---- src/target.rs | 13 +- tests/integration.rs | 44 +++--- 10 files changed, 137 insertions(+), 375 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd15781..ba07865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ migration table in the README. - **Unicode correctness**: all calls now use the wide (`W`) API variants with UTF-16 strings. 0.1 used the ANSI variants, which mangled non-ASCII share names, user names, and passwords (typically surfacing as spurious - `LogonFailure`). + `ERROR_LOGON_FAILURE`). - `ERROR_EXTENDED_ERROR` no longer swallows the real error: the provider-specific code and message are fetched via `WNetGetLastErrorW` and carried in `Error::ExtendedError`. @@ -36,7 +36,7 @@ migration table in the README. guard that disconnects on drop, with `leak()` and explicit `disconnect()`. A guard always owns a redirected local device and cancels exactly that device, so dropping it can never tear down a connection it did not create. - Deviceless targets are rejected with `InvalidParameter` (Windows does not + Deviceless targets are rejected with `ERROR_INVALID_PARAMETER` (Windows does not reference-count deviceless connections, so no guard can own one); see the `Connection` docs. - `cancel_connection`: disconnect any connection by device or remote name. @@ -47,10 +47,10 @@ migration table in the README. `delete_share`, `sessions`, `delete_session`, `open_files`, `close_file`, `connections` — with automatic fallback to lower information levels when not administrator. `delete_session` requires a non-empty client and/or - user filter (`InvalidParameter` otherwise — netapi32 treats an empty + user filter (`ERROR_INVALID_PARAMETER` otherwise — netapi32 treats an empty string as no filter); ending every session on a server is the separate, explicit `delete_all_sessions`. -- `Error::Other` resolves `NERR_*` codes (2100–2999) through netmsg.dll, so +- `Error::Windows` resolves `NERR_*` codes (2100–2999) through netmsg.dll, so undocumented netapi32 errors display their real message instead of an unknown-error placeholder. - `Error::raw_os_error` and `From for std::io::Error`. Converting an @@ -59,7 +59,7 @@ migration table in the README. into the generic `ERROR_EXTENDED_ERROR` (1208) message. - Cargo features: `tracing` (now optional!) and `zeroize` (wipe password buffers on drop). -- The `server` enumeration loop fails with `Error::Other(ERROR_MORE_DATA)` +- The `server` enumeration loop fails with `Error::Windows(ERROR_MORE_DATA)` instead of spinning forever when a malformed server keeps reporting `ERROR_MORE_DATA` without delivering entries or terminating. Similarly, `connect_auto` sizes its access-name buffer to fit any real access name up @@ -69,10 +69,12 @@ migration table in the README. ### Changed -- `Error` is `#[non_exhaustive]` and gained variants for the query/server - APIs; `Error::CStringConversion` is now `Error::InteriorNul`. -- `windows-sys` 0.52 → 0.60, `thiserror` 1 → 2; dependencies are declared - Windows-only, and the crate compiles to nothing on other targets. +- `Error` is `#[non_exhaustive]`; Windows and `NERR_*` statuses are exposed as + `Error::Windows(code)`, while input-validation and provider-specific errors + retain dedicated variants. `Error::CStringConversion` is now + `Error::InteriorNul`. +- `windows-sys` 0.52 → 0.60; dependencies are declared Windows-only, and the + crate compiles to nothing on other targets. - docs.rs builds Windows targets; MSRV pinned at 1.85. ## 0.1.2 diff --git a/Cargo.lock b/Cargo.lock index 29c8d97..09ece82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,7 +36,6 @@ dependencies = [ name = "sambrs" version = "0.2.0" dependencies = [ - "thiserror", "tracing", "windows-sys", "zeroize", @@ -53,26 +52,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing" version = "0.1.40" diff --git a/Cargo.toml b/Cargo.toml index 7d69728..34e3637 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ tracing = ["dep:tracing"] zeroize = ["dep:zeroize"] [target.'cfg(windows)'.dependencies] -thiserror = "2" tracing = { version = "0.1", optional = true } zeroize = { version = "1", optional = true } diff --git a/src/enumerate.rs b/src/enumerate.rs index 099afbc..7b65a14 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -164,7 +164,7 @@ impl Resources { code => return Err(Error::from_status(code)), } } - Err(Error::Other(ERROR_MORE_DATA)) + Err(Error::Windows(ERROR_MORE_DATA)) } } @@ -243,8 +243,8 @@ pub fn remembered() -> Result { /// before it lets you enumerate. /// /// # Errors -/// [`Error::BadNetName`] / [`Error::NoNetOrBadPath`] when the server cannot -/// be found, [`Error::AccessDenied`] when it refuses anonymous enumeration. +/// `ERROR_BAD_NET_NAME` / `ERROR_NO_NET_OR_BAD_PATH` when the server cannot +/// be found, or `ERROR_ACCESS_DENIED` when it refuses anonymous enumeration. pub fn server_shares(server: &str) -> Result { open( WNet::RESOURCE_GLOBALNET, diff --git a/src/error.rs b/src/error.rs index 6b85880..e7f308c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,193 +1,51 @@ -use thiserror::Error; -use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_ALREADY_ASSIGNED, ERROR_BAD_DEV_TYPE, ERROR_BAD_DEVICE, - ERROR_BAD_NET_NAME, ERROR_BAD_PROFILE, ERROR_BAD_PROVIDER, ERROR_BAD_USERNAME, ERROR_BUSY, - ERROR_CANCELLED, ERROR_CANNOT_OPEN_PROFILE, ERROR_CONNECTION_UNAVAIL, ERROR_DEV_NOT_EXIST, - ERROR_DEVICE_ALREADY_REMEMBERED, ERROR_DEVICE_IN_USE, ERROR_EXTENDED_ERROR, - ERROR_INVALID_ADDRESS, ERROR_INVALID_LEVEL, ERROR_INVALID_PARAMETER, ERROR_INVALID_PASSWORD, - ERROR_LOGON_FAILURE, ERROR_NO_NET_OR_BAD_PATH, ERROR_NO_NETWORK, ERROR_NOT_CONNECTED, - ERROR_NOT_ENOUGH_MEMORY, ERROR_NOT_SUPPORTED, ERROR_OPEN_FILES, - ERROR_SESSION_CREDENTIAL_CONFLICT, NO_ERROR, -}; -use windows_sys::Win32::NetworkManagement::NetManagement::{ - NERR_ClientNameNotFound as NERR_CLIENT_NAME_NOT_FOUND, - NERR_DuplicateShare as NERR_DUPLICATE_SHARE, NERR_FileIdNotFound as NERR_FILE_ID_NOT_FOUND, - NERR_NetNameNotFound as NERR_NET_NAME_NOT_FOUND, NERR_Success as NERR_SUCCESS, - NERR_UnknownDevDir as NERR_UNKNOWN_DEV_DIR, NERR_UserNotFound as NERR_USER_NOT_FOUND, -}; +use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, NO_ERROR}; +use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success as NERR_SUCCESS; use windows_sys::Win32::NetworkManagement::WNet; pub type Result = std::result::Result; /// Errors returned by sambrs operations. -/// -/// Most variants map 1:1 to a documented Windows error code; -/// [`Error::raw_os_error`] returns the underlying code. The enum is -/// `#[non_exhaustive]` so new variants can be added as more of the Windows -/// API surface is wrapped. #[non_exhaustive] -#[derive(Error, Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { - // ── input validation (no OS error code) ──────────────────────────────── - #[error("input string contains an interior NUL, which cannot be passed to the Windows API")] + /// Input contains an interior NUL and cannot cross the Windows API boundary. InteriorNul, - #[error("'{0}' is not a valid drive letter (expected A-Z)")] + /// The character is not an ASCII drive letter. InvalidDriveLetter(char), - - // ── WNet connect/disconnect ──────────────────────────────────────────── - /// The caller does not have access to the network resource. - #[error("access to the network resource was denied")] - AccessDenied, - /// The local device is already connected to a network resource. - #[error("the local device is already connected to a network resource")] - AlreadyAssigned, - /// The type of the local device and the type of the network resource do - /// not match (e.g. mounting a printer share on a drive letter). - #[error("the local device type and the network resource type do not match")] - BadDevType, - /// Returned when the local device name names a device that cannot be - /// redirected to a network resource. - #[error("the specified device name is not valid")] - BadDevice, - /// Returned when no network resource provider accepts the remote name: - /// it is empty, malformed, or names a resource that cannot be located. - #[error("the network name cannot be found")] - BadNetName, - /// The user profile is in an incorrect format. - #[error("the user profile is in an incorrect format")] - BadProfile, - /// Returned when the configured provider name does not match any network - /// provider installed on the system. - #[error("the specified network provider name is not valid")] - BadProvider, - /// The specified user name is not valid. - #[error("the specified user name is not valid")] - BadUsername, - /// The router or provider is busy, possibly still initializing. The - /// caller should retry. - #[error("the router or provider is busy; retry the operation")] - Busy, - /// The connection attempt was canceled by the user through a provider - /// dialog box, or by a called resource. - #[error("the connection attempt was canceled")] - Cancelled, - /// The system is unable to open the user profile to process persistent - /// connections. - #[error("the user profile cannot be opened to process persistent connections")] - CannotOpenProfile, - /// Returned when the user profile already remembers a connection for this - /// local device name that points to a different network resource. - #[error("the local device name has a remembered connection to another network resource")] - DeviceAlreadyRemembered, - /// Returned when connecting with redirection (`CONNECT_REDIRECT`, see - /// [`ConnectOptions::redirect`](crate::ConnectOptions::redirect)) without - /// a local device name to redirect to. - #[error("an attempt was made to access an invalid address")] - InvalidAddress, - /// A parameter is incorrect — for `WNet` connections, a resource type - /// other than disk, print, or any, or an incorrect or unknown flag value. - /// Also synthesized without a Windows call by - /// [`server::delete_session`](crate::server::delete_session) when neither - /// a client nor a user filter is given, and by - /// [`SmbTarget::connect_guarded`](crate::SmbTarget::connect_guarded) for a - /// target without a local device. - #[error("a parameter is incorrect")] - InvalidParameter, - /// The specified password is invalid (and, for connects, the interactive - /// flag is not set, so Windows could not prompt for a correct one). - #[error("the specified password is invalid")] - InvalidPassword, - /// Logon failure because of an unknown user name or a bad password. - #[error("logon failed: unknown user name or bad password")] - LogonFailure, - /// No network provider accepted the given network path — none of them - /// recognized the remote name. - #[error("no network provider accepted the given network path")] - NoNetOrBadPath, - /// The network is unavailable. - #[error("the network is unavailable")] - NoNetwork, - /// Windows does not allow the same user to hold connections to one server - /// or share under more than one user name. Disconnect all previous - /// connections to the server or share and try again. - #[error("a connection to the server or share already exists under different credentials")] - SessionCredentialConflict, - /// The device is in use by an active process and cannot be disconnected. - #[error("the device is in use by an active process and cannot be disconnected")] - DeviceInUse, - /// The name is not a redirected device, or the system is not currently - /// connected to it. - #[error("the name is not a redirected device or a currently connected resource")] - NotConnected, - /// There are open files on the connection and the disconnect was not - /// forced (see - /// [`DisconnectOptions::force`](crate::DisconnectOptions::force)). - #[error("there are open files and the disconnect was not forced")] - OpenFiles, - /// A network-specific error reported by the network provider, resolved via - /// `WNetGetLastErrorW`. `code` is the provider's own error code. - #[error( - "a network-specific error occurred (code {code}): {description} [provider: {provider}]" - )] + /// A Windows or `NERR_*` status code. + Windows(u32), + /// A provider-specific error resolved through `WNetGetLastErrorW`. ExtendedError { code: u32, description: String, provider: String, }, +} - // ── introspection ────────────────────────────────────────────────────── - /// The device is not currently connected, but it is a remembered - /// (persistent) connection. - #[error("the device is not connected, but it is a remembered connection")] - ConnectionUnavailable, - /// The request is not supported for this resource. - #[error("the request is not supported for this resource")] - NotSupported, - /// The specified device does not exist. - #[error("the specified device does not exist")] - DeviceNotFound, - - // ── netapi32 share/session/file administration ───────────────────────── - /// The share name does not exist on the target server. - #[error("the share name does not exist on this server")] - NetNameNotFound, - /// The share name is already in use on the target server. - #[error("the share name is already in use on this server")] - DuplicateShare, - /// The device or directory backing the share does not exist. - #[error("the device or directory does not exist")] - UnknownDeviceOrDirectory, - /// No session exists with the given computer name. - #[error("no session exists with that computer name")] - ClientNameNotFound, - /// The user name could not be found. - #[error("the user name could not be found")] - UserNotFound, - /// No open file with the given identification number exists. - #[error("no open file with that identification number exists")] - FileIdNotFound, - /// The system call level is not correct — the requested information level - /// is not supported. - #[error("the requested information level is not supported")] - InvalidLevel, - /// Not enough memory is available to complete the operation. - #[error("not enough memory is available to complete the operation")] - NotEnoughMemory, - - /// Any status code without a dedicated variant. The message is resolved - /// through the system message table where possible. - /// - /// sambrs also surfaces `Other(ERROR_MORE_DATA)` (234) when Windows asks - /// for a retry that sambrs refuses to make: a buffer-size retry that - /// never fits, an enumeration batch that delivers no entries (either - /// retry would loop forever), or an access-name buffer in - /// [`connect_auto`](crate::SmbTarget::connect_auto) reported as too small - /// even though it fits any real access name (that retry could establish - /// a second connection). - #[error("Windows error {code}: {msg}", code = .0, msg = os_message(*.0))] - Other(u32), +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InteriorNul => f.write_str( + "input string contains an interior NUL, which cannot be passed to the Windows API", + ), + Self::InvalidDriveLetter(c) => { + write!(f, "'{c}' is not a valid drive letter (expected A-Z)") + } + Self::Windows(code) => write!(f, "Windows error {code}: {}", os_message(*code)), + Self::ExtendedError { + code, + description, + provider, + } => write!( + f, + "a network-specific error occurred (code {code}): {description} [provider: {provider}]" + ), + } + } } +impl std::error::Error for Error {} + fn os_message(code: u32) -> String { use windows_sys::Win32::NetworkManagement::NetManagement::{MAX_NERR, NERR_BASE}; @@ -250,119 +108,30 @@ fn netmsg_message(code: u32) -> Option { } impl Error { - /// Map a raw Windows / `NERR_*` status code to an [`Error`]. - /// - /// `ERROR_EXTENDED_ERROR` is intentionally *not* resolved here — `WNet` call - /// sites intercept it and fetch the provider details via - /// [`wnet_extended_error`]. pub(crate) fn from_status(code: u32) -> Self { - match code { - ERROR_ACCESS_DENIED => Self::AccessDenied, - ERROR_ALREADY_ASSIGNED => Self::AlreadyAssigned, - ERROR_BAD_DEV_TYPE => Self::BadDevType, - ERROR_BAD_DEVICE => Self::BadDevice, - ERROR_BAD_NET_NAME => Self::BadNetName, - ERROR_BAD_PROFILE => Self::BadProfile, - ERROR_BAD_PROVIDER => Self::BadProvider, - ERROR_BAD_USERNAME => Self::BadUsername, - ERROR_BUSY => Self::Busy, - ERROR_CANCELLED => Self::Cancelled, - ERROR_CANNOT_OPEN_PROFILE => Self::CannotOpenProfile, - ERROR_CONNECTION_UNAVAIL => Self::ConnectionUnavailable, - ERROR_DEV_NOT_EXIST => Self::DeviceNotFound, - ERROR_DEVICE_ALREADY_REMEMBERED => Self::DeviceAlreadyRemembered, - ERROR_DEVICE_IN_USE => Self::DeviceInUse, - ERROR_INVALID_ADDRESS => Self::InvalidAddress, - ERROR_INVALID_LEVEL => Self::InvalidLevel, - ERROR_INVALID_PARAMETER => Self::InvalidParameter, - ERROR_INVALID_PASSWORD => Self::InvalidPassword, - ERROR_LOGON_FAILURE => Self::LogonFailure, - ERROR_NOT_CONNECTED => Self::NotConnected, - ERROR_NOT_ENOUGH_MEMORY => Self::NotEnoughMemory, - ERROR_NOT_SUPPORTED => Self::NotSupported, - ERROR_NO_NET_OR_BAD_PATH => Self::NoNetOrBadPath, - ERROR_NO_NETWORK => Self::NoNetwork, - ERROR_OPEN_FILES => Self::OpenFiles, - ERROR_SESSION_CREDENTIAL_CONFLICT => Self::SessionCredentialConflict, - NERR_CLIENT_NAME_NOT_FOUND => Self::ClientNameNotFound, - NERR_DUPLICATE_SHARE => Self::DuplicateShare, - NERR_FILE_ID_NOT_FOUND => Self::FileIdNotFound, - NERR_NET_NAME_NOT_FOUND => Self::NetNameNotFound, - NERR_UNKNOWN_DEV_DIR => Self::UnknownDeviceOrDirectory, - NERR_USER_NOT_FOUND => Self::UserNotFound, - other => Self::Other(other), - } + Self::Windows(code) } - /// The Windows error code this error corresponds to. - /// - /// Returns `None` for input-validation errors that have no Windows code - /// ([`Error::InteriorNul`], [`Error::InvalidDriveLetter`]). The code - /// usually comes from a failed Windows API call, but sambrs synthesizes - /// [`Error::InvalidParameter`] for some rejected inputs (see - /// [`server::delete_session`](crate::server::delete_session) and - /// [`SmbTarget::connect_guarded`](crate::SmbTarget::connect_guarded)); it - /// still reports the matching `ERROR_INVALID_PARAMETER`. For - /// [`Error::ExtendedError`] this is `ERROR_EXTENDED_ERROR` (1208); the - /// provider-specific code is in the variant itself. + /// The underlying Windows status code, if this error has one. #[must_use] pub fn raw_os_error(&self) -> Option { match self { - Self::InteriorNul | Self::InvalidDriveLetter(_) => None, - Self::AccessDenied => Some(ERROR_ACCESS_DENIED), - Self::AlreadyAssigned => Some(ERROR_ALREADY_ASSIGNED), - Self::BadDevType => Some(ERROR_BAD_DEV_TYPE), - Self::BadDevice => Some(ERROR_BAD_DEVICE), - Self::BadNetName => Some(ERROR_BAD_NET_NAME), - Self::BadProfile => Some(ERROR_BAD_PROFILE), - Self::BadProvider => Some(ERROR_BAD_PROVIDER), - Self::BadUsername => Some(ERROR_BAD_USERNAME), - Self::Busy => Some(ERROR_BUSY), - Self::Cancelled => Some(ERROR_CANCELLED), - Self::CannotOpenProfile => Some(ERROR_CANNOT_OPEN_PROFILE), - Self::ConnectionUnavailable => Some(ERROR_CONNECTION_UNAVAIL), - Self::DeviceNotFound => Some(ERROR_DEV_NOT_EXIST), - Self::DeviceAlreadyRemembered => Some(ERROR_DEVICE_ALREADY_REMEMBERED), - Self::DeviceInUse => Some(ERROR_DEVICE_IN_USE), + Self::Windows(code) => Some(*code), Self::ExtendedError { .. } => Some(ERROR_EXTENDED_ERROR), - Self::InvalidAddress => Some(ERROR_INVALID_ADDRESS), - Self::InvalidLevel => Some(ERROR_INVALID_LEVEL), - Self::InvalidParameter => Some(ERROR_INVALID_PARAMETER), - Self::InvalidPassword => Some(ERROR_INVALID_PASSWORD), - Self::LogonFailure => Some(ERROR_LOGON_FAILURE), - Self::NotConnected => Some(ERROR_NOT_CONNECTED), - Self::NotEnoughMemory => Some(ERROR_NOT_ENOUGH_MEMORY), - Self::NotSupported => Some(ERROR_NOT_SUPPORTED), - Self::NoNetOrBadPath => Some(ERROR_NO_NET_OR_BAD_PATH), - Self::NoNetwork => Some(ERROR_NO_NETWORK), - Self::OpenFiles => Some(ERROR_OPEN_FILES), - Self::SessionCredentialConflict => Some(ERROR_SESSION_CREDENTIAL_CONFLICT), - Self::ClientNameNotFound => Some(NERR_CLIENT_NAME_NOT_FOUND), - Self::DuplicateShare => Some(NERR_DUPLICATE_SHARE), - Self::FileIdNotFound => Some(NERR_FILE_ID_NOT_FOUND), - Self::NetNameNotFound => Some(NERR_NET_NAME_NOT_FOUND), - Self::UnknownDeviceOrDirectory => Some(NERR_UNKNOWN_DEV_DIR), - Self::UserNotFound => Some(NERR_USER_NOT_FOUND), - Self::Other(code) => Some(*code), + Self::InteriorNul | Self::InvalidDriveLetter(_) => None, } } } -/// Errors with a Windows code convert via `from_raw_os_error`, keeping the -/// system message and `io::Error::raw_os_error`. The exceptions keep the -/// [`enum@Error`] itself as the payload instead: [`Error::ExtendedError`], whose -/// code would only be the generic `ERROR_EXTENDED_ERROR` (1208) — the -/// provider's own code, description, and name live in the variant — and the -/// input-validation errors, which have no Windows code at all. impl From for std::io::Error { - fn from(e: Error) -> Self { - if matches!(e, Error::ExtendedError { .. }) { - return Self::other(e); - } - match e.raw_os_error() { + fn from(error: Error) -> Self { + match error { #[allow(clippy::cast_possible_wrap)] - Some(code) => Self::from_raw_os_error(code as i32), - None => Self::new(std::io::ErrorKind::InvalidInput, e), + Error::Windows(code) => Self::from_raw_os_error(code as i32), + Error::ExtendedError { .. } => Self::other(error), + Error::InteriorNul | Error::InvalidDriveLetter(_) => { + Self::new(std::io::ErrorKind::InvalidInput, error) + } } } } @@ -420,6 +189,7 @@ pub(crate) fn wnet_extended_error() -> Error { #[cfg(test)] mod tests { use super::*; + use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; #[test] fn io_error_conversion_keeps_extended_error_details() { @@ -429,8 +199,6 @@ mod tests { provider: String::from("Test Provider"), }; let io: std::io::Error = e.into(); - // Not from_raw_os_error(1208) — the generic message would drop the - // provider details this variant exists to carry. assert_eq!(io.raw_os_error(), None); let msg = io.to_string(); assert!(msg.contains("59"), "provider code lost: {msg}"); @@ -443,7 +211,7 @@ mod tests { #[test] fn io_error_conversion_maps_windows_codes() { - let io: std::io::Error = Error::AccessDenied.into(); + let io: std::io::Error = Error::Windows(ERROR_ACCESS_DENIED).into(); #[allow(clippy::cast_possible_wrap)] { assert_eq!(io.raw_os_error(), Some(ERROR_ACCESS_DENIED as i32)); @@ -457,12 +225,12 @@ mod tests { } #[test] - fn other_error_resolves_nerr_messages_from_netmsg_dll() { + fn windows_error_resolves_nerr_messages_from_netmsg_dll() { // NERR_NetNotStarted (2102) has no system-table message; it must // come from netmsg.dll. The text is locale-dependent, so assert only - // that a message was found and that Other's Display uses it. + // that a message was found and that Error's Display uses it. let msg = netmsg_message(2102).expect("netmsg.dll must resolve NERR codes"); assert!(!msg.is_empty()); - assert!(Error::Other(2102).to_string().contains(&msg)); + assert!(Error::Windows(2102).to_string().contains(&msg)); } } diff --git a/src/options.rs b/src/options.rs index 730054b..06059ab 100644 --- a/src/options.rs +++ b/src/options.rs @@ -4,7 +4,7 @@ use windows_sys::Win32::NetworkManagement::WNet; /// A local Windows drive letter (`A:` – `Z:`). /// /// Guarantees at compile time that a mount point is a valid device letter, -/// instead of failing at connect time with a confusing [`Error::BadDevice`]. +/// instead of failing at connect time with a confusing `ERROR_BAD_DEVICE`. /// /// ``` /// use sambrs::DriveLetter; @@ -71,7 +71,7 @@ pub enum ResourceType { /// Any resource type (`RESOURCETYPE_ANY`). /// /// Only valid for deviceless connections: Windows rejects it with - /// [`Error::InvalidParameter`] when a local device is redirected — + /// `ERROR_INVALID_PARAMETER` when a local device is redirected — /// whether configured explicitly or chosen automatically by /// [`SmbTarget::connect_auto`](crate::SmbTarget::connect_auto). Any = WNet::RESOURCETYPE_ANY, @@ -147,7 +147,7 @@ impl ConnectOptions { /// `CONNECT_INTERACTIVE`: the operating system may interact with the user /// for authentication purposes, e.g. by showing a password prompt instead - /// of failing with [`Error::InvalidPassword`]. + /// of failing with `ERROR_INVALID_PASSWORD`. pub fn interactive(self, yes: bool) -> Self { self.flag(WNet::CONNECT_INTERACTIVE, yes) } @@ -237,7 +237,7 @@ impl DisconnectOptions { /// Disconnect even if there are open files or jobs on the connection. /// When `false` (the default), disconnecting fails with - /// [`Error::OpenFiles`] if anything is still open. + /// `ERROR_OPEN_FILES` if anything is still open. pub fn force(mut self, yes: bool) -> Self { self.force = yes; self diff --git a/src/query.rs b/src/query.rs index b99db13..107651b 100644 --- a/src/query.rs +++ b/src/query.rs @@ -21,7 +21,7 @@ fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { code => return Err(Error::from_status(code)), } } - Err(Error::Other(ERROR_MORE_DATA)) + Err(Error::Windows(ERROR_MORE_DATA)) } /// The remote name a redirected local device is connected to, via @@ -34,9 +34,9 @@ fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { /// ``` /// /// # Errors -/// [`Error::NotConnected`] if the device is not redirected, -/// [`Error::ConnectionUnavailable`] if it is remembered but not currently -/// connected, [`Error::BadDevice`] for invalid device names. +/// `ERROR_NOT_CONNECTED` if the device is not redirected, +/// `ERROR_CONNECTION_UNAVAIL` if it is remembered but not currently +/// connected, or `ERROR_BAD_DEVICE` for an invalid device name. pub fn get_connection(local_device: &str) -> Result { let device = to_wide(local_device)?; // SAFETY: `device` outlives the call; the buffer is sized via `len`. @@ -49,7 +49,7 @@ pub fn get_connection(local_device: &str) -> Result { /// the name of the current user of the process. /// /// # Errors -/// [`Error::NotConnected`] if the name is not a connected resource. +/// `ERROR_NOT_CONNECTED` if the name is not a connected resource. pub fn get_user(connection: Option<&str>) -> Result { let name = connection.map(to_wide).transpose()?; // SAFETY: `name` (when present) outlives the call. @@ -61,8 +61,8 @@ pub fn get_user(connection: Option<&str>) -> Result { /// `\\server\share\dir\file.txt`. /// /// # Errors -/// [`Error::NotSupported`] or [`Error::NotConnected`] when the path is not on -/// a network-redirected device. +/// `ERROR_NOT_SUPPORTED` or `ERROR_NOT_CONNECTED` when the path is not on a +/// network-redirected device. pub fn get_universal_name(local_path: &str) -> Result { let path = to_wide(local_path)?; // u64 elements keep the buffer aligned for UNIVERSAL_NAME_INFOW. @@ -95,5 +95,5 @@ pub fn get_universal_name(local_path: &str) -> Result { code => return Err(Error::from_status(code)), } } - Err(Error::Other(ERROR_MORE_DATA)) + Err(Error::Windows(ERROR_MORE_DATA)) } diff --git a/src/server.rs b/src/server.rs index 6f5497b..d4a3c0e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -24,7 +24,9 @@ use crate::error::{Error, Result, check_net}; use crate::strings::{from_pwstr, opt_ptr, to_wide}; use crate::trace::debug; use std::time::Duration; -use windows_sys::Win32::Foundation::ERROR_MORE_DATA; +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, +}; use windows_sys::Win32::NetworkManagement::NetManagement::{ MAX_PREFERRED_LENGTH, NERR_Success as NERR_SUCCESS, NetApiBufferFree, }; @@ -57,7 +59,7 @@ impl Drop for NetBuffer { /// /// The protocol contract says every `ERROR_MORE_DATA` batch delivers entries /// and advances the resume handle; a malformed or malicious server can -/// violate that, so the loop fails with `Error::Other(ERROR_MORE_DATA)` +/// violate that, so the loop fails with `Error::Windows(ERROR_MORE_DATA)` /// instead of spinning forever: immediately when a batch delivers nothing /// (the next call would repeat the identical request), and after /// `MAX_BATCHES` batches as a backstop against a resume handle that yields @@ -91,7 +93,7 @@ fn net_enum( if buf.is_null() || read == 0 { // ERROR_MORE_DATA with an empty batch: no progress was // made, so looping would repeat the identical call. - return Err(Error::Other(ERROR_MORE_DATA)); + return Err(Error::Windows(ERROR_MORE_DATA)); } // ERROR_MORE_DATA: loop again, the resume handle captured by // `call` continues where this batch ended. @@ -99,7 +101,7 @@ fn net_enum( code => return Err(Error::from_status(code)), } } - Err(Error::Other(ERROR_MORE_DATA)) + Err(Error::Windows(ERROR_MORE_DATA)) } /// Owned `String` from a nul-terminated wide pointer; `None` when the pointer @@ -234,7 +236,7 @@ impl ShareInfo { /// List the shares on a server via `NetShareEnum`. /// /// Tries information level 2 (full detail, administrative rights) first and -/// transparently falls back to level 1 on [`Error::AccessDenied`]. +/// transparently falls back to level 1 on `ERROR_ACCESS_DENIED`. /// /// # Errors /// See [`Error`]. @@ -258,7 +260,7 @@ pub fn shares(server: Option<&str>) -> Result> { |info| unsafe { ShareInfo::from_level2(info) }, ) { Ok(out) => Ok(out), - Err(Error::AccessDenied) => { + Err(Error::Windows(ERROR_ACCESS_DENIED)) => { debug!("NetShareEnum level 2 denied, falling back to level 1"); let mut resume = 0u32; net_enum::( @@ -284,7 +286,7 @@ pub fn shares(server: Option<&str>) -> Result> { /// level-1 fallback as [`shares`]. /// /// # Errors -/// [`Error::NetNameNotFound`] when the share does not exist. +/// `NERR_NetNameNotFound` when the share does not exist. // netapi32 allocates its info buffers with alignment suitable for any of its // info structures, so the u8 -> SHARE_INFO_* casts below are sound. #[allow(clippy::cast_ptr_alignment)] @@ -314,7 +316,7 @@ pub fn share_info(server: Option<&str>, name: &str) -> Result { // SAFETY: level-2 success means `buf` holds one SHARE_INFO_2. Ok(unsafe { ShareInfo::from_level2(&*buf.0.cast::()) }) } - Err(Error::AccessDenied) => { + Err(Error::Windows(ERROR_ACCESS_DENIED)) => { let buf = get(1)?; // SAFETY: level-1 success means `buf` holds one SHARE_INFO_1. Ok(unsafe { ShareInfo::from_level1(&*buf.0.cast::()) }) @@ -385,9 +387,9 @@ impl NewShare { /// administrative rights on the target server. /// /// # Errors -/// [`Error::DuplicateShare`] when the name is taken, -/// [`Error::UnknownDeviceOrDirectory`] when the backing path does not exist, -/// [`Error::AccessDenied`] without administrative rights. +/// `NERR_DuplicateShare` when the name is taken, +/// `NERR_UnknownDevDir` when the backing path does not exist, +/// `ERROR_ACCESS_DENIED` without administrative rights. pub fn add_share(server: Option<&str>, share: &NewShare) -> Result<()> { let server_w = server.map(to_wide).transpose()?; let name_w = to_wide(&share.name)?; @@ -425,7 +427,7 @@ pub fn add_share(server: Option<&str>, share: &NewShare) -> Result<()> { /// target server. /// /// # Errors -/// [`Error::NetNameNotFound`] when the share does not exist. +/// `NERR_NetNameNotFound` when the share does not exist. pub fn delete_share(server: Option<&str>, name: &str) -> Result<()> { let server_w = server.map(to_wide).transpose()?; let name_w = to_wide(name)?; @@ -500,11 +502,11 @@ impl SessionInfo { /// [`SessionInfo::client`] may report the bare name without them. /// /// Tries information level 1 (full detail, administrative rights) first and -/// transparently falls back to level 10 on [`Error::AccessDenied`]. +/// transparently falls back to level 10 on `ERROR_ACCESS_DENIED`. /// /// # Errors -/// [`Error::ClientNameNotFound`] / [`Error::UserNotFound`] when a filter -/// matches nothing. +/// `NERR_ClientNameNotFound` / `NERR_UserNotFound` when a filter matches +/// nothing. pub fn sessions( server: Option<&str>, client: Option<&str>, @@ -537,7 +539,7 @@ pub fn sessions( |info| unsafe { SessionInfo::from_level1(info) }, ) { Ok(out) => Ok(out), - Err(Error::AccessDenied) => { + Err(Error::Windows(ERROR_ACCESS_DENIED)) => { debug!("NetSessionEnum level 1 denied, falling back to level 10"); let mut resume = 0u32; net_enum::( @@ -572,10 +574,9 @@ pub fn sessions( /// function, [`delete_all_sessions`]. /// /// # Errors -/// [`Error::InvalidParameter`] when both `client` and `username` are `None` -/// or empty (use [`delete_all_sessions`] to end every session), -/// [`Error::ClientNameNotFound`] / [`Error::UserNotFound`] when nothing -/// matches. +/// `ERROR_INVALID_PARAMETER` when both `client` and `username` are `None` or +/// empty (use [`delete_all_sessions`] to end every session), +/// `NERR_ClientNameNotFound` / `NERR_UserNotFound` when nothing matches. pub fn delete_session( server: Option<&str>, client: Option<&str>, @@ -586,7 +587,7 @@ pub fn delete_session( let client = client.filter(|c| !c.is_empty()); let username = username.filter(|u| !u.is_empty()); if client.is_none() && username.is_none() { - return Err(Error::InvalidParameter); + return Err(Error::Windows(ERROR_INVALID_PARAMETER)); } session_del(server, client, username) } @@ -599,7 +600,7 @@ pub fn delete_session( /// [`delete_session`]. /// /// # Errors -/// [`Error::AccessDenied`] without administrative rights. +/// `ERROR_ACCESS_DENIED` without administrative rights. pub fn delete_all_sessions(server: Option<&str>) -> Result<()> { session_del(server, None, None) } @@ -658,7 +659,7 @@ impl OpenFile { /// administrative rights on the target server. /// /// # Errors -/// [`Error::AccessDenied`] without administrative rights. +/// `ERROR_ACCESS_DENIED` without administrative rights. pub fn open_files( server: Option<&str>, base_path: Option<&str>, @@ -705,7 +706,7 @@ pub fn open_files( /// the client side. /// /// # Errors -/// [`Error::FileIdNotFound`] when the id does not exist. +/// `NERR_FileIdNotFound` when the id does not exist. pub fn close_file(server: Option<&str>, id: u32) -> Result<()> { let server_w = server.map(to_wide).transpose()?; // SAFETY: the string outlives the call. @@ -742,7 +743,7 @@ pub struct ShareConnection { /// the connections made from that computer. /// /// # Errors -/// [`Error::NetNameNotFound`] when the qualifier matches no share. +/// `NERR_NetNameNotFound` when the qualifier matches no share. pub fn connections(server: Option<&str>, qualifier: &str) -> Result> { let server_w = server.map(to_wide).transpose()?; let qualifier_w = to_wide(qualifier)?; @@ -806,7 +807,7 @@ mod tests { ] { assert_eq!( delete_session(None, client, username).unwrap_err(), - Error::InvalidParameter + Error::Windows(ERROR_INVALID_PARAMETER) ); } } @@ -824,7 +825,7 @@ mod tests { }, |_| panic!("no entries were delivered"), ); - assert_eq!(result, Err(Error::Other(ERROR_MORE_DATA))); + assert_eq!(result, Err(Error::Windows(ERROR_MORE_DATA))); assert_eq!(calls, 1); } @@ -855,7 +856,7 @@ mod tests { entries += 1; }, ); - assert_eq!(result, Err(Error::Other(ERROR_MORE_DATA))); + assert_eq!(result, Err(Error::Windows(ERROR_MORE_DATA))); assert!(entries > 0, "delivered entries must still be consumed"); } diff --git a/src/target.rs b/src/target.rs index 6e74f77..8d4d162 100644 --- a/src/target.rs +++ b/src/target.rs @@ -2,6 +2,7 @@ use crate::error::{Error, Result, check_wnet}; use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; use crate::strings::{WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide}; use crate::trace::{debug, trace}; +use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER; use windows_sys::Win32::NetworkManagement::WNet; /// The wide-string buffers and resource type for one connect call, converted @@ -189,7 +190,7 @@ impl SmbTarget { /// Connect with default options: a temporary, non-interactive connection. /// /// Connecting multiple times works fine in deviceless mode but fails with - /// [`Error::AlreadyAssigned`] when a local mount point is set. Windows + /// `ERROR_ALREADY_ASSIGNED` when a local mount point is set. Windows /// does not reference-count connections, though: repeated deviceless /// connects share one underlying connection, and a single /// [`disconnect`](Self::disconnect) cancels them all. @@ -241,7 +242,7 @@ impl SmbTarget { /// /// The target's resource type must be [`ResourceType::Disk`] or /// [`ResourceType::Print`]: Windows rejects `RESOURCETYPE_ANY` with - /// [`Error::InvalidParameter`] when it chooses the device itself. + /// `ERROR_INVALID_PARAMETER` when it chooses the device itself. /// /// # Errors /// See [`Error`]. @@ -288,7 +289,7 @@ impl SmbTarget { /// Requires a local device (set via [`mount_on`](Self::mount_on) or /// [`local_device`](Self::local_device)): the device is the /// one thing a guard can exclusively own — connecting fails with - /// [`Error::AlreadyAssigned`] if it is taken, and canceling it by name on + /// `ERROR_ALREADY_ASSIGNED` if it is taken, and canceling it by name on /// drop touches no other connection. A deviceless connection offers no /// such handle: Windows does not reference-count connections, and /// canceling by remote name tears down **every** deviceless connection @@ -298,11 +299,11 @@ impl SmbTarget { /// pick the device instead. /// /// # Errors - /// [`Error::InvalidParameter`] (synthesized without a Windows call) when + /// `ERROR_INVALID_PARAMETER` (synthesized without a Windows call) when /// this target has no local device; otherwise see [`Error`]. pub fn connect_guarded(&self, options: ConnectOptions) -> Result { let Some(device) = self.local.as_deref() else { - return Err(Error::InvalidParameter); + return Err(Error::Windows(ERROR_INVALID_PARAMETER)); }; let device = device.to_string(); self.connect_with(options)?; @@ -456,7 +457,7 @@ mod tests { let target = SmbTarget::new(r"\\server\share"); assert_eq!( target.connect_guarded(ConnectOptions::new()).unwrap_err(), - Error::InvalidParameter + Error::Windows(ERROR_INVALID_PARAMETER) ); } } diff --git a/tests/integration.rs b/tests/integration.rs index ced54b6..87713f3 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -5,6 +5,13 @@ use sambrs::{ ConnectOptions, DisconnectOptions, DriveLetter, Error, SmbTarget, cancel_connection, enumerate, query, server, }; +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_ALREADY_ASSIGNED, ERROR_BAD_NET_NAME, ERROR_INVALID_PASSWORD, + ERROR_LOGON_FAILURE, ERROR_NO_NET_OR_BAD_PATH, ERROR_NO_NETWORK, ERROR_OPEN_FILES, +}; +use windows_sys::Win32::NetworkManagement::NetManagement::{ + NERR_DuplicateShare as NERR_DUPLICATE_SHARE, NERR_NetNameNotFound as NERR_NET_NAME_NOT_FOUND, +}; fn share_name() -> String { std::env::var("SAMBRS_TEST_SHARE").expect("SAMBRS_TEST_SHARE must be set") @@ -38,8 +45,7 @@ fn drive_exists(letter: DriveLetter) -> bool { // ── connect / disconnect ──────────────────────────────────────────────────── -// Lovely Windows sometimes returns `LogonFailure` and sometimes -// `InvalidPassword` for a bad password. +// Lovely Windows returns several statuses for a bad password. #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn wrong_password_fails_without_prompting() { @@ -49,7 +55,9 @@ fn wrong_password_fails_without_prompting() { assert!( matches!( result, - Err(Error::InvalidPassword | Error::LogonFailure | Error::AccessDenied) + Err(Error::Windows( + ERROR_INVALID_PASSWORD | ERROR_LOGON_FAILURE | ERROR_ACCESS_DENIED + )) ), "unexpected result: {result:?}" ); @@ -61,17 +69,15 @@ fn nonexistent_share_fails() { let target = SmbTarget::new(r"\\thisisnotashare.local\Share-Name").credentials(username(), password()); let result = target.connect(); - // The documented WNetAddConnection2W error list is not exhaustive - // ("Other: use FormatMessage"): unresolvable hosts commonly surface as - // ERROR_BAD_NETPATH (53) or ERROR_SEM_TIMEOUT (121), which have no - // dedicated variant. + // The documented WNetAddConnection2W error list is not exhaustive; + // unresolvable hosts commonly surface as ERROR_BAD_NETPATH (53) or + // ERROR_SEM_TIMEOUT (121). assert!( matches!( result, - Err(Error::BadNetName - | Error::NoNetOrBadPath - | Error::NoNetwork - | Error::Other(53 | 121)) + Err(Error::Windows( + ERROR_BAD_NET_NAME | ERROR_NO_NET_OR_BAD_PATH | ERROR_NO_NETWORK | 53 | 121 + )) ), "unexpected result: {result:?}" ); @@ -93,7 +99,10 @@ fn deviceless_connect_and_reconnect_works() { fn mounted_reconnect_fails_with_already_assigned() { let target = target(Some(DriveLetter::S)); target.connect().unwrap(); - assert_eq!(target.connect(), Err(Error::AlreadyAssigned)); + assert_eq!( + target.connect(), + Err(Error::Windows(ERROR_ALREADY_ASSIGNED)) + ); target.disconnect().unwrap(); } @@ -119,7 +128,7 @@ fn force_disconnect_with_open_file_works() { target.connect().unwrap(); let file = std::fs::File::create(r"U:\sambrs-force-disconnect.txt").unwrap(); // Non-forced disconnect must refuse while a file is open. - assert_eq!(target.disconnect(), Err(Error::OpenFiles)); + assert_eq!(target.disconnect(), Err(Error::Windows(ERROR_OPEN_FILES))); target .disconnect_with(DisconnectOptions::new().force(true)) .unwrap(); @@ -161,7 +170,7 @@ fn guard_drop_leaves_other_connections_alone() { } assert!(!drive_exists(DriveLetter::V)); // The deviceless connection must still be alive; disconnecting it now - // fails with NotConnected if the guard's drop tore it down. + // fails with ERROR_NOT_CONNECTED if the guard's drop tore it down. deviceless.disconnect().unwrap(); } @@ -315,10 +324,13 @@ fn server_add_get_delete_share_roundtrip() { // Adding the same name again must fail cleanly. let dup = server::add_share(None, &server::NewShare::disk(&name, dir.to_str().unwrap())); - assert_eq!(dup, Err(Error::DuplicateShare)); + assert_eq!(dup, Err(Error::Windows(NERR_DUPLICATE_SHARE))); server::delete_share(None, &name).unwrap(); - assert_eq!(server::share_info(None, &name), Err(Error::NetNameNotFound)); + assert_eq!( + server::share_info(None, &name), + Err(Error::Windows(NERR_NET_NAME_NOT_FOUND)) + ); } #[test] From 5523b2c75c1fe9312f7493160555b7052e8dc919 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 15:20:46 +0000 Subject: [PATCH 34/42] refactor: inline option flag conversion --- src/options.rs | 41 +++++++++++------------------------------ src/target.rs | 12 ++++++++---- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/src/options.rs b/src/options.rs index 06059ab..82e3465 100644 --- a/src/options.rs +++ b/src/options.rs @@ -95,7 +95,7 @@ pub enum ResourceType { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[must_use] pub struct ConnectOptions { - flags: u32, + pub(crate) flags: u32, } impl Default for ConnectOptions { @@ -215,10 +215,6 @@ impl ConnectOptions { pub fn write_through(self, yes: bool) -> Self { self.flag(WNet::CONNECT_WRITE_THROUGH_SEMANTICS, yes) } - - pub(crate) fn to_flags(self) -> u32 { - self.flags - } } /// Options for [`SmbTarget::disconnect_with`](crate::SmbTarget::disconnect_with) @@ -226,8 +222,8 @@ impl ConnectOptions { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[must_use] pub struct DisconnectOptions { - force: bool, - forget: bool, + pub(crate) force: bool, + pub(crate) forget: bool, } impl DisconnectOptions { @@ -254,18 +250,6 @@ impl DisconnectOptions { self.forget = yes; self } - - pub(crate) fn flags(self) -> u32 { - if self.forget { - WNet::CONNECT_UPDATE_PROFILE - } else { - 0 - } - } - - pub(crate) fn force_bool(self) -> i32 { - i32::from(self.force) - } } #[cfg(test)] @@ -294,19 +278,19 @@ mod tests { #[test] fn default_options_are_temporary() { - assert_eq!(ConnectOptions::new().to_flags(), WNet::CONNECT_TEMPORARY); + assert_eq!(ConnectOptions::new().flags, WNet::CONNECT_TEMPORARY); assert_eq!( ConnectOptions::new() .interactive(true) .interactive(false) - .to_flags(), + .flags, WNet::CONNECT_TEMPORARY ); } #[test] fn persist_replaces_temporary() { - let flags = ConnectOptions::new().persist(true).to_flags(); + let flags = ConnectOptions::new().persist(true).flags; assert_eq!(flags, WNet::CONNECT_UPDATE_PROFILE); } @@ -324,7 +308,7 @@ mod tests { .require_integrity(true) .require_privacy(true) .write_through(true) - .to_flags(); + .flags; assert_eq!( flags, WNet::CONNECT_TEMPORARY @@ -344,12 +328,9 @@ mod tests { #[test] fn disconnect_options_map() { - assert_eq!(DisconnectOptions::new().flags(), 0); - assert_eq!(DisconnectOptions::new().force_bool(), 0); - assert_eq!( - DisconnectOptions::new().forget(true).flags(), - WNet::CONNECT_UPDATE_PROFILE - ); - assert_eq!(DisconnectOptions::new().force(true).force_bool(), 1); + assert!(!DisconnectOptions::new().forget); + assert!(!DisconnectOptions::new().force); + assert!(DisconnectOptions::new().forget(true).forget); + assert!(DisconnectOptions::new().force(true).force); } } diff --git a/src/target.rs b/src/target.rs index 8d4d162..b73f24e 100644 --- a/src/target.rs +++ b/src/target.rs @@ -208,7 +208,7 @@ impl SmbTarget { /// See [`Error`] — every documented `WNetAddConnection2W` failure has a /// dedicated variant. pub fn connect_with(&self, options: ConnectOptions) -> Result<()> { - let flags = options.to_flags(); + let flags = options.flags; let args = ConnectArgs::new(self)?; let resource = args.resource(); @@ -247,7 +247,7 @@ impl SmbTarget { /// # Errors /// See [`Error`]. pub fn connect_auto(&self, options: ConnectOptions) -> Result { - let flags = options.to_flags() | WNet::CONNECT_REDIRECT; + let flags = options.flags | WNet::CONNECT_REDIRECT; let args = ConnectArgs::new(self)?; let resource = args.resource(); @@ -370,9 +370,13 @@ pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { let wide = to_wide(name)?; trace!("disconnecting {name}"); // SAFETY: `wide` is a valid nul-terminated string outliving the call. - let status = unsafe { - WNet::WNetCancelConnection2W(wide.as_ptr(), options.flags(), options.force_bool()) + let flags = if options.forget { + WNet::CONNECT_UPDATE_PROFILE + } else { + 0 }; + let status = + unsafe { WNet::WNetCancelConnection2W(wide.as_ptr(), flags, i32::from(options.force)) }; debug!("WNetCancelConnection2W returned {status}"); check_wnet(status) } From ced2a13b2bdc3be902514826d70b7a26afc177d6 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 15:22:57 +0000 Subject: [PATCH 35/42] test: deduplicate integration environment lookup --- tests/integration.rs | 48 +++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 87713f3..eefc624 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -13,16 +13,12 @@ use windows_sys::Win32::NetworkManagement::NetManagement::{ NERR_DuplicateShare as NERR_DUPLICATE_SHARE, NERR_NetNameNotFound as NERR_NET_NAME_NOT_FOUND, }; -fn share_name() -> String { - std::env::var("SAMBRS_TEST_SHARE").expect("SAMBRS_TEST_SHARE must be set") -} - -fn username() -> String { - std::env::var("SAMBRS_TEST_USERNAME").expect("SAMBRS_TEST_USERNAME must be set") -} +const SHARE: &str = "SAMBRS_TEST_SHARE"; +const USERNAME: &str = "SAMBRS_TEST_USERNAME"; +const PASSWORD: &str = "SAMBRS_TEST_PASSWORD"; -fn password() -> String { - std::env::var("SAMBRS_TEST_PASSWORD").expect("SAMBRS_TEST_PASSWORD must be set") +fn required_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set")) } /// `server::*` tests only make sense against the local machine, with rights @@ -32,11 +28,9 @@ fn local_admin() -> bool { } fn target(mount: Option) -> SmbTarget { - let target = SmbTarget::new(share_name()).credentials(username(), password()); - match mount { - Some(letter) => target.mount_on(letter), - None => target, - } + let target = SmbTarget::new(required_env(SHARE)) + .credentials(required_env(USERNAME), required_env(PASSWORD)); + mount.into_iter().fold(target, SmbTarget::mount_on) } fn drive_exists(letter: DriveLetter) -> bool { @@ -49,8 +43,8 @@ fn drive_exists(letter: DriveLetter) -> bool { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn wrong_password_fails_without_prompting() { - let target = - SmbTarget::new(share_name()).credentials(username(), "definitely-the-wrong-password-1"); + let target = SmbTarget::new(required_env(SHARE)) + .credentials(required_env(USERNAME), "definitely-the-wrong-password-1"); let result = target.connect(); assert!( matches!( @@ -66,8 +60,8 @@ fn wrong_password_fails_without_prompting() { #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn nonexistent_share_fails() { - let target = - SmbTarget::new(r"\\thisisnotashare.local\Share-Name").credentials(username(), password()); + let target = SmbTarget::new(r"\\thisisnotashare.local\Share-Name") + .credentials(required_env(USERNAME), required_env(PASSWORD)); let result = target.connect(); // The documented WNetAddConnection2W error list is not exhaustive; // unresolvable hosts commonly surface as ERROR_BAD_NETPATH (53) or @@ -90,7 +84,7 @@ fn deviceless_connect_and_reconnect_works() { target.connect().unwrap(); // Reconnecting a deviceless connection is fine. target.connect().unwrap(); - assert!(std::path::Path::new(&share_name()).is_dir()); + assert!(std::path::Path::new(&required_env(SHARE)).is_dir()); target.disconnect().unwrap(); } @@ -215,9 +209,9 @@ fn get_connection_returns_the_remote_name() { target.connect().unwrap(); let remote = query::get_connection("W:").unwrap(); assert!( - remote.eq_ignore_ascii_case(&share_name()), + remote.eq_ignore_ascii_case(&required_env(SHARE)), "{remote} != {}", - share_name() + required_env(SHARE) ); target.disconnect().unwrap(); } @@ -240,9 +234,9 @@ fn get_universal_name_resolves_a_mounted_path() { let unc = query::get_universal_name(r"W:\").unwrap(); assert!( unc.to_ascii_lowercase() - .starts_with(&share_name().to_ascii_lowercase()), + .starts_with(&required_env(SHARE).to_ascii_lowercase()), "{unc} does not start with {}", - share_name() + required_env(SHARE) ); target.disconnect().unwrap(); } @@ -259,7 +253,7 @@ fn enumerate_connections_contains_the_share() { .filter_map(Result::ok) .any(|r| { r.remote_name - .is_some_and(|n| n.eq_ignore_ascii_case(&share_name())) + .is_some_and(|n| n.eq_ignore_ascii_case(&required_env(SHARE))) }); target.disconnect().unwrap(); assert!(found, "active connection to the test share not enumerated"); @@ -269,7 +263,7 @@ fn enumerate_connections_contains_the_share() { #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn enumerate_server_shares_contains_the_share() { // \\server\share -> \\server - let full = share_name(); + let full = required_env(SHARE); let server = full.trim_start_matches('\\').split('\\').next().unwrap(); let server_root = format!(r"\\{server}"); // Authenticate first: servers may refuse anonymous enumeration. @@ -292,7 +286,7 @@ fn server_shares_lists_the_test_share() { eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); return; } - let leaf = share_name().rsplit('\\').next().unwrap().to_string(); + let leaf = required_env(SHARE).rsplit('\\').next().unwrap().to_string(); let all = server::shares(None).unwrap(); assert!( all.iter().any(|s| s.name.eq_ignore_ascii_case(&leaf)), @@ -340,7 +334,7 @@ fn server_sessions_and_connections_are_listable() { eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); return; } - let leaf = share_name().rsplit('\\').next().unwrap().to_string(); + let leaf = required_env(SHARE).rsplit('\\').next().unwrap().to_string(); let target = target(None); target.connect().unwrap(); From b2c137a57785241a78eaafa63ca86cdd1813080c Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 16:34:49 +0000 Subject: [PATCH 36/42] build: disable unused tracing attributes --- Cargo.lock | 47 ----------------------------------------------- Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 09ece82..7f89660 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,24 +14,6 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" -dependencies = [ - "proc-macro2", -] - [[package]] name = "sambrs" version = "0.2.0" @@ -41,17 +23,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "tracing" version = "0.1.40" @@ -59,21 +30,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.32" @@ -83,12 +42,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "unicode-ident" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 34e3637..852eb46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ tracing = ["dep:tracing"] zeroize = ["dep:zeroize"] [target.'cfg(windows)'.dependencies] -tracing = { version = "0.1", optional = true } +tracing = { version = "0.1", optional = true, default-features = false, features = ["std"] } zeroize = { version = "1", optional = true } [target.'cfg(windows)'.dependencies.windows-sys] From 68aaf9923b88639da0ce89f7fffe6d4fabd13f45 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 16:35:14 +0000 Subject: [PATCH 37/42] refactor: inline Windows error construction --- src/enumerate.rs | 4 ++-- src/error.rs | 8 ++------ src/query.rs | 4 ++-- src/server.rs | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/enumerate.rs b/src/enumerate.rs index 7b65a14..7a9c860 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -161,7 +161,7 @@ impl Resources { self.buf = vec![0u64; (size as usize).div_ceil(size_of::())]; } ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), - code => return Err(Error::from_status(code)), + code => return Err(Error::Windows(code)), } } Err(Error::Windows(ERROR_MORE_DATA)) @@ -214,7 +214,7 @@ fn open( finished: false, }), ERROR_EXTENDED_ERROR => Err(wnet_extended_error()), - code => Err(Error::from_status(code)), + code => Err(Error::Windows(code)), } } diff --git a/src/error.rs b/src/error.rs index e7f308c..af067f7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -108,10 +108,6 @@ fn netmsg_message(code: u32) -> Option { } impl Error { - pub(crate) fn from_status(code: u32) -> Self { - Self::Windows(code) - } - /// The underlying Windows status code, if this error has one. #[must_use] pub fn raw_os_error(&self) -> Option { @@ -142,7 +138,7 @@ pub(crate) fn check_wnet(status: u32) -> Result<()> { match status { NO_ERROR => Ok(()), ERROR_EXTENDED_ERROR => Err(wnet_extended_error()), - code => Err(Error::from_status(code)), + code => Err(Error::Windows(code)), } } @@ -151,7 +147,7 @@ pub(crate) fn check_net(status: u32) -> Result<()> { if status == NERR_SUCCESS { Ok(()) } else { - Err(Error::from_status(status)) + Err(Error::Windows(status)) } } diff --git a/src/query.rs b/src/query.rs index 107651b..8078a5c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -18,7 +18,7 @@ fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { // `len` now holds the required size in characters. ERROR_MORE_DATA => buf = vec![0u16; len as usize + 1], ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), - code => return Err(Error::from_status(code)), + code => return Err(Error::Windows(code)), } } Err(Error::Windows(ERROR_MORE_DATA)) @@ -92,7 +92,7 @@ pub fn get_universal_name(local_path: &str) -> Result { // `size` now holds the required size in bytes. ERROR_MORE_DATA => buf = vec![0u64; (size as usize).div_ceil(size_of::())], ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), - code => return Err(Error::from_status(code)), + code => return Err(Error::Windows(code)), } } Err(Error::Windows(ERROR_MORE_DATA)) diff --git a/src/server.rs b/src/server.rs index d4a3c0e..8b80760 100644 --- a/src/server.rs +++ b/src/server.rs @@ -98,7 +98,7 @@ fn net_enum( // ERROR_MORE_DATA: loop again, the resume handle captured by // `call` continues where this batch ended. } - code => return Err(Error::from_status(code)), + code => return Err(Error::Windows(code)), } } Err(Error::Windows(ERROR_MORE_DATA)) From 0026ca4d5bac88199e3f365ba640ad5fd5182706 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 16:35:21 +0000 Subject: [PATCH 38/42] build: drop duplicate docs.rs target --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 852eb46..d295ee4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,4 +40,3 @@ features = [ [package.metadata.docs.rs] default-target = "x86_64-pc-windows-msvc" -targets = ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] From b5daf392115c04a9aaeba3e0735b0505a9f70d7b Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 16:35:29 +0000 Subject: [PATCH 39/42] build: rely on automatic docs link --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d295ee4..483dc36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ readme = "README.md" edition = "2024" rust-version = "1.85" repository = "https://github.com/samvdst/sambrs" -documentation = "https://docs.rs/sambrs" categories = ["os::windows-apis", "filesystem", "api-bindings"] keywords = ["windows", "smb", "share", "network"] description = """ From fc1f5453e3b7ae5e3bb481fb9bf65d193613497d Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 20:09:24 +0000 Subject: [PATCH 40/42] refactor: focus crate on SMB client operations --- .github/workflows/ci.yml | 19 +- CHANGELOG.md | 105 +-- CONTEXT.md | 30 +- Cargo.toml | 17 +- README.md | 140 ++- .../0001-opinionated-smb-client-boundary.md | 11 + src/enumerate.rs | 67 +- src/error.rs | 159 +--- src/lib.rs | 3 +- src/options.rs | 188 +--- src/query.rs | 31 +- src/server.rs | 874 ------------------ src/strings.rs | 6 +- src/target.rs | 234 ++--- src/trace.rs | 17 - tests/integration.rs | 133 +-- 16 files changed, 386 insertions(+), 1648 deletions(-) create mode 100644 docs/adr/0001-opinionated-smb-client-boundary.md delete mode 100644 src/server.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3da8a37..897c339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,15 +28,11 @@ jobs: icacls 'C:\sambrs-share' /grant 'smbtest:(OI)(CI)F' | Out-Null "SAMBRS_TEST_USERNAME=$env:COMPUTERNAME\smbtest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - name: Unit + integration tests (all features) - run: cargo test --all-features -- --include-ignored + - name: Unit + integration tests + run: cargo test -- --include-ignored env: SAMBRS_TEST_SHARE: \\localhost\sambrs-test SAMBRS_TEST_PASSWORD: Sambrs-CI-Pass-1! - SAMBRS_TEST_LOCAL: '1' - - - name: Unit tests (no default features) - run: cargo test msrv: name: MSRV check (Rust 1.85, Windows target) @@ -49,10 +45,10 @@ jobs: targets: x86_64-pc-windows-msvc - name: crate (incl. tests) compiles on the declared MSRV - run: cargo check --target x86_64-pc-windows-msvc --all-targets --all-features + run: cargo check --target x86_64-pc-windows-msvc --all-targets lint: - name: Lint, docs, cross-target checks (Linux) + name: Lint and docs (Windows target) runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -65,12 +61,9 @@ jobs: run: cargo fmt --check - name: clippy (pedantic is enforced in the crate root) - run: cargo clippy --target x86_64-pc-windows-msvc --all-targets --all-features -- -D warnings + run: cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings - name: rustdoc - run: cargo doc --target x86_64-pc-windows-msvc --all-features --no-deps + run: cargo doc --target x86_64-pc-windows-msvc --no-deps env: RUSTDOCFLAGS: -D warnings - - - name: crate is a well-behaved no-op on non-Windows hosts - run: cargo check --all-features diff --git a/CHANGELOG.md b/CHANGELOG.md index ba07865..788aeda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,80 +2,49 @@ ## 0.2.0 (unreleased) -A ground-up rework: from "a tiny wrapper around two calls" to safe, flexible -bindings for the Windows SMB surface. **Breaking release** — see the -migration table in the README. - -### Fixed - -- **Unicode correctness**: all calls now use the wide (`W`) API variants with - UTF-16 strings. 0.1 used the ANSI variants, which mangled non-ASCII share - names, user names, and passwords (typically surfacing as spurious - `ERROR_LOGON_FAILURE`). -- `ERROR_EXTENDED_ERROR` no longer swallows the real error: the - provider-specific code and message are fetched via `WNetGetLastErrorW` and - carried in `Error::ExtendedError`. -- `disconnect`'s misleading `persist` parameter (which *removed* persistence) - is now `DisconnectOptions::forget`. +A breaking rework into a safe, opinionated Windows client for existing SMB +disk shares. ### Added -- Authenticate as the logged-on user by omitting credentials (0.1 always - passed non-null credentials, making SSO unreachable). -- Fluent `SmbTarget` configuration with `credentials`, `username`, `password`, - `mount_on` (type-safe `DriveLetter`), `local_device`, `resource_type` - (disk/printer), and `provider`. -- `ConnectOptions` covering every documented `CONNECT_*` flag: `persist`, - `update_recent`, `interactive`, `prompt`, `commandline`, `redirect`, - `current_media`, `save_credentials`, `reset_credentials`, - `require_integrity` (SMB signing), `require_privacy` (SMB encryption), and - `write_through`. -- `SmbTarget::connect_auto`: let Windows pick a free drive letter - (`WNetUseConnectionW`), returning the assigned name. -- `SmbTarget::connect_guarded` / `connect_auto_guarded`: RAII `Connection` - guard that disconnects on drop, with `leak()` and explicit `disconnect()`. - A guard always owns a redirected local device and cancels exactly that - device, so dropping it can never tear down a connection it did not create. - Deviceless targets are rejected with `ERROR_INVALID_PARAMETER` (Windows does not - reference-count deviceless connections, so no guard can own one); see the - `Connection` docs. -- `cancel_connection`: disconnect any connection by device or remote name. -- `query` module: `get_connection`, `get_user`, `get_universal_name`. -- `enumerate` module: iterate active connections, remembered connections, and - the shares a server exposes (`WNetOpenEnumW` family), plus `resources_raw`. -- `server` module (netapi32): `shares`, `share_info`, `add_share`, - `delete_share`, `sessions`, `delete_session`, `open_files`, `close_file`, - `connections` — with automatic fallback to lower information levels when - not administrator. `delete_session` requires a non-empty client and/or - user filter (`ERROR_INVALID_PARAMETER` otherwise — netapi32 treats an empty - string as no filter); ending every session on a server is the separate, - explicit `delete_all_sessions`. -- `Error::Windows` resolves `NERR_*` codes (2100–2999) through netmsg.dll, so - undocumented netapi32 errors display their real message instead of an - unknown-error placeholder. -- `Error::raw_os_error` and `From for std::io::Error`. Converting an - `ExtendedError` keeps the error as the `io::Error` payload, so the - provider's own code, description, and name survive instead of collapsing - into the generic `ERROR_EXTENDED_ERROR` (1208) message. -- Cargo features: `tracing` (now optional!) and `zeroize` (wipe password - buffers on drop). -- The `server` enumeration loop fails with `Error::Windows(ERROR_MORE_DATA)` - instead of spinning forever when a malformed server keeps reporting - `ERROR_MORE_DATA` without delivering entries or terminating. Similarly, - `connect_auto` sizes its access-name buffer to fit any real access name up - front and fails instead of retrying: Windows leaves undocumented whether a - call that failed with `ERROR_MORE_DATA` already established the - connection, so a retry could create a second one. +- `SmbTarget` connections using the logged-on identity, paired credentials, or + partial username/password credentials. +- Deviceless, explicit-drive, and automatically assigned connections. +- Persistent drive mappings and explicit `force`/`forget` disconnect options. +- `ConnectOptions::require_integrity` for SMB signing and + `require_privacy` for SMB encryption. +- RAII `Connection` guards for temporary drive mappings, with explicit + disconnect and leak operations. +- Focused queries for mapped-drive targets, connection users, and universal + UNC paths. +- Disk-focused enumeration of active connections, remembered mappings, and a + server's existing shares. +- Provider-specific extended errors from `WNetGetLastErrorW`. +- Unconditional detailed `tracing` events. Usernames and targets are logged; + passwords never are. +- Unconditional zeroization of stored and transient UTF-16 password buffers. ### Changed -- `Error` is `#[non_exhaustive]`; Windows and `NERR_*` statuses are exposed as - `Error::Windows(code)`, while input-validation and provider-specific errors - retain dedicated variants. `Error::CStringConversion` is now - `Error::InteriorNul`. -- `windows-sys` 0.52 → 0.60; dependencies are declared Windows-only, and the - crate compiles to nothing on other targets. -- docs.rs builds Windows targets; MSRV pinned at 1.85. +- All Windows calls use Unicode (`W`) APIs. +- Windows statuses are preserved as `Error::Windows(code)` while + crate-originated validation and provider-specific errors remain typed. +- Persistence requires a drive mapping; guarded connections cannot persist; + forgetting requires a drive mapping. Invalid combinations fail before a + Windows call. +- Deviceless connections are kept out of Windows' recent-connections list. +- Dependencies and API are Windows-only; cross-platform consumers must gate + the dependency and usage with `cfg(windows)`. +- `windows-sys` 0.52 → 0.60; MSRV is Rust 1.85. + +### Removed + +- Server administration and share creation. +- Printer resources, arbitrary local devices, and custom provider selection. +- Raw WNet enumeration and metadata. +- Interactive authentication, connection-wide write-through, and other raw + `CONNECT_*` controls outside persistence, signing, and encryption. +- `Error::raw_os_error` and conversion into `std::io::Error`. ## 0.1.2 diff --git a/CONTEXT.md b/CONTEXT.md index f5107d2..a151b3b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,17 +1,29 @@ -# SMB Resource Management +# Existing SMB Resource Access -This context describes connecting to SMB resources and inspecting shares hosted by SMB servers. +This context describes client-side access to existing SMB shares: authenticating, mapping drives, remembering mappings, and inspecting available or active resources. ## Language **SMB target**: -A reusable description of a remote SMB resource, its credentials, and its desired local redirection. It exists independently of whether a connection is active. -_Avoid_: SMB share, connection +A reusable description of an existing remote SMB resource, its credentials, and an optional desired drive letter. It exists independently of whether access is active. +_Avoid_: New share, connection + +**Deviceless connection**: +Active authenticated access to an SMB target through its UNC path, without assigning a local drive letter. +_Avoid_: Drive mapping + +**Drive mapping**: +An active association between a Windows drive letter and an SMB target. It may be temporary or remembered. +_Avoid_: Share, deviceless connection + +**Remembered mapping**: +A drive mapping recorded in the Windows user profile so Windows can restore it at logon. +_Avoid_: Active connection, recent connection **Connection**: -An active, owned local-device redirection to an SMB target. Its lifetime determines when that redirection is disconnected. -_Avoid_: SMB target, share +An owned temporary drive mapping whose lifetime controls when the mapping is disconnected. +_Avoid_: SMB target, remembered mapping -**Server share info**: -A snapshot describing an SMB share hosted by a server, including its type and available administrative details. -_Avoid_: SMB target, connection +**Server share**: +An existing disk resource exposed by an SMB server for clients to inspect or access. +_Avoid_: Local directory, newly created share diff --git a/Cargo.toml b/Cargo.toml index 483dc36..121a833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,31 +10,20 @@ repository = "https://github.com/samvdst/sambrs" categories = ["os::windows-apis", "filesystem", "api-bindings"] keywords = ["windows", "smb", "share", "network"] description = """ -Safe and flexible bindings for Windows SMB share operations: connect and disconnect network shares (WNet), query and enumerate connections and remote shares, and administer server-side shares, sessions, and open files (netapi32). +Safe, opinionated Windows SMB client operations: connect, disconnect, persist, query, and enumerate existing network shares. """ # Allowlist: ship only the crate itself, not repo/CI/agent tooling. include = ["/src", "/tests", "/README.md", "/CHANGELOG.md", "/LICENSE"] -[features] -# Emit `tracing` debug/trace events for every Windows API call. -tracing = ["dep:tracing"] -# Wipe password buffers (both the stored String and transient UTF-16 copies) after use. -zeroize = ["dep:zeroize"] - [target.'cfg(windows)'.dependencies] -tracing = { version = "0.1", optional = true, default-features = false, features = ["std"] } -zeroize = { version = "1", optional = true } +tracing = { version = "0.1", default-features = false, features = ["std"] } +zeroize = "1" [target.'cfg(windows)'.dependencies.windows-sys] version = "0.60" features = [ "Win32_Foundation", "Win32_NetworkManagement_WNet", - "Win32_NetworkManagement_NetManagement", - "Win32_Storage_FileSystem", - # FormatMessageW + LoadLibraryExW, to resolve NERR_* messages from netmsg.dll - "Win32_System_Diagnostics_Debug", - "Win32_System_LibraryLoader", ] [package.metadata.docs.rs] diff --git a/README.md b/README.md index 721ff26..08b28bd 100644 --- a/README.md +++ b/README.md @@ -3,45 +3,49 @@ [![crates.io](https://img.shields.io/crates/v/sambrs.svg)](https://crates.io/crates/sambrs) [![docs.rs](https://img.shields.io/docsrs/sambrs)](https://docs.rs/sambrs) -Safe and flexible Rust bindings for Windows SMB share operations. +Safe, opinionated Windows SMB client operations for existing disk shares. Sam -> SMB -> Rust -> Samba is taken!? -> sambrs -The crate wraps three areas of the Windows API, aiming to expose the full -flexibility of the underlying calls behind a safe, unopinionated interface: - -- **Connecting** — `WNetAddConnection2W`, `WNetUseConnectionW`, and - `WNetCancelConnection2W`: connect to a share deviceless, mounted on a drive - letter of your choice, or on a letter Windows picks. Every documented - `CONNECT_*` flag is available through `ConnectOptions`, including - per-connection SMB signing (`require_integrity`) and encryption - (`require_privacy`) enforcement. An optional RAII `Connection` guard - disconnects on drop. -- **Querying and enumerating** — `WNetGetConnectionW`, `WNetGetUserW`, - `WNetGetUniversalNameW`, and the `WNetOpenEnumW` family: inspect existing - connections, list remembered ones, and enumerate the shares a server - exposes, as a plain Rust `Iterator`. -- **Administering** — the netapi32 `NetShare*`, `NetSession*`, `NetFile*`, - and `NetConnectionEnum` functions: create and delete shares, and manage - sessions and open files, on the local machine or a remote server. - -All strings cross the FFI boundary as UTF-16 (the `W` API variants), so share -names, user names, and passwords with any Unicode content work correctly. +`sambrs` keeps unsafe Windows FFI, UTF-16 conversion, buffer ownership, +validation, cleanup, and `WNet` quirks out of applications. It supports: + +- deviceless, explicit-drive, and automatically assigned connections; +- temporary and persistent drive mappings; +- default, paired, and partial credentials; +- SMB signing and encryption requirements; +- guarded temporary mappings that disconnect on drop; +- querying mapped drives, connection users, and universal UNC paths; +- enumerating active connections, remembered mappings, and a server's disk shares. + +It deliberately does not create or administer shares, handle printers, expose +raw `WNet` controls, or transfer files. See the [boundary +ADR](https://github.com/samvdst/sambrs/blob/main/docs/adr/0001-opinionated-smb-client-boundary.md). + +Passwords and their temporary UTF-16 buffers are wiped on drop. Operations, +targets, usernames, options, and raw Windows statuses are emitted through +`tracing`; passwords are never logged. ## Installation +The crate is Windows-only, so cross-platform applications should make the +dependency conditional: + ```toml -[dependencies] +[target.'cfg(windows)'.dependencies] sambrs = "0.2" ``` +MSRV is Rust 1.85 (edition 2024). + ## Usage -Configure an `SmbTarget` and establish a connection. Once connected (mounted -or deviceless), `std::fs` works on it like on any local path: +Configure an `SmbTarget` and establish a connection. Once connected, +`std::fs` works on the UNC path or mapped drive like any other filesystem +path: ```no_run -use sambrs::{ConnectOptions, DriveLetter, SmbTarget}; +use sambrs::{ConnectOptions, DisconnectOptions, DriveLetter, SmbTarget}; fn main() -> Result<(), Box> { let target = SmbTarget::new(r"\\server.local\share") @@ -50,19 +54,19 @@ fn main() -> Result<(), Box> { target.connect_with( ConnectOptions::new() - .persist(true) // restore the mapping at logon - .require_privacy(true), // enforce SMB encryption + .persist(true) + .require_privacy(true), )?; - // use std::fs as if D:\ was a local directory println!("{}", std::fs::metadata(r"D:\")?.is_dir()); - target.disconnect()?; + target.disconnect_with(DisconnectOptions::new().forget(true))?; Ok(()) } ``` -Without credentials, the connection authenticates as the logged-on user: +Persistence requires an explicit or automatically assigned drive. A +deviceless connection uses the UNC path directly and is never remembered: ```no_run # fn main() -> Result<(), sambrs::Error> { @@ -72,39 +76,34 @@ target.connect()?; # } ``` -Enumerate what a server offers, or administer shares (see the -[`enumerate`](https://docs.rs/sambrs/latest/sambrs/enumerate/) and -[`server`](https://docs.rs/sambrs/latest/sambrs/server/) module docs for the -full API): +Use a guard for an automatically cleaned-up temporary mapping: ```no_run # fn main() -> Result<(), sambrs::Error> { -for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { - println!("{:?}", resource?.remote_name); -} - -sambrs::server::add_share( - None, // local machine - &sambrs::server::NewShare::disk("scratch", r"C:\scratch"), -)?; +let target = sambrs::SmbTarget::new(r"\\server.local\share"); +let connection = target.connect_auto_guarded(sambrs::ConnectOptions::new())?; +println!("mapped on {}", connection.device()); # Ok(()) # } ``` -## Cargo features +Guarded connections cannot be persistent because persistence outlives the +guard. -Both features are off by default — no forced dependencies: +Inspect existing resources through the focused query and enumeration modules: -- `tracing` — emit [`tracing`](https://docs.rs/tracing) debug/trace events for - every Windows API call. -- `zeroize` — wipe password buffers (the stored `String` and the transient - UTF-16 copies) when they are dropped. - -## Platform support +```no_run +# fn main() -> Result<(), sambrs::Error> { +for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { + println!("{:?}", resource?.remote_name); +} -Windows only. On other targets the crate compiles to nothing, so it is safe -to keep in cross-platform dependency trees; gate your usage behind -`#[cfg(windows)]`. MSRV is 1.85 (edition 2024). +let remote = sambrs::query::get_connection("D:")?; +let user = sambrs::query::get_user(Some("D:"))?; +let unc = sambrs::query::get_universal_name(r"D:\folder\file.txt")?; +# Ok(()) +# } +``` ## Testing @@ -115,43 +114,38 @@ them at one and include them explicitly: SAMBRS_TEST_SHARE=\\server\share SAMBRS_TEST_USERNAME=DOMAIN\user SAMBRS_TEST_PASSWORD=... -SAMBRS_TEST_LOCAL=1 # only if the share is local; enables the server:: tests cargo test -- --include-ignored ``` -The tests mount real drive letters and must run single-threaded: a git -checkout sets `RUST_TEST_THREADS=1` via `.cargo/config.toml`; set it yourself -when running from a packaged copy of the crate. +The tests mount real drive letters and must run single-threaded. A git +checkout sets `RUST_TEST_THREADS=1` through `.cargo/config.toml`; set it +manually when running from a packaged copy. ## Migrating from 0.1 -`0.2` is a rework of the whole API; the most important changes: +`0.2` is a breaking rework of the API: | 0.1 | 0.2 | | --- | --- | | `SmbShare::new(share, user, pass, Some('d'))` | `SmbTarget::new(share).credentials(user, pass).mount_on(DriveLetter::D)` | -| `share.connect(persist, interactive)` | `target.connect_with(ConnectOptions::new().persist(persist).interactive(interactive))` | -| `share.disconnect(persist, force)` | `target.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` — note the rename: the old `persist: true` *removed* the persistence | +| `share.connect(persist, interactive)` | `target.connect_with(ConnectOptions::new().persist(persist))` | +| `share.disconnect(persist, force)` | `target.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` | | `Error::CStringConversion` | `Error::InteriorNul` | -Under the hood, 0.1 used the ANSI (`A`) API variants, which silently mangled -non-ASCII share names, user names, and passwords; 0.2 uses the wide (`W`) -variants throughout. Empty-string credentials are no longer the way to say -"use my logon credentials" — omit them instead (empty string means a real -empty password now, matching the Windows API). +The old `persist: true` disconnect argument removed persistence, hence the +`forget` rename. Version 0.2 uses Unicode Windows APIs throughout and omitting +credentials now means "use the logged-on Windows identity." ## License This project is licensed under the MIT License. See the -[LICENSE](https://github.com/samvdst/sambrs/blob/main/LICENSE) for details. +[license](https://github.com/samvdst/sambrs/blob/main/LICENSE). -## Special Thanks +## Special thanks -Special thanks to [Christian Visintin](https://github.com/veeso) for his -informative [blog -post](https://blog.veeso.dev/blog/en/how-to-access-an-smb-share-with-rust-on-windows/) -on accessing SMB shares with Rust on Windows. If you need a fully-featured -remote file access solution that works across multiple protocols, you should -definitely check out his project +Thanks to [Christian Visintin](https://github.com/veeso) for his [article on +accessing SMB shares with Rust on +Windows](https://blog.veeso.dev/blog/en/how-to-access-an-smb-share-with-rust-on-windows/). +For a fully featured, cross-platform remote-file solution, see [remotefs](https://github.com/veeso/remotefs-rs). diff --git a/docs/adr/0001-opinionated-smb-client-boundary.md b/docs/adr/0001-opinionated-smb-client-boundary.md new file mode 100644 index 0000000..86c6e97 --- /dev/null +++ b/docs/adr/0001-opinionated-smb-client-boundary.md @@ -0,0 +1,11 @@ +# Keep sambrs an opinionated SMB client boundary + +sambrs exists to keep unsafe Windows FFI, UTF-16 conversion, buffer and handle ownership, validation, cleanup, and WNet quirks out of its consuming application. It is a Windows-only client for connecting to and inspecting existing disk shares, not a complete or unopinionated wrapper over the Windows networking APIs. + +## Consequences + +The public API uses SMB concepts rather than raw WNet flags and metadata. It supports deviceless, explicit-drive, and auto-assigned connections; temporary and remembered mappings; default, paired, and partial credentials; signing and encryption requirements; forced disconnect and forgetting mappings; guarded temporary mappings; and focused query and enumeration operations. + +Server administration, share creation, printers, arbitrary local devices, custom network providers, raw enumeration, interactive authentication, write-through connections, and file-transfer logic are out of scope. Tracing and password zeroization are unconditional: traces may include UNC paths, drive letters, and usernames, but never passwords. + +Invalid combinations are rejected before Windows is called: persistence requires a drive mapping, guarded connections cannot persist, and forgetting requires a drive mapping. Deviceless connections are kept out of Windows' recent-connections list. diff --git a/src/enumerate.rs b/src/enumerate.rs index 7a9c860..8ae8fd9 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -12,27 +12,18 @@ use crate::error::{Error, Result, wnet_extended_error}; use crate::strings::{from_pwstr, len_u32, to_wide}; -use crate::trace::trace; +use crate::trace::{debug, trace}; use std::collections::VecDeque; use windows_sys::Win32::Foundation::{ ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, HANDLE, NO_ERROR, }; use windows_sys::Win32::NetworkManagement::WNet; -/// An owned snapshot of one `NETRESOURCEW` entry. -/// -/// The `scope`, `resource_type`, `display_type`, and `usage` fields carry the -/// raw `RESOURCE_*`, `RESOURCETYPE_*`, `RESOURCEDISPLAYTYPE_*`, and -/// `RESOURCEUSAGE_*` values from `winnetwk.h` (available as constants in -/// `windows_sys::Win32::NetworkManagement::WNet`). +/// An owned snapshot of one disk-share resource. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct NetResource { - pub scope: u32, - pub resource_type: u32, - pub display_type: u32, - pub usage: u32, - /// Local device when this entry is a redirected connection, e.g. `"Z:"`. + /// Local drive when this entry is a mapped connection, e.g. `"Z:"`. pub local_name: Option, /// Remote name, e.g. `\\server\share`. pub remote_name: Option, @@ -41,19 +32,6 @@ pub struct NetResource { } impl NetResource { - /// Whether this resource is a container (server, domain) that can itself - /// be enumerated. - #[must_use] - pub fn is_container(&self) -> bool { - self.usage & WNet::RESOURCEUSAGE_CONTAINER != 0 - } - - /// Whether this resource can be connected to. - #[must_use] - pub fn is_connectable(&self) -> bool { - self.usage & WNet::RESOURCEUSAGE_CONNECTABLE != 0 - } - /// # Safety /// The string pointers in `raw` must each be null or point to a valid /// nul-terminated UTF-16 string — as they are in a `NETRESOURCEW` @@ -63,10 +41,6 @@ impl NetResource { // SAFETY: guaranteed by caller. unsafe { Self { - scope: raw.dwScope, - resource_type: raw.dwType, - display_type: raw.dwDisplayType, - usage: raw.dwUsage, local_name: from_pwstr(raw.lpLocalName), remote_name: from_pwstr(raw.lpRemoteName), comment: from_pwstr(raw.lpComment), @@ -125,6 +99,7 @@ impl Resources { &raw mut size, ) }; + debug!("WNetEnumResourceW returned {status} (entries={count}, bytes={size})"); match status { NO_ERROR => { trace!("WNetEnumResourceW returned {count} entries"); @@ -171,9 +146,8 @@ impl Resources { impl Drop for Resources { fn drop(&mut self) { // SAFETY: the handle came from WNetOpenEnumW and is closed only here. - unsafe { - WNet::WNetCloseEnum(self.handle); - } + let status = unsafe { WNet::WNetCloseEnum(self.handle) }; + debug!("WNetCloseEnum returned {status}"); } } @@ -206,6 +180,7 @@ fn open( &raw mut handle, ) }; + debug!("WNetOpenEnumW returned {status}"); match status { NO_ERROR => Ok(Resources { handle, @@ -224,7 +199,8 @@ fn open( /// # Errors /// See [`Error`]. pub fn connections() -> Result { - open(WNet::RESOURCE_CONNECTED, WNet::RESOURCETYPE_ANY, 0, None) + trace!("enumerating active disk-share connections"); + open(WNet::RESOURCE_CONNECTED, WNet::RESOURCETYPE_DISK, 0, None) } /// Remembered (persistent) connections (`RESOURCE_REMEMBERED`) — restored at @@ -233,11 +209,12 @@ pub fn connections() -> Result { /// # Errors /// See [`Error`]. pub fn remembered() -> Result { - open(WNet::RESOURCE_REMEMBERED, WNet::RESOURCETYPE_ANY, 0, None) + trace!("enumerating remembered disk-share mappings"); + open(WNet::RESOURCE_REMEMBERED, WNet::RESOURCETYPE_DISK, 0, None) } -/// The resources a server exposes (`RESOURCE_GLOBALNET` rooted at `server`, -/// e.g. `r"\\fileserver"`): its shares, including shared printers. +/// The disk shares a server exposes (`RESOURCE_GLOBALNET` rooted at `server`, +/// e.g. `r"\\fileserver"`). /// /// The server may require an authenticated connection (e.g. to `IPC$`) /// before it lets you enumerate. @@ -246,25 +223,11 @@ pub fn remembered() -> Result { /// `ERROR_BAD_NET_NAME` / `ERROR_NO_NET_OR_BAD_PATH` when the server cannot /// be found, or `ERROR_ACCESS_DENIED` when it refuses anonymous enumeration. pub fn server_shares(server: &str) -> Result { + trace!("enumerating disk shares on {server}"); open( WNet::RESOURCE_GLOBALNET, - WNet::RESOURCETYPE_ANY, + WNet::RESOURCETYPE_DISK, 0, Some(server), ) } - -/// Fully general enumeration — pass raw `RESOURCE_*` scope, `RESOURCETYPE_*` -/// type, and `RESOURCEUSAGE_*` usage values, and optionally the remote name -/// of a container to enumerate (as in [`server_shares`]). -/// -/// # Errors -/// See [`Error`]. -pub fn resources_raw( - scope: u32, - resource_type: u32, - usage: u32, - root_remote: Option<&str>, -) -> Result { - open(scope, resource_type, usage, root_remote) -} diff --git a/src/error.rs b/src/error.rs index af067f7..5b1735d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,4 @@ use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, NO_ERROR}; -use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success as NERR_SUCCESS; use windows_sys::Win32::NetworkManagement::WNet; pub type Result = std::result::Result; @@ -12,7 +11,15 @@ pub enum Error { InteriorNul, /// The character is not an ASCII drive letter. InvalidDriveLetter(char), - /// A Windows or `NERR_*` status code. + /// Persistence was requested without a local drive mapping. + PersistenceRequiresDrive, + /// A guarded connection was requested without a local drive mapping. + GuardRequiresDrive, + /// A guarded connection was configured to persist beyond the guard's lifetime. + PersistentGuard, + /// Forgetting was requested for a connection without a local drive mapping. + ForgetRequiresDrive, + /// A Windows status code. Windows(u32), /// A provider-specific error resolved through `WNetGetLastErrorW`. ExtendedError { @@ -31,6 +38,14 @@ impl std::fmt::Display for Error { Self::InvalidDriveLetter(c) => { write!(f, "'{c}' is not a valid drive letter (expected A-Z)") } + Self::PersistenceRequiresDrive => { + f.write_str("persistent SMB connections require a drive mapping") + } + Self::GuardRequiresDrive => { + f.write_str("a guarded SMB connection requires a drive mapping") + } + Self::PersistentGuard => f.write_str("a guarded SMB connection cannot be persistent"), + Self::ForgetRequiresDrive => f.write_str("only a drive mapping can be forgotten"), Self::Windows(code) => write!(f, "Windows error {code}: {}", os_message(*code)), Self::ExtendedError { code, @@ -47,91 +62,10 @@ impl std::fmt::Display for Error { impl std::error::Error for Error {} fn os_message(code: u32) -> String { - use windows_sys::Win32::NetworkManagement::NetManagement::{MAX_NERR, NERR_BASE}; - - // NERR_* messages live in netmsg.dll's message table, which the system - // table behind `from_raw_os_error` cannot see (it would show an - // unknown-error placeholder for them). - if (NERR_BASE..=MAX_NERR).contains(&code) { - if let Some(msg) = netmsg_message(code) { - return msg; - } - } #[allow(clippy::cast_possible_wrap)] std::io::Error::from_raw_os_error(code as i32).to_string() } -/// Look up a message in netmsg.dll's message table; `None` when the module -/// or the message is unavailable (the caller then falls back to the system -/// message table). -fn netmsg_message(code: u32) -> Option { - use windows_sys::Win32::Foundation::FreeLibrary; - use windows_sys::Win32::System::Diagnostics::Debug::{ - FORMAT_MESSAGE_FROM_HMODULE, FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW, - }; - use windows_sys::Win32::System::LibraryLoader::{LOAD_LIBRARY_AS_DATAFILE, LoadLibraryExW}; - - let netmsg = crate::strings::to_wide("netmsg.dll").ok()?; - // SAFETY: `netmsg` is a valid nul-terminated string; the returned module - // handle is freed below, after the message has been copied out of it. - let module = unsafe { - LoadLibraryExW( - netmsg.as_ptr(), - std::ptr::null_mut(), - LOAD_LIBRARY_AS_DATAFILE, - ) - }; - if module.is_null() { - return None; - } - let mut buf = [0u16; 512]; - // SAFETY: `module` stays valid and `buf` outlives the call; - // IGNORE_INSERTS guarantees the null `arguments` is never read. - let len = unsafe { - FormatMessageW( - FORMAT_MESSAGE_FROM_HMODULE | FORMAT_MESSAGE_IGNORE_INSERTS, - module, - code, - 0, // default language search order - buf.as_mut_ptr(), - crate::strings::len_u32(buf.len()), - std::ptr::null(), - ) - }; - // SAFETY: `module` came from LoadLibraryExW and is freed exactly once. - unsafe { - FreeLibrary(module); - } - // Messages end with "\r\n"; io::Error's Display doesn't have trailing - // whitespace either. - (len > 0).then(|| crate::strings::from_wide_buf(&buf).trim_end().to_string()) -} - -impl Error { - /// The underlying Windows status code, if this error has one. - #[must_use] - pub fn raw_os_error(&self) -> Option { - match self { - Self::Windows(code) => Some(*code), - Self::ExtendedError { .. } => Some(ERROR_EXTENDED_ERROR), - Self::InteriorNul | Self::InvalidDriveLetter(_) => None, - } - } -} - -impl From for std::io::Error { - fn from(error: Error) -> Self { - match error { - #[allow(clippy::cast_possible_wrap)] - Error::Windows(code) => Self::from_raw_os_error(code as i32), - Error::ExtendedError { .. } => Self::other(error), - Error::InteriorNul | Error::InvalidDriveLetter(_) => { - Self::new(std::io::ErrorKind::InvalidInput, error) - } - } - } -} - /// Turn a `WNet` status code into a `Result`, resolving `ERROR_EXTENDED_ERROR` /// into the provider-specific error details. pub(crate) fn check_wnet(status: u32) -> Result<()> { @@ -142,15 +76,6 @@ pub(crate) fn check_wnet(status: u32) -> Result<()> { } } -/// Turn a netapi32 status code into a `Result`. -pub(crate) fn check_net(status: u32) -> Result<()> { - if status == NERR_SUCCESS { - Ok(()) - } else { - Err(Error::Windows(status)) - } -} - /// Fetch the provider-specific error behind `ERROR_EXTENDED_ERROR` via /// `WNetGetLastErrorW`. Must be called on the same thread that received the /// failing status, before any other `WNet` call. @@ -167,6 +92,7 @@ pub(crate) fn wnet_extended_error() -> Error { crate::strings::len_u32(provider.len()), ) }; + crate::trace::debug!("WNetGetLastErrorW returned {status} (provider status {code})"); if status == NO_ERROR { Error::ExtendedError { code, @@ -181,52 +107,3 @@ pub(crate) fn wnet_extended_error() -> Error { } } } - -#[cfg(test)] -mod tests { - use super::*; - use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; - - #[test] - fn io_error_conversion_keeps_extended_error_details() { - let e = Error::ExtendedError { - code: 59, - description: String::from("the provider says no"), - provider: String::from("Test Provider"), - }; - let io: std::io::Error = e.into(); - assert_eq!(io.raw_os_error(), None); - let msg = io.to_string(); - assert!(msg.contains("59"), "provider code lost: {msg}"); - assert!( - msg.contains("the provider says no"), - "description lost: {msg}" - ); - assert!(msg.contains("Test Provider"), "provider name lost: {msg}"); - } - - #[test] - fn io_error_conversion_maps_windows_codes() { - let io: std::io::Error = Error::Windows(ERROR_ACCESS_DENIED).into(); - #[allow(clippy::cast_possible_wrap)] - { - assert_eq!(io.raw_os_error(), Some(ERROR_ACCESS_DENIED as i32)); - } - } - - #[test] - fn io_error_conversion_flags_input_validation_errors() { - let io: std::io::Error = Error::InteriorNul.into(); - assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput); - } - - #[test] - fn windows_error_resolves_nerr_messages_from_netmsg_dll() { - // NERR_NetNotStarted (2102) has no system-table message; it must - // come from netmsg.dll. The text is locale-dependent, so assert only - // that a message was found and that Error's Display uses it. - let msg = netmsg_message(2102).expect("netmsg.dll must resolve NERR codes"); - assert!(!msg.is_empty()); - assert!(Error::Windows(2102).to_string().contains(&msg)); - } -} diff --git a/src/lib.rs b/src/lib.rs index 7e60dcc..e65b8c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,8 +10,7 @@ mod trace; pub mod enumerate; pub mod query; -pub mod server; pub use error::{Error, Result}; -pub use options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; +pub use options::{ConnectOptions, DisconnectOptions, DriveLetter}; pub use target::{Connection, SmbTarget, cancel_connection}; diff --git a/src/options.rs b/src/options.rs index 82e3465..f4a893a 100644 --- a/src/options.rs +++ b/src/options.rs @@ -58,40 +58,10 @@ impl std::fmt::Display for DriveLetter { } } -/// The type of network resource to connect to (`dwType`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[non_exhaustive] -#[repr(u32)] -pub enum ResourceType { - /// A disk share (`RESOURCETYPE_DISK`). The default. - #[default] - Disk = WNet::RESOURCETYPE_DISK, - /// A shared printer (`RESOURCETYPE_PRINT`). - Print = WNet::RESOURCETYPE_PRINT, - /// Any resource type (`RESOURCETYPE_ANY`). - /// - /// Only valid for deviceless connections: Windows rejects it with - /// `ERROR_INVALID_PARAMETER` when a local device is redirected — - /// whether configured explicitly or chosen automatically by - /// [`SmbTarget::connect_auto`](crate::SmbTarget::connect_auto). - Any = WNet::RESOURCETYPE_ANY, -} - -/// Options for [`SmbTarget::connect_with`](crate::SmbTarget::connect_with), -/// covering every documented `CONNECT_*` flag of `WNetAddConnection2W` / -/// `WNetUseConnectionW`. +/// Options for [`SmbTarget::connect_with`](crate::SmbTarget::connect_with). /// -/// The default (`ConnectOptions::new()`) is a temporary, non-interactive -/// connection — the same behavior as -/// [`SmbTarget::connect`](crate::SmbTarget::connect). -/// -/// ``` -/// use sambrs::ConnectOptions; -/// -/// let opts = ConnectOptions::new() -/// .persist(true) -/// .require_privacy(true); // enforce SMB encryption -/// ``` +/// The default is a temporary, non-interactive connection. Deviceless +/// connections are kept out of Windows' recent-connections list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[must_use] pub struct ConnectOptions { @@ -107,7 +77,7 @@ impl Default for ConnectOptions { impl ConnectOptions { pub const fn new() -> Self { Self { - flags: WNet::CONNECT_TEMPORARY, + flags: WNet::CONNECT_TEMPORARY | WNet::CONNECT_UPDATE_RECENT, } } @@ -120,101 +90,26 @@ impl ConnectOptions { self } - /// `CONNECT_UPDATE_PROFILE`: remember the connection and restore it when - /// the user logs on again. - /// - /// From the [Microsoft docs](https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2w): - /// the operating system remembers only successful connections that - /// redirect local devices; deviceless connections are not remembered. - /// When this is `false` (the default), `CONNECT_TEMPORARY` is passed - /// instead. - pub fn persist(mut self, yes: bool) -> Self { - self.flags &= !(WNet::CONNECT_UPDATE_PROFILE | WNet::CONNECT_TEMPORARY); - self.flags |= if yes { - WNet::CONNECT_UPDATE_PROFILE - } else { - WNet::CONNECT_TEMPORARY - }; - self - } - - /// `CONNECT_UPDATE_RECENT`: keep the connection out of the recent - /// connection list unless it has a redirected local device associated - /// with it. - pub fn update_recent(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_UPDATE_RECENT, yes) - } - - /// `CONNECT_INTERACTIVE`: the operating system may interact with the user - /// for authentication purposes, e.g. by showing a password prompt instead - /// of failing with `ERROR_INVALID_PASSWORD`. - pub fn interactive(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_INTERACTIVE, yes) - } - - /// `CONNECT_PROMPT`: always prompt for the user name and password instead - /// of first trying supplied or remembered credentials. Only valid - /// together with [`interactive`](Self::interactive). - pub fn prompt(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_PROMPT, yes) + pub(crate) fn is_persistent(self) -> bool { + self.flags & WNet::CONNECT_UPDATE_PROFILE != 0 } - /// `CONNECT_COMMANDLINE`: prompt for authentication on the command line - /// instead of with a GUI dialog. Only valid together with - /// [`interactive`](Self::interactive); also a prerequisite for - /// [`save_credentials`](Self::save_credentials) and - /// [`reset_credentials`](Self::reset_credentials) to take effect. - pub fn commandline(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_COMMANDLINE, yes) + /// Remember this drive mapping and restore it when the user logs on. + /// Persistence requires an explicit or automatically assigned drive. + pub fn persist(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_UPDATE_PROFILE, yes) + .flag(WNet::CONNECT_TEMPORARY, !yes) } - /// `CONNECT_REDIRECT`: force the redirection of a local device even if a - /// local device name was not specified. - pub fn redirect(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_REDIRECT, yes) - } - - /// `CONNECT_CURRENT_MEDIA`: do not start a new media to establish the - /// connection (e.g. do not initiate a new dial-up connection). - pub fn current_media(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_CURRENT_MEDIA, yes) - } - - /// `CONNECT_CMD_SAVECRED`: if the operating system prompts for a - /// credential, it is saved by the credential manager. - /// - /// Windows ignores this flag unless [`interactive`](Self::interactive) - /// and [`commandline`](Self::commandline) are also set. - pub fn save_credentials(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_CMD_SAVECRED, yes) - } - - /// `CONNECT_CRED_RESET`: reset the credentials for this connection that - /// are saved by the credential manager. - /// - /// Windows ignores this flag unless [`commandline`](Self::commandline) - /// is also set. - pub fn reset_credentials(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_CRED_RESET, yes) - } - - /// `CONNECT_REQUIRE_INTEGRITY`: fail the connection if SMB signing cannot - /// be enforced. + /// Fail if SMB signing cannot be enforced. pub fn require_integrity(self, yes: bool) -> Self { self.flag(WNet::CONNECT_REQUIRE_INTEGRITY, yes) } - /// `CONNECT_REQUIRE_PRIVACY`: fail the connection if SMB encryption - /// cannot be enforced. + /// Fail if SMB encryption cannot be enforced. pub fn require_privacy(self, yes: bool) -> Self { self.flag(WNet::CONNECT_REQUIRE_PRIVACY, yes) } - - /// `CONNECT_WRITE_THROUGH_SEMANTICS`: writes go through to the server - /// before the operation completes (no write caching). - pub fn write_through(self, yes: bool) -> Self { - self.flag(WNet::CONNECT_WRITE_THROUGH_SEMANTICS, yes) - } } /// Options for [`SmbTarget::disconnect_with`](crate::SmbTarget::disconnect_with) @@ -231,21 +126,14 @@ impl DisconnectOptions { Self::default() } - /// Disconnect even if there are open files or jobs on the connection. - /// When `false` (the default), disconnecting fails with - /// `ERROR_OPEN_FILES` if anything is still open. + /// Disconnect even if files or jobs remain open on the mapping. pub fn force(mut self, yes: bool) -> Self { self.force = yes; self } - /// `CONNECT_UPDATE_PROFILE`: update the user profile so this connection - /// is no longer persistent — the system will not restore it on the next - /// logon. Disconnecting by remote name (deviceless) has no effect on - /// persistent connections. - /// - /// Note: this was called `persist` in sambrs 0.1, which suggested the - /// opposite of what the flag does. + /// Remove this drive mapping from the user profile so Windows will not + /// restore it at the next logon. pub fn forget(mut self, yes: bool) -> Self { self.forget = yes; self @@ -277,52 +165,32 @@ mod tests { } #[test] - fn default_options_are_temporary() { - assert_eq!(ConnectOptions::new().flags, WNet::CONNECT_TEMPORARY); + fn default_options_are_temporary_and_suppress_recent_deviceless_connections() { assert_eq!( - ConnectOptions::new() - .interactive(true) - .interactive(false) - .flags, - WNet::CONNECT_TEMPORARY + ConnectOptions::new().flags, + WNet::CONNECT_TEMPORARY | WNet::CONNECT_UPDATE_RECENT ); } #[test] fn persist_replaces_temporary() { - let flags = ConnectOptions::new().persist(true).flags; - assert_eq!(flags, WNet::CONNECT_UPDATE_PROFILE); + assert_eq!( + ConnectOptions::new().persist(true).flags, + WNet::CONNECT_UPDATE_PROFILE | WNet::CONNECT_UPDATE_RECENT + ); } #[test] - fn all_flags_map() { - let flags = ConnectOptions::new() - .update_recent(true) - .interactive(true) - .prompt(true) - .commandline(true) - .redirect(true) - .current_media(true) - .save_credentials(true) - .reset_credentials(true) - .require_integrity(true) - .require_privacy(true) - .write_through(true) - .flags; + fn security_options_map() { assert_eq!( - flags, + ConnectOptions::new() + .require_integrity(true) + .require_privacy(true) + .flags, WNet::CONNECT_TEMPORARY | WNet::CONNECT_UPDATE_RECENT - | WNet::CONNECT_INTERACTIVE - | WNet::CONNECT_PROMPT - | WNet::CONNECT_COMMANDLINE - | WNet::CONNECT_REDIRECT - | WNet::CONNECT_CURRENT_MEDIA - | WNet::CONNECT_CMD_SAVECRED - | WNet::CONNECT_CRED_RESET | WNet::CONNECT_REQUIRE_INTEGRITY | WNet::CONNECT_REQUIRE_PRIVACY - | WNet::CONNECT_WRITE_THROUGH_SEMANTICS ); } diff --git a/src/query.rs b/src/query.rs index 8078a5c..5fc413c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -4,16 +4,19 @@ use crate::error::{Error, Result, wnet_extended_error}; use crate::strings::{from_pwstr, len_u32, opt_ptr, to_wide}; +use crate::trace::{debug, trace}; use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, NO_ERROR}; use windows_sys::Win32::NetworkManagement::WNet; /// Call a `WNet` function that fills a wide-string output buffer, growing the /// buffer when Windows reports `ERROR_MORE_DATA`. -fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { +fn wide_out(operation: &str, mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { let mut buf = vec![0u16; 256]; for _ in 0..4 { let mut len = len_u32(buf.len()); - match call(buf.as_mut_ptr(), &raw mut len) { + let status = call(buf.as_mut_ptr(), &raw mut len); + debug!("{operation} returned {status}"); + match status { NO_ERROR => return Ok(crate::strings::from_wide_buf(&buf)), // `len` now holds the required size in characters. ERROR_MORE_DATA => buf = vec![0u16; len as usize + 1], @@ -24,8 +27,7 @@ fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { Err(Error::Windows(ERROR_MORE_DATA)) } -/// The remote name a redirected local device is connected to, via -/// `WNetGetConnectionW`. +/// The remote name a mapped drive is connected to, via `WNetGetConnectionW`. /// /// ```no_run /// let remote = sambrs::query::get_connection("Z:")?; @@ -37,10 +39,13 @@ fn wide_out(mut call: impl FnMut(*mut u16, *mut u32) -> u32) -> Result { /// `ERROR_NOT_CONNECTED` if the device is not redirected, /// `ERROR_CONNECTION_UNAVAIL` if it is remembered but not currently /// connected, or `ERROR_BAD_DEVICE` for an invalid device name. -pub fn get_connection(local_device: &str) -> Result { - let device = to_wide(local_device)?; +pub fn get_connection(drive: &str) -> Result { + trace!("querying remote share mapped to {drive}"); + let device = to_wide(drive)?; // SAFETY: `device` outlives the call; the buffer is sized via `len`. - wide_out(|buf, len| unsafe { WNet::WNetGetConnectionW(device.as_ptr(), buf, len) }) + wide_out("WNetGetConnectionW", |buf, len| unsafe { + WNet::WNetGetConnectionW(device.as_ptr(), buf, len) + }) } /// The user name used to establish a connection, via `WNetGetUserW`. @@ -51,9 +56,17 @@ pub fn get_connection(local_device: &str) -> Result { /// # Errors /// `ERROR_NOT_CONNECTED` if the name is not a connected resource. pub fn get_user(connection: Option<&str>) -> Result { + trace!( + "querying user for {}", + connection.unwrap_or("") + ); let name = connection.map(to_wide).transpose()?; // SAFETY: `name` (when present) outlives the call. - wide_out(|buf, len| unsafe { WNet::WNetGetUserW(opt_ptr(name.as_deref()), buf, len) }) + let user = wide_out("WNetGetUserW", |buf, len| unsafe { + WNet::WNetGetUserW(opt_ptr(name.as_deref()), buf, len) + })?; + debug!("WNetGetUserW resolved user {user}"); + Ok(user) } /// The UNC path for a local path on a redirected drive, via @@ -64,6 +77,7 @@ pub fn get_user(connection: Option<&str>) -> Result { /// `ERROR_NOT_SUPPORTED` or `ERROR_NOT_CONNECTED` when the path is not on a /// network-redirected device. pub fn get_universal_name(local_path: &str) -> Result { + trace!("resolving universal name for mapped path"); let path = to_wide(local_path)?; // u64 elements keep the buffer aligned for UNIVERSAL_NAME_INFOW. let mut buf = vec![0u64; 128]; @@ -79,6 +93,7 @@ pub fn get_universal_name(local_path: &str) -> Result { &raw mut size, ) }; + debug!("WNetGetUniversalNameW returned {status}"); match status { NO_ERROR => { // SAFETY: on success the buffer starts with a diff --git a/src/server.rs b/src/server.rs deleted file mode 100644 index 8b80760..0000000 --- a/src/server.rs +++ /dev/null @@ -1,874 +0,0 @@ -//! Server-side SMB administration via netapi32: enumerate, inspect, create, -//! and delete shares; list and end sessions; list and close open files; and -//! list the connections made to a share. -//! -//! Every function takes the target server as `Option<&str>` — `None` means -//! the local machine, `Some(r"\\fileserver")` administers a remote one. Most -//! of these calls require administrative rights on the target; the -//! enumeration functions fall back to a lower information level (fewer -//! fields, see the struct docs) when access is denied. -//! -//! ```no_run -//! use sambrs::server; -//! -//! // Create a share on the local machine, list everything, delete it again. -//! server::add_share(None, &server::NewShare::disk("scratch", r"C:\scratch"))?; -//! for share in server::shares(None)? { -//! println!("{} -> {:?}", share.name, share.path); -//! } -//! server::delete_share(None, "scratch")?; -//! # Ok::<(), sambrs::Error>(()) -//! ``` - -use crate::error::{Error, Result, check_net}; -use crate::strings::{from_pwstr, opt_ptr, to_wide}; -use crate::trace::debug; -use std::time::Duration; -use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, -}; -use windows_sys::Win32::NetworkManagement::NetManagement::{ - MAX_PREFERRED_LENGTH, NERR_Success as NERR_SUCCESS, NetApiBufferFree, -}; -use windows_sys::Win32::Storage::FileSystem::{ - CONNECTION_INFO_1, FILE_INFO_3, NetConnectionEnum, NetFileClose, NetFileEnum, NetSessionDel, - NetSessionEnum, NetShareAdd, NetShareDel, NetShareEnum, NetShareGetInfo, PERM_FILE_CREATE, - PERM_FILE_READ, PERM_FILE_WRITE, SESS_GUEST, SESSION_INFO_1, SESSION_INFO_10, SHARE_INFO_1, - SHARE_INFO_2, SHI_USES_UNLIMITED, STYPE_DEVICE, STYPE_DISKTREE, STYPE_IPC, STYPE_MASK, - STYPE_PRINTQ, STYPE_SPECIAL, STYPE_TEMPORARY, -}; - -/// Frees a netapi32-allocated buffer on drop. -struct NetBuffer(*mut u8); - -impl Drop for NetBuffer { - fn drop(&mut self) { - if !self.0.is_null() { - // SAFETY: the pointer was allocated by netapi32 and is freed once. - unsafe { - NetApiBufferFree(self.0.cast()); - } - } - } -} - -/// Run a `Net*Enum` call to completion, handling the resume/`ERROR_MORE_DATA` -/// protocol and buffer ownership. `call` receives the out-buffer pointer and -/// the entries-read / total-entries out-params; `map` converts each returned -/// entry while the buffer is still alive. -/// -/// The protocol contract says every `ERROR_MORE_DATA` batch delivers entries -/// and advances the resume handle; a malformed or malicious server can -/// violate that, so the loop fails with `Error::Windows(ERROR_MORE_DATA)` -/// instead of spinning forever: immediately when a batch delivers nothing -/// (the next call would repeat the identical request), and after -/// `MAX_BATCHES` batches as a backstop against a resume handle that yields -/// entries but never terminates. -fn net_enum( - mut call: impl FnMut(*mut *mut u8, *mut u32, *mut u32) -> u32, - mut map: impl FnMut(&T) -> U, -) -> Result> { - // Batches are MAX_PREFERRED_LENGTH-sized (remotely still tens of KB), so - // thousands of batches are far beyond any real enumeration. - const MAX_BATCHES: u32 = 4096; - let mut out = Vec::new(); - for _ in 0..MAX_BATCHES { - let mut buf: *mut u8 = std::ptr::null_mut(); - let mut read = 0u32; - let mut total = 0u32; - let status = call(&raw mut buf, &raw mut read, &raw mut total); - let _guard = NetBuffer(buf); - match status { - NERR_SUCCESS | ERROR_MORE_DATA => { - if !buf.is_null() { - // SAFETY: netapi32 returned `read` entries of type T in a - // properly aligned buffer owned by `_guard`. - let entries = - unsafe { std::slice::from_raw_parts(buf.cast::(), read as usize) }; - out.extend(entries.iter().map(&mut map)); - } - if status == NERR_SUCCESS { - return Ok(out); - } - if buf.is_null() || read == 0 { - // ERROR_MORE_DATA with an empty batch: no progress was - // made, so looping would repeat the identical call. - return Err(Error::Windows(ERROR_MORE_DATA)); - } - // ERROR_MORE_DATA: loop again, the resume handle captured by - // `call` continues where this batch ended. - } - code => return Err(Error::Windows(code)), - } - } - Err(Error::Windows(ERROR_MORE_DATA)) -} - -/// Owned `String` from a nul-terminated wide pointer; `None` when the pointer -/// is null **or** the string is empty. netapi32 uses null and empty -/// interchangeably for "no value", so every optional string field of the -/// structs below normalizes both to `None`. -/// -/// # Safety -/// As [`from_pwstr`]: `ptr` must either be null or point to a valid -/// nul-terminated UTF-16 string. -unsafe fn from_pwstr_nonempty(ptr: *const u16) -> Option { - // SAFETY: guaranteed by caller. - unsafe { from_pwstr(ptr) }.filter(|s| !s.is_empty()) -} - -/// The type of a share, unpacked from the raw `STYPE_*` value. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub struct ShareType { - pub kind: ShareKind, - /// `STYPE_SPECIAL`: an administrative share (`C$`, `ADMIN$`, `IPC$`, …). - pub special: bool, - /// `STYPE_TEMPORARY`: not persistent across server restarts. - pub temporary: bool, -} - -/// The base kind of a share. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[non_exhaustive] -pub enum ShareKind { - /// A disk share (`STYPE_DISKTREE`). - #[default] - Disk, - /// A print queue (`STYPE_PRINTQ`). - PrintQueue, - /// A communication device (`STYPE_DEVICE`). - Device, - /// Interprocess communication, i.e. `IPC$` (`STYPE_IPC`). - Ipc, - /// Any other raw `STYPE_*` base value. - Other(u32), -} - -impl ShareType { - fn from_raw(raw: u32) -> Self { - let kind = match raw & STYPE_MASK { - STYPE_DISKTREE => ShareKind::Disk, - STYPE_PRINTQ => ShareKind::PrintQueue, - STYPE_DEVICE => ShareKind::Device, - STYPE_IPC => ShareKind::Ipc, - other => ShareKind::Other(other), - }; - Self { - kind, - special: raw & STYPE_SPECIAL != 0, - temporary: raw & STYPE_TEMPORARY != 0, - } - } -} - -impl ShareKind { - fn to_raw(self) -> u32 { - match self { - Self::Disk => STYPE_DISKTREE, - Self::PrintQueue => STYPE_PRINTQ, - Self::Device => STYPE_DEVICE, - Self::Ipc => STYPE_IPC, - Self::Other(raw) => raw, - } - } -} - -/// A share on a server, from `NetShareEnum` / `NetShareGetInfo`. -/// -/// The `path`, `permissions`, `max_uses`, and `current_uses` fields come from -/// information level 2, which requires administrative rights on the target -/// server; they are `None` when the call had to fall back to level 1. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ShareInfo { - pub name: String, - pub share_type: ShareType, - /// Comment shown next to the share; `None` when absent or empty. - pub remark: Option, - /// Local path backing the share, e.g. `C:\data` (level 2 only); `None` - /// when absent or empty (`IPC$` has no path). - pub path: Option, - /// Share-level permission bits, `ACCESS_*` (level 2 only). - pub permissions: Option, - /// Concurrent-user limit; `u32::MAX` means unlimited (level 2 only). - pub max_uses: Option, - /// Current number of connections to the share (level 2 only). - pub current_uses: Option, -} - -impl ShareInfo { - /// # Safety - /// `info` must come from a live netapi32 level-2 buffer. - unsafe fn from_level2(info: &SHARE_INFO_2) -> Self { - // SAFETY: guaranteed by caller. - unsafe { - Self { - name: from_pwstr(info.shi2_netname).unwrap_or_default(), - share_type: ShareType::from_raw(info.shi2_type), - remark: from_pwstr_nonempty(info.shi2_remark), - path: from_pwstr_nonempty(info.shi2_path), - permissions: Some(info.shi2_permissions), - max_uses: Some(info.shi2_max_uses), - current_uses: Some(info.shi2_current_uses), - } - } - } - - /// # Safety - /// `info` must come from a live netapi32 level-1 buffer. - unsafe fn from_level1(info: &SHARE_INFO_1) -> Self { - // SAFETY: guaranteed by caller. - unsafe { - Self { - name: from_pwstr(info.shi1_netname).unwrap_or_default(), - share_type: ShareType::from_raw(info.shi1_type), - remark: from_pwstr_nonempty(info.shi1_remark), - path: None, - permissions: None, - max_uses: None, - current_uses: None, - } - } - } -} - -/// List the shares on a server via `NetShareEnum`. -/// -/// Tries information level 2 (full detail, administrative rights) first and -/// transparently falls back to level 1 on `ERROR_ACCESS_DENIED`. -/// -/// # Errors -/// See [`Error`]. -pub fn shares(server: Option<&str>) -> Result> { - let server_w = server.map(to_wide).transpose()?; - let server_ptr = opt_ptr(server_w.as_deref()); - - let mut resume = 0u32; - match net_enum::( - |buf, read, total| unsafe { - NetShareEnum( - server_ptr, - 2, - buf, - MAX_PREFERRED_LENGTH, - read, - total, - &raw mut resume, - ) - }, - |info| unsafe { ShareInfo::from_level2(info) }, - ) { - Ok(out) => Ok(out), - Err(Error::Windows(ERROR_ACCESS_DENIED)) => { - debug!("NetShareEnum level 2 denied, falling back to level 1"); - let mut resume = 0u32; - net_enum::( - |buf, read, total| unsafe { - NetShareEnum( - server_ptr, - 1, - buf, - MAX_PREFERRED_LENGTH, - read, - total, - &raw mut resume, - ) - }, - |info| unsafe { ShareInfo::from_level1(info) }, - ) - } - Err(e) => Err(e), - } -} - -/// Details of a single share via `NetShareGetInfo`, with the same level-2 → -/// level-1 fallback as [`shares`]. -/// -/// # Errors -/// `NERR_NetNameNotFound` when the share does not exist. -// netapi32 allocates its info buffers with alignment suitable for any of its -// info structures, so the u8 -> SHARE_INFO_* casts below are sound. -#[allow(clippy::cast_ptr_alignment)] -pub fn share_info(server: Option<&str>, name: &str) -> Result { - let server_w = server.map(to_wide).transpose()?; - let name_w = to_wide(name)?; - - let get = |level: u32| -> Result { - let mut buf: *mut u8 = std::ptr::null_mut(); - // SAFETY: pointers outlive the call; `buf` receives an allocation - // owned by the returned NetBuffer. - let status = unsafe { - NetShareGetInfo( - opt_ptr(server_w.as_deref()), - name_w.as_ptr(), - level, - &raw mut buf, - ) - }; - let guard = NetBuffer(buf); - check_net(status)?; - Ok(guard) - }; - - match get(2) { - Ok(buf) => { - // SAFETY: level-2 success means `buf` holds one SHARE_INFO_2. - Ok(unsafe { ShareInfo::from_level2(&*buf.0.cast::()) }) - } - Err(Error::Windows(ERROR_ACCESS_DENIED)) => { - let buf = get(1)?; - // SAFETY: level-1 success means `buf` holds one SHARE_INFO_1. - Ok(unsafe { ShareInfo::from_level1(&*buf.0.cast::()) }) - } - Err(e) => Err(e), - } -} - -/// Definition of a share to create with [`add_share`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct NewShare { - name: String, - path: String, - kind: ShareKind, - remark: Option, - permissions: u32, - max_uses: u32, -} - -impl NewShare { - /// A disk share exposing the local directory `path` (e.g. `C:\data`) as - /// `name`, with default share-level permissions (`ACCESS_ALL`) and no - /// user limit. - /// - /// Note that share-level permissions are largely historical — access is - /// normally governed by the NTFS ACLs of the backing directory. - pub fn disk(name: impl Into, path: impl Into) -> Self { - Self { - name: name.into(), - path: path.into(), - kind: ShareKind::Disk, - remark: None, - permissions: windows_sys::Win32::Storage::FileSystem::ACCESS_ALL, - max_uses: SHI_USES_UNLIMITED, - } - } - - /// An optional comment shown next to the share. - #[must_use] - pub fn remark(mut self, remark: impl Into) -> Self { - self.remark = Some(remark.into()); - self - } - - /// Override the share kind (e.g. [`ShareKind::PrintQueue`]). - #[must_use] - pub fn kind(mut self, kind: ShareKind) -> Self { - self.kind = kind; - self - } - - /// Override the share-level permission bits (`ACCESS_*`). - #[must_use] - pub fn permissions(mut self, permissions: u32) -> Self { - self.permissions = permissions; - self - } - - /// Limit the number of concurrent users. - #[must_use] - pub fn max_uses(mut self, max_uses: u32) -> Self { - self.max_uses = max_uses; - self - } -} - -/// Create a share via `NetShareAdd` (information level 2). Requires -/// administrative rights on the target server. -/// -/// # Errors -/// `NERR_DuplicateShare` when the name is taken, -/// `NERR_UnknownDevDir` when the backing path does not exist, -/// `ERROR_ACCESS_DENIED` without administrative rights. -pub fn add_share(server: Option<&str>, share: &NewShare) -> Result<()> { - let server_w = server.map(to_wide).transpose()?; - let name_w = to_wide(&share.name)?; - let path_w = to_wide(&share.path)?; - let remark_w = share.remark.as_deref().map(to_wide).transpose()?; - - let info = SHARE_INFO_2 { - shi2_netname: name_w.as_ptr().cast_mut(), - shi2_type: share.kind.to_raw(), - shi2_remark: opt_ptr(remark_w.as_deref()), - shi2_permissions: share.permissions, - shi2_max_uses: share.max_uses, - shi2_current_uses: 0, - shi2_path: path_w.as_ptr().cast_mut(), - shi2_passwd: std::ptr::null_mut(), - }; - - let mut parm_err = 0u32; - // SAFETY: `info` and all buffers it points into outlive the call. - let status = unsafe { - NetShareAdd( - opt_ptr(server_w.as_deref()), - 2, - std::ptr::from_ref(&info).cast(), - &raw mut parm_err, - ) - }; - if status != NERR_SUCCESS { - debug!("NetShareAdd failed with {status} (parameter index {parm_err})"); - } - check_net(status) -} - -/// Delete a share via `NetShareDel`. Requires administrative rights on the -/// target server. -/// -/// # Errors -/// `NERR_NetNameNotFound` when the share does not exist. -pub fn delete_share(server: Option<&str>, name: &str) -> Result<()> { - let server_w = server.map(to_wide).transpose()?; - let name_w = to_wide(name)?; - // SAFETY: both strings outlive the call. - let status = unsafe { NetShareDel(opt_ptr(server_w.as_deref()), name_w.as_ptr(), 0) }; - check_net(status) -} - -/// An SMB session established with a server, from `NetSessionEnum`. -/// -/// The `open_files` and `is_guest` fields come from information level 1, -/// which requires administrative rights; they are `None` when the call had -/// to fall back to level 10. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct SessionInfo { - /// Name of the computer that established the session. - pub client: String, - /// Name of the user who established the session; `None` when absent or - /// empty. - pub username: Option, - /// Number of files, devices, and pipes opened during the session - /// (level 1 only). - pub open_files: Option, - /// How long the session has been active. - pub active: Duration, - /// How long the session has been idle. - pub idle: Duration, - /// Whether the client connected as a guest (level 1 only). - pub is_guest: Option, -} - -impl SessionInfo { - /// # Safety - /// `info` must come from a live netapi32 level-1 buffer. - unsafe fn from_level1(info: &SESSION_INFO_1) -> Self { - // SAFETY: guaranteed by caller. - unsafe { - Self { - client: from_pwstr(info.sesi1_cname).unwrap_or_default(), - username: from_pwstr_nonempty(info.sesi1_username), - open_files: Some(info.sesi1_num_opens), - active: Duration::from_secs(u64::from(info.sesi1_time)), - idle: Duration::from_secs(u64::from(info.sesi1_idle_time)), - is_guest: Some(info.sesi1_user_flags & SESS_GUEST != 0), - } - } - } - - /// # Safety - /// `info` must come from a live netapi32 level-10 buffer. - unsafe fn from_level10(info: &SESSION_INFO_10) -> Self { - // SAFETY: guaranteed by caller. - unsafe { - Self { - client: from_pwstr(info.sesi10_cname).unwrap_or_default(), - username: from_pwstr_nonempty(info.sesi10_username), - open_files: None, - active: Duration::from_secs(u64::from(info.sesi10_time)), - idle: Duration::from_secs(u64::from(info.sesi10_idle_time)), - is_guest: None, - } - } - } -} - -/// List the SMB sessions on a server via `NetSessionEnum`, optionally -/// filtered by client computer name and/or user name. -/// -/// The `client` filter is the API's `UncClientName` parameter and must be in -/// UNC form with the leading backslashes (`r"\\workstation"`); note that -/// [`SessionInfo::client`] may report the bare name without them. -/// -/// Tries information level 1 (full detail, administrative rights) first and -/// transparently falls back to level 10 on `ERROR_ACCESS_DENIED`. -/// -/// # Errors -/// `NERR_ClientNameNotFound` / `NERR_UserNotFound` when a filter matches -/// nothing. -pub fn sessions( - server: Option<&str>, - client: Option<&str>, - username: Option<&str>, -) -> Result> { - let server_w = server.map(to_wide).transpose()?; - let client_w = client.map(to_wide).transpose()?; - let user_w = username.map(to_wide).transpose()?; - let (server_ptr, client_ptr, user_ptr) = ( - opt_ptr(server_w.as_deref()), - opt_ptr(client_w.as_deref()), - opt_ptr(user_w.as_deref()), - ); - - let mut resume = 0u32; - match net_enum::( - |buf, read, total| unsafe { - NetSessionEnum( - server_ptr, - client_ptr, - user_ptr, - 1, - buf, - MAX_PREFERRED_LENGTH, - read, - total, - &raw mut resume, - ) - }, - |info| unsafe { SessionInfo::from_level1(info) }, - ) { - Ok(out) => Ok(out), - Err(Error::Windows(ERROR_ACCESS_DENIED)) => { - debug!("NetSessionEnum level 1 denied, falling back to level 10"); - let mut resume = 0u32; - net_enum::( - |buf, read, total| unsafe { - NetSessionEnum( - server_ptr, - client_ptr, - user_ptr, - 10, - buf, - MAX_PREFERRED_LENGTH, - read, - total, - &raw mut resume, - ) - }, - |info| unsafe { SessionInfo::from_level10(info) }, - ) - } - Err(e) => Err(e), - } -} - -/// End SMB sessions on a server via `NetSessionDel`. Requires administrative -/// rights on the target server. -/// -/// `client` and `username` scope what is deleted: sessions from that -/// computer (in UNC form, `r"\\workstation"` — see [`sessions`]), sessions -/// of that user, or their intersection. At least one of the two is required, -/// and an empty string does not count — netapi32 treats it like a missing -/// filter. Ending every session on the server is deliberately a separate -/// function, [`delete_all_sessions`]. -/// -/// # Errors -/// `ERROR_INVALID_PARAMETER` when both `client` and `username` are `None` or -/// empty (use [`delete_all_sessions`] to end every session), -/// `NERR_ClientNameNotFound` / `NERR_UserNotFound` when nothing matches. -pub fn delete_session( - server: Option<&str>, - client: Option<&str>, - username: Option<&str>, -) -> Result<()> { - // netapi32 treats an empty filter string like a null one, so empty - // strings must not slip past the all-sessions guard below. - let client = client.filter(|c| !c.is_empty()); - let username = username.filter(|u| !u.is_empty()); - if client.is_none() && username.is_none() { - return Err(Error::Windows(ERROR_INVALID_PARAMETER)); - } - session_del(server, client, username) -} - -/// End **every** SMB session on a server via `NetSessionDel` with no client -/// or user filter. Requires administrative rights on the target server. -/// -/// **This disconnects all clients of the server at once**, without notifying -/// them — open files may lose data. For anything more targeted, use -/// [`delete_session`]. -/// -/// # Errors -/// `ERROR_ACCESS_DENIED` without administrative rights. -pub fn delete_all_sessions(server: Option<&str>) -> Result<()> { - session_del(server, None, None) -} - -fn session_del(server: Option<&str>, client: Option<&str>, username: Option<&str>) -> Result<()> { - let server_w = server.map(to_wide).transpose()?; - let client_w = client.map(to_wide).transpose()?; - let user_w = username.map(to_wide).transpose()?; - // SAFETY: all strings outlive the call. - let status = unsafe { - NetSessionDel( - opt_ptr(server_w.as_deref()), - opt_ptr(client_w.as_deref()), - opt_ptr(user_w.as_deref()), - ) - }; - check_net(status) -} - -/// A file, device, or pipe opened through SMB on a server, from -/// `NetFileEnum` (information level 3). -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct OpenFile { - /// Server-assigned identifier; pass to [`close_file`]. - pub id: u32, - pub path: String, - /// The user (or computer, for null sessions) that opened it; `None` when - /// absent or empty. - pub username: Option, - /// Number of locks held on the file. - pub locks: u32, - /// Raw `PERM_FILE_*` access bits; see the `can_*` methods. - pub permissions: u32, -} - -impl OpenFile { - #[must_use] - pub fn can_read(&self) -> bool { - self.permissions & PERM_FILE_READ != 0 - } - - #[must_use] - pub fn can_write(&self) -> bool { - self.permissions & PERM_FILE_WRITE != 0 - } - - #[must_use] - pub fn can_create(&self) -> bool { - self.permissions & PERM_FILE_CREATE != 0 - } -} - -/// List the open files/devices/pipes on a server via `NetFileEnum`, -/// optionally filtered to a base path prefix and/or user name. Requires -/// administrative rights on the target server. -/// -/// # Errors -/// `ERROR_ACCESS_DENIED` without administrative rights. -pub fn open_files( - server: Option<&str>, - base_path: Option<&str>, - username: Option<&str>, -) -> Result> { - let server_w = server.map(to_wide).transpose()?; - let path_w = base_path.map(to_wide).transpose()?; - let user_w = username.map(to_wide).transpose()?; - - let mut resume = 0usize; // NetFileEnum's resume handle is pointer-sized - net_enum::( - |buf, read, total| unsafe { - NetFileEnum( - opt_ptr(server_w.as_deref()), - opt_ptr(path_w.as_deref()), - opt_ptr(user_w.as_deref()), - 3, - buf, - MAX_PREFERRED_LENGTH, - read, - total, - &raw mut resume, - ) - }, - |info| { - // SAFETY: strings live in the enumeration buffer held by net_enum. - unsafe { - OpenFile { - id: info.fi3_id, - path: from_pwstr(info.fi3_pathname).unwrap_or_default(), - username: from_pwstr_nonempty(info.fi3_username), - locks: info.fi3_num_locks, - permissions: info.fi3_permissions, - } - } - }, - ) -} - -/// Force-close an open file/device/pipe by its [`OpenFile::id`] via -/// `NetFileClose`. Requires administrative rights on the target server. -/// -/// The client is not notified — use with care, this can cause data loss on -/// the client side. -/// -/// # Errors -/// `NERR_FileIdNotFound` when the id does not exist. -pub fn close_file(server: Option<&str>, id: u32) -> Result<()> { - let server_w = server.map(to_wide).transpose()?; - // SAFETY: the string outlives the call. - let status = unsafe { NetFileClose(opt_ptr(server_w.as_deref()), id) }; - check_net(status) -} - -/// A connection made to a shared resource, from `NetConnectionEnum` -/// (information level 1). -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ShareConnection { - /// Server-assigned connection identifier. - pub id: u32, - pub share_type: ShareType, - /// Number of files currently open through this connection. - pub open_files: u32, - /// Number of users on this connection. - pub users: u32, - /// How long the connection has been established. - pub active: Duration, - /// User on this connection; `None` when absent or empty. - pub username: Option, - /// Depending on the qualifier passed to [`connections`]: the share name - /// or the client computer name. `None` when absent or empty. - pub name: Option, -} - -/// List the connections to a share, or from a client computer, via -/// `NetConnectionEnum`. Requires administrative rights on the target server. -/// -/// `qualifier` is either a share name (`"data"`) — listing the connections -/// made to that share — or a computer name (`r"\\workstation"`) — listing -/// the connections made from that computer. -/// -/// # Errors -/// `NERR_NetNameNotFound` when the qualifier matches no share. -pub fn connections(server: Option<&str>, qualifier: &str) -> Result> { - let server_w = server.map(to_wide).transpose()?; - let qualifier_w = to_wide(qualifier)?; - - let mut resume = 0u32; - net_enum::( - |buf, read, total| unsafe { - NetConnectionEnum( - opt_ptr(server_w.as_deref()), - qualifier_w.as_ptr(), - 1, - buf, - MAX_PREFERRED_LENGTH, - read, - total, - &raw mut resume, - ) - }, - |info| { - // SAFETY: strings live in the enumeration buffer held by net_enum. - unsafe { - ShareConnection { - id: info.coni1_id, - share_type: ShareType::from_raw(info.coni1_type), - open_files: info.coni1_num_opens, - users: info.coni1_num_users, - active: Duration::from_secs(u64::from(info.coni1_time)), - username: from_pwstr_nonempty(info.coni1_username), - name: from_pwstr_nonempty(info.coni1_netname), - } - } - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn share_type_unpacks_flags() { - let t = ShareType::from_raw(STYPE_DISKTREE | STYPE_SPECIAL); - assert_eq!(t.kind, ShareKind::Disk); - assert!(t.special); - assert!(!t.temporary); - - let t = ShareType::from_raw(STYPE_PRINTQ | STYPE_TEMPORARY); - assert_eq!(t.kind, ShareKind::PrintQueue); - assert!(!t.special); - assert!(t.temporary); - } - - #[test] - fn delete_session_requires_a_nonempty_filter() { - // Rejected before the FFI call, so this cannot touch real sessions. - for (client, username) in [ - (None, None), - (Some(""), None), - (None, Some("")), - (Some(""), Some("")), - ] { - assert_eq!( - delete_session(None, client, username).unwrap_err(), - Error::Windows(ERROR_INVALID_PARAMETER) - ); - } - } - - #[test] - fn net_enum_fails_on_an_empty_more_data_batch() { - // ERROR_MORE_DATA with no entries delivered means the next call - // would repeat the identical request; net_enum must error out - // instead of looping forever. - let mut calls = 0; - let result = net_enum::( - |_, _, _| { - calls += 1; - ERROR_MORE_DATA - }, - |_| panic!("no entries were delivered"), - ); - assert_eq!(result, Err(Error::Windows(ERROR_MORE_DATA))); - assert_eq!(calls, 1); - } - - #[test] - // netapi32 allocates with alignment suitable for any of its info - // structures, so the u8 -> u32 cast below is sound. - #[allow(clippy::cast_ptr_alignment)] - fn net_enum_gives_up_on_a_never_ending_enumeration() { - use windows_sys::Win32::NetworkManagement::NetManagement::NetApiBufferAllocate; - - // A resume handle that keeps yielding entries without ever reaching - // NERR_SUCCESS must hit the batch backstop, not run unbounded. - let mut entries = 0u32; - let result = net_enum::( - |buf, read, _| { - // SAFETY: `buf` receives a real netapi32 allocation (freed - // by net_enum's NetBuffer guard) holding the one u32 entry - // that `read` reports. - unsafe { - NetApiBufferAllocate(4, buf.cast()); - (*buf).cast::().write(7); - read.write(1); - } - ERROR_MORE_DATA - }, - |&n| { - assert_eq!(n, 7); - entries += 1; - }, - ); - assert_eq!(result, Err(Error::Windows(ERROR_MORE_DATA))); - assert!(entries > 0, "delivered entries must still be consumed"); - } - - #[test] - fn share_kind_roundtrips() { - for kind in [ - ShareKind::Disk, - ShareKind::PrintQueue, - ShareKind::Device, - ShareKind::Ipc, - ] { - assert_eq!(ShareType::from_raw(kind.to_raw()).kind, kind); - } - } -} diff --git a/src/strings.rs b/src/strings.rs index d84fa3a..f7fd66b 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -22,12 +22,8 @@ pub(crate) fn to_wide(s: &str) -> Result> { Ok(wide) } -/// A nul-terminated UTF-16 buffer that is wiped on drop when the `zeroize` -/// feature is enabled. -#[cfg(feature = "zeroize")] +/// A nul-terminated UTF-16 buffer that is wiped on drop. pub(crate) type WideSecret = zeroize::Zeroizing>; -#[cfg(not(feature = "zeroize"))] -pub(crate) type WideSecret = Vec; /// Pointer to an optional wide string, or null when absent. /// diff --git a/src/target.rs b/src/target.rs index b73f24e..309ccee 100644 --- a/src/target.rs +++ b/src/target.rs @@ -1,12 +1,11 @@ use crate::error::{Error, Result, check_wnet}; -use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter, ResourceType}; +use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter}; use crate::strings::{WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide}; use crate::trace::{debug, trace}; -use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER; use windows_sys::Win32::NetworkManagement::WNet; -/// The wide-string buffers and resource type for one connect call, converted -/// from an [`SmbTarget`]. +/// The wide-string buffers for one connect call, converted from an +/// [`SmbTarget`]. /// /// Owning them in one struct pins down the borrow discipline: the struct must /// outlive the FFI call because every pointer passed to it borrows from the @@ -14,22 +13,23 @@ use windows_sys::Win32::NetworkManagement::WNet; struct ConnectArgs { remote: Vec, local: Option>, - provider: Option>, username: Option>, password: Option, - resource_type: ResourceType, } impl ConnectArgs { fn new(target: &SmbTarget) -> Result { - let password = target.password.as_deref().map(to_wide).transpose()?; + let password = target + .password + .as_deref() + .map(to_wide) + .transpose()? + .map(WideSecret::from); Ok(Self { remote: to_wide(&target.remote)?, local: target.local.as_deref().map(to_wide).transpose()?, - provider: target.provider.as_deref().map(to_wide).transpose()?, username: target.username.as_deref().map(to_wide).transpose()?, - password: password.map(WideSecret::from), - resource_type: target.resource_type, + password, }) } @@ -39,19 +39,18 @@ impl ConnectArgs { fn resource(&self) -> WNet::NETRESOURCEW { WNet::NETRESOURCEW { dwScope: 0, // ignored by WNetAddConnection2W / WNetUseConnectionW - dwType: self.resource_type as u32, + dwType: WNet::RESOURCETYPE_DISK, dwDisplayType: 0, // ignored, as dwScope dwUsage: 0, // ignored, as dwScope lpLocalName: opt_ptr(self.local.as_deref()), lpRemoteName: self.remote.as_ptr().cast_mut(), lpComment: std::ptr::null_mut(), // ignored, as dwScope - lpProvider: opt_ptr(self.provider.as_deref()), + lpProvider: std::ptr::null_mut(), } } } -/// A reusable target for SMB connections, optionally redirected to a local -/// device. +/// A reusable target for SMB connections, optionally mapped to a local drive. /// /// Construct one with [`SmbTarget::new`], configure it with the fluent setters, /// then establish the connection with one of the `connect*` methods. @@ -74,8 +73,6 @@ pub struct SmbTarget { username: Option, password: Option, local: Option, - resource_type: ResourceType, - provider: Option, } // Deliberately manual: must never leak the password. @@ -86,13 +83,10 @@ impl std::fmt::Debug for SmbTarget { .field("username", &self.username) .field("password", &self.password.as_ref().map(|_| "")) .field("local", &self.local) - .field("resource_type", &self.resource_type) - .field("provider", &self.provider) .finish() } } -#[cfg(feature = "zeroize")] impl Drop for SmbTarget { fn drop(&mut self) { use zeroize::Zeroize; @@ -109,8 +103,6 @@ impl SmbTarget { username: None, password: None, local: None, - resource_type: ResourceType::Disk, - provider: None, } } @@ -137,11 +129,8 @@ impl SmbTarget { #[must_use] pub fn password(mut self, password: impl Into) -> Self { // Wipe any previously set password before the assignment drops it. - #[cfg(feature = "zeroize")] - { - use zeroize::Zeroize; - self.password.zeroize(); - } + use zeroize::Zeroize; + self.password.zeroize(); self.password = Some(password.into()); self } @@ -153,34 +142,6 @@ impl SmbTarget { self } - /// Redirect to an arbitrary local device name (e.g. `"LPT1"` for a - /// printer share). Prefer [`mount_on`](Self::mount_on) for drive letters; - /// this escape hatch is passed to Windows unvalidated. - #[must_use] - pub fn local_device(mut self, device: impl Into) -> Self { - self.local = Some(device.into()); - self - } - - /// The resource type to connect to. Defaults to [`ResourceType::Disk`]. - /// - /// [`ResourceType::Any`] is only valid for deviceless connections; see - /// its documentation. - #[must_use] - pub fn resource_type(mut self, resource_type: ResourceType) -> Self { - self.resource_type = resource_type; - self - } - - /// The network provider to use (`lpProvider`). Microsoft: set this only - /// if you know the network provider you want; otherwise let the operating - /// system determine which provider the network name maps to. - #[must_use] - pub fn provider(mut self, provider: impl Into) -> Self { - self.provider = Some(provider.into()); - self - } - /// The remote name, e.g. `\\server\share`. #[must_use] pub fn remote(&self) -> &str { @@ -196,8 +157,7 @@ impl SmbTarget { /// [`disconnect`](Self::disconnect) cancels them all. /// /// # Errors - /// See [`Error`] — every documented `WNetAddConnection2W` failure has a - /// dedicated variant. + /// Returns [`Error`] for invalid options or a failed Windows call. pub fn connect(&self) -> Result<()> { self.connect_with(ConnectOptions::new()) } @@ -205,14 +165,21 @@ impl SmbTarget { /// Connect with explicit [`ConnectOptions`]. /// /// # Errors - /// See [`Error`] — every documented `WNetAddConnection2W` failure has a - /// dedicated variant. + /// [`Error::PersistenceRequiresDrive`] when persistence is requested for + /// a deviceless target, or another [`Error`] when the Windows call fails. pub fn connect_with(&self, options: ConnectOptions) -> Result<()> { + if options.is_persistent() && self.local.is_none() { + return Err(Error::PersistenceRequiresDrive); + } let flags = options.flags; let args = ConnectArgs::new(self)?; let resource = args.resource(); - trace!("connecting to {} with flags {flags:#x}", self.remote); + trace!( + "connecting to {} as {} with flags {flags:#x}", + self.remote, + self.username.as_deref().unwrap_or("") + ); // SAFETY: all pointers in `resource` and the credential pointers stay // valid for the duration of the call — they borrow from the buffers @@ -230,20 +197,15 @@ impl SmbTarget { check_wnet(status) } - /// Connect and let Windows pick a free local device, via - /// `WNetUseConnectionW` with `CONNECT_REDIRECT`. + /// Connect and let Windows pick a free drive, via `WNetUseConnectionW` + /// with `CONNECT_REDIRECT`. /// - /// Returns the name through which the share is accessible — the assigned - /// device (e.g. `"Z:"`), or the local device configured on this target if - /// one was set. Pass the returned name to + /// Returns the drive through which the share is accessible (e.g. `"Z:"`), + /// or the drive configured on this target if one was set. Pass the returned name to /// [`cancel_connection`] to disconnect, or use /// [`connect_auto_guarded`](Self::connect_auto_guarded) to have that /// happen automatically. /// - /// The target's resource type must be [`ResourceType::Disk`] or - /// [`ResourceType::Print`]: Windows rejects `RESOURCETYPE_ANY` with - /// `ERROR_INVALID_PARAMETER` when it chooses the device itself. - /// /// # Errors /// See [`Error`]. pub fn connect_auto(&self, options: ConnectOptions) -> Result { @@ -251,10 +213,14 @@ impl SmbTarget { let args = ConnectArgs::new(self)?; let resource = args.resource(); - trace!("auto-connecting to {} with flags {flags:#x}", self.remote); + trace!( + "auto-connecting to {} as {} with flags {flags:#x}", + self.remote, + self.username.as_deref().unwrap_or("") + ); // Sized so any real access name fits on the first call: the name is - // either a redirected local device ("Z:", nowhere near 1024) or, for + // either a mapped drive ("Z:", nowhere near 1024) or, for // a deviceless connection, a provider-adjusted form of the remote // name — covered by the `remote.len()` headroom. There is // deliberately no grow-and-retry on ERROR_MORE_DATA: Windows does @@ -286,9 +252,8 @@ impl SmbTarget { /// Connect and return an RAII [`Connection`] guard that disconnects when /// dropped. /// - /// Requires a local device (set via [`mount_on`](Self::mount_on) or - /// [`local_device`](Self::local_device)): the device is the - /// one thing a guard can exclusively own — connecting fails with + /// Requires a drive set via [`mount_on`](Self::mount_on): the drive is + /// the one thing a guard can exclusively own — connecting fails with /// `ERROR_ALREADY_ASSIGNED` if it is taken, and canceling it by name on /// drop touches no other connection. A deviceless connection offers no /// such handle: Windows does not reference-count connections, and @@ -299,11 +264,15 @@ impl SmbTarget { /// pick the device instead. /// /// # Errors - /// `ERROR_INVALID_PARAMETER` (synthesized without a Windows call) when - /// this target has no local device; otherwise see [`Error`]. + /// [`Error::GuardRequiresDrive`] when this target has no drive, or + /// [`Error::PersistentGuard`] when persistence is requested; otherwise + /// see [`Error`]. pub fn connect_guarded(&self, options: ConnectOptions) -> Result { + if options.is_persistent() { + return Err(Error::PersistentGuard); + } let Some(device) = self.local.as_deref() else { - return Err(Error::Windows(ERROR_INVALID_PARAMETER)); + return Err(Error::GuardRequiresDrive); }; let device = device.to_string(); self.connect_with(options)?; @@ -314,13 +283,17 @@ impl SmbTarget { } /// [`connect_auto`](Self::connect_auto) with an RAII [`Connection`] - /// guard: Windows picks a free local device, and the guard cancels + /// guard: Windows picks a free drive, and the guard cancels /// exactly that device when dropped. [`Connection::device`] tells you /// where the target is mounted. /// /// # Errors - /// See [`connect_auto`](Self::connect_auto). + /// [`Error::PersistentGuard`] when persistence is requested; otherwise + /// see [`connect_auto`](Self::connect_auto). pub fn connect_auto_guarded(&self, options: ConnectOptions) -> Result { + if options.is_persistent() { + return Err(Error::PersistentGuard); + } let device = self.connect_auto(options)?; Ok(Connection { device, @@ -330,15 +303,14 @@ impl SmbTarget { /// Disconnect with default options: non-forced, keeping any persistence. /// - /// Disconnects by local device name when this target has one, otherwise by + /// Disconnects by drive name when this target has one, otherwise by /// remote name. Disconnecting by remote name cancels **all** deviceless /// connections to that resource in this logon session — Windows does not /// reference-count them, so this undoes every [`connect`](Self::connect) /// to the resource at once, not just one. /// /// # Errors - /// See [`Error`] — every documented `WNetCancelConnection2W` failure has - /// a dedicated variant. + /// Returns [`Error`] when the Windows call fails. pub fn disconnect(&self) -> Result<()> { self.disconnect_with(DisconnectOptions::new()) } @@ -353,22 +325,28 @@ impl SmbTarget { } } -/// Cancel a connection by name: either a redirected local device (e.g. -/// `"Z:"`) or a remote name (`\\server\share`) for deviceless connections. +/// Cancel a connection by name: either a mapped drive (e.g. `"Z:"`) or a +/// remote name (`\\server\share`) for deviceless connections. /// /// This is the direct wrapper around `WNetCancelConnection2W`; use it to /// disconnect names returned by /// [`SmbTarget::connect_auto`] or found via [`crate::enumerate`]. /// -/// A local device name cancels only that redirection; a remote name cancels +/// A drive name cancels only that mapping; a remote name cancels /// **all** deviceless connections to that resource in this logon session /// (Windows does not reference-count them). /// /// # Errors /// See [`Error`]. pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { + if options.forget && !is_drive(name) { + return Err(Error::ForgetRequiresDrive); + } let wide = to_wide(name)?; - trace!("disconnecting {name}"); + trace!( + "disconnecting {name} (force={}, forget={})", + options.force, options.forget + ); // SAFETY: `wide` is a valid nul-terminated string outliving the call. let flags = if options.forget { WNet::CONNECT_UPDATE_PROFILE @@ -381,13 +359,17 @@ pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { check_wnet(status) } +fn is_drive(name: &str) -> bool { + let bytes = name.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + /// RAII guard returned by [`SmbTarget::connect_guarded`] and /// [`SmbTarget::connect_auto_guarded`]: cancels the connection when dropped -/// (best effort — a failure on drop is only visible as a `tracing` event, -/// with the `tracing` feature enabled). +/// (best effort — a failure on drop is only visible as a `tracing` event). /// -/// A guard always owns a local device redirection ([`Connection::device`]) -/// and cancels exactly that device, never the remote name. The device was +/// A guard always owns a drive mapping ([`Connection::device`]) and cancels +/// exactly that drive, never the remote name. The drive was /// free when the guard connected it, so a live guard is its sole owner and /// dropping it cannot tear down a connection made elsewhere. (This is why /// deviceless connections cannot be guarded — canceling one means canceling @@ -399,13 +381,13 @@ pub fn cancel_connection(name: &str, options: DisconnectOptions) -> Result<()> { #[derive(Debug)] #[must_use = "dropping the guard disconnects the connection immediately"] pub struct Connection { - /// The redirected local device this guard exclusively owns. + /// The mapped drive this guard exclusively owns. device: String, armed: bool, } impl Connection { - /// The local device this guard owns (e.g. `"Z:"`) — the target is + /// The drive this guard owns (e.g. `"Z:"`) — the target is /// accessible through it for as long as the guard lives. #[must_use] pub fn device(&self) -> &str { @@ -444,24 +426,70 @@ mod tests { #[test] fn configures_target_fluently() { let target = SmbTarget::new(r"\\server\share") - .credentials("user", "pass") - .mount_on(DriveLetter::D) - .provider("provider"); + .username("user") + .password("secret-value") + .mount_on(DriveLetter::D); assert_eq!(target.username.as_deref(), Some("user")); - assert_eq!(target.password.as_deref(), Some("pass")); + assert_eq!(target.password.as_deref(), Some("secret-value")); assert_eq!(target.local.as_deref(), Some("D:")); - assert_eq!(target.provider.as_deref(), Some("provider")); + let debug = format!("{target:?}"); + assert!(debug.contains("user")); + assert!(!debug.contains("secret-value")); } #[test] - fn deviceless_connect_guarded_is_rejected() { - // Rejected before any Windows call: without a local device there is - // nothing a guard can exclusively own, and canceling by remote name - // would tear down deviceless connections the guard never made. - let target = SmbTarget::new(r"\\server\share"); + fn credential_forms_keep_missing_fields_absent() { + let default = SmbTarget::new(r"\\server\share"); + let args = ConnectArgs::new(&default).unwrap(); + assert!(args.username.is_none()); + assert!(args.password.is_none()); + + let username_only = SmbTarget::new(r"\\server\share").username("user"); + let args = ConnectArgs::new(&username_only).unwrap(); + assert!(args.username.is_some()); + assert!(args.password.is_none()); + + let password_only = SmbTarget::new(r"\\server\share").password("secret-value"); + let args = ConnectArgs::new(&password_only).unwrap(); + assert!(args.username.is_none()); + assert!(args.password.is_some()); + } + + #[test] + fn invalid_lifetime_combinations_are_rejected_before_windows_calls() { + let deviceless = SmbTarget::new(r"\\server\share"); + let persistent = ConnectOptions::new().persist(true); + assert_eq!( + deviceless.connect_with(persistent), + Err(Error::PersistenceRequiresDrive) + ); assert_eq!( - target.connect_guarded(ConnectOptions::new()).unwrap_err(), - Error::Windows(ERROR_INVALID_PARAMETER) + deviceless + .connect_guarded(ConnectOptions::new()) + .unwrap_err(), + Error::GuardRequiresDrive ); + assert_eq!( + deviceless.connect_auto_guarded(persistent).unwrap_err(), + Error::PersistentGuard + ); + + let mapped = SmbTarget::new(r"\\server\share").mount_on(DriveLetter::D); + assert_eq!( + mapped.connect_guarded(persistent).unwrap_err(), + Error::PersistentGuard + ); + assert_eq!( + deviceless.disconnect_with(DisconnectOptions::new().forget(true)), + Err(Error::ForgetRequiresDrive) + ); + } + + #[test] + fn drive_names_are_recognized_for_forgetting() { + assert!(is_drive("D:")); + assert!(is_drive("z:")); + assert!(!is_drive(r"\\server\share")); + assert!(!is_drive("D:\\")); } } diff --git a/src/trace.rs b/src/trace.rs index 95d824b..95cc687 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -1,18 +1 @@ -//! Internal shim so the crate can emit `tracing` events without forcing the -//! dependency on users. With the `tracing` feature disabled these macros -//! expand to nothing. - -#[cfg(feature = "tracing")] pub(crate) use tracing::{debug, trace}; - -// The disabled variants still type-check their arguments (at zero runtime -// cost) so code compiles identically with and without the feature. -#[cfg(not(feature = "tracing"))] -macro_rules! disabled { - ($($arg:tt)*) => {{ - let _ = format_args!($($arg)*); - }}; -} - -#[cfg(not(feature = "tracing"))] -pub(crate) use {disabled as debug, disabled as trace}; diff --git a/tests/integration.rs b/tests/integration.rs index eefc624..b3f6592 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -3,16 +3,12 @@ use sambrs::{ ConnectOptions, DisconnectOptions, DriveLetter, Error, SmbTarget, cancel_connection, enumerate, - query, server, + query, }; use windows_sys::Win32::Foundation::{ ERROR_ACCESS_DENIED, ERROR_ALREADY_ASSIGNED, ERROR_BAD_NET_NAME, ERROR_INVALID_PASSWORD, ERROR_LOGON_FAILURE, ERROR_NO_NET_OR_BAD_PATH, ERROR_NO_NETWORK, ERROR_OPEN_FILES, }; -use windows_sys::Win32::NetworkManagement::NetManagement::{ - NERR_DuplicateShare as NERR_DUPLICATE_SHARE, NERR_NetNameNotFound as NERR_NET_NAME_NOT_FOUND, -}; - const SHARE: &str = "SAMBRS_TEST_SHARE"; const USERNAME: &str = "SAMBRS_TEST_USERNAME"; const PASSWORD: &str = "SAMBRS_TEST_PASSWORD"; @@ -21,12 +17,6 @@ fn required_env(name: &str) -> String { std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set")) } -/// `server::*` tests only make sense against the local machine, with rights -/// to administer it. -fn local_admin() -> bool { - std::env::var("SAMBRS_TEST_LOCAL").is_ok_and(|v| v == "1") -} - fn target(mount: Option) -> SmbTarget { let target = SmbTarget::new(required_env(SHARE)) .credentials(required_env(USERNAME), required_env(PASSWORD)); @@ -100,6 +90,29 @@ fn mounted_reconnect_fails_with_already_assigned() { target.disconnect().unwrap(); } +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn persistent_mapping_is_remembered_and_can_be_forgotten() { + let target = target(Some(DriveLetter::R)); + target + .connect_with(ConnectOptions::new().persist(true)) + .unwrap(); + + let remembered: Result, _> = enumerate::remembered().and_then(Iterator::collect); + let cleanup = target.disconnect_with(DisconnectOptions::new().force(true).forget(true)); + cleanup.unwrap(); + + assert!( + remembered.unwrap().iter().any(|resource| { + resource + .local_name + .as_deref() + .is_some_and(|drive| drive.eq_ignore_ascii_case("R:")) + }), + "persistent R: mapping was not enumerated as remembered" + ); +} + #[test] #[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] fn two_letters_to_the_same_share_work() { @@ -276,101 +289,3 @@ fn enumerate_server_shares_contains_the_share() { target.disconnect().unwrap(); assert!(found, "test share not found in {server_root}'s share list"); } - -// ── server administration (local machine only) ────────────────────────────── - -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn server_shares_lists_the_test_share() { - if !local_admin() { - eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); - return; - } - let leaf = required_env(SHARE).rsplit('\\').next().unwrap().to_string(); - let all = server::shares(None).unwrap(); - assert!( - all.iter().any(|s| s.name.eq_ignore_ascii_case(&leaf)), - "share {leaf} not in {all:?}" - ); -} - -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn server_add_get_delete_share_roundtrip() { - if !local_admin() { - eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); - return; - } - let dir = std::env::temp_dir().join("sambrs-roundtrip-share"); - std::fs::create_dir_all(&dir).unwrap(); - let name = format!("sambrs-tmp-{}", std::process::id()); - - server::add_share( - None, - &server::NewShare::disk(&name, dir.to_str().unwrap()).remark("sambrs test share"), - ) - .unwrap(); - - let info = server::share_info(None, &name).unwrap(); - assert_eq!(info.name.to_ascii_lowercase(), name.to_ascii_lowercase()); - assert_eq!(info.share_type.kind, server::ShareKind::Disk); - assert_eq!(info.remark.as_deref(), Some("sambrs test share")); - - // Adding the same name again must fail cleanly. - let dup = server::add_share(None, &server::NewShare::disk(&name, dir.to_str().unwrap())); - assert_eq!(dup, Err(Error::Windows(NERR_DUPLICATE_SHARE))); - - server::delete_share(None, &name).unwrap(); - assert_eq!( - server::share_info(None, &name), - Err(Error::Windows(NERR_NET_NAME_NOT_FOUND)) - ); -} - -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn server_sessions_and_connections_are_listable() { - if !local_admin() { - eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); - return; - } - let leaf = required_env(SHARE).rsplit('\\').next().unwrap().to_string(); - let target = target(None); - target.connect().unwrap(); - - // Establishing the connection above means at least one session and one - // connection must be visible. - let sessions = server::sessions(None, None, None).unwrap(); - assert!(!sessions.is_empty(), "no SMB sessions listed"); - - let connections = server::connections(None, &leaf).unwrap(); - assert!(!connections.is_empty(), "no connections to {leaf} listed"); - - target.disconnect().unwrap(); -} - -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn server_open_files_are_listable_and_closable() { - if !local_admin() { - eprintln!("skipped: SAMBRS_TEST_LOCAL != 1"); - return; - } - let target = target(Some(DriveLetter::Y)); - target.connect().unwrap(); - let path = r"Y:\sambrs-open-file.txt"; - let file = std::fs::File::create(path).unwrap(); - - let open = server::open_files(None, None, None).unwrap(); - let ours = open - .iter() - .find(|f| f.path.contains("sambrs-open-file")) - .unwrap_or_else(|| panic!("our open file not listed by NetFileEnum; listed: {open:?}")); - server::close_file(None, ours.id).unwrap(); - - drop(file); - let _ = std::fs::remove_file(path); - target - .disconnect_with(DisconnectOptions::new().force(true)) - .unwrap(); -} From a754aa20b0be6e894e45b36e747055c32d60ea31 Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 20:23:52 +0000 Subject: [PATCH 41/42] Simplify internal plumbing --- .cargo/config.toml | 4 --- .github/workflows/ci.yml | 2 +- README.md | 6 ++--- src/enumerate.rs | 56 ++++++++++++---------------------------- src/error.rs | 2 +- src/lib.rs | 1 - src/query.rs | 2 +- src/target.rs | 2 +- src/trace.rs | 1 - 9 files changed, 22 insertions(+), 54 deletions(-) delete mode 100644 .cargo/config.toml delete mode 100644 src/trace.rs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index fafdce5..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,4 +0,0 @@ -# The integration tests mount real drive letters and enumerate live -# connections; running them concurrently would race on that global state. -[env] -RUST_TEST_THREADS = "1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897c339..c74d416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: "SAMBRS_TEST_USERNAME=$env:COMPUTERNAME\smbtest" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Unit + integration tests - run: cargo test -- --include-ignored + run: cargo test -- --include-ignored --test-threads=1 env: SAMBRS_TEST_SHARE: \\localhost\sambrs-test SAMBRS_TEST_PASSWORD: Sambrs-CI-Pass-1! diff --git a/README.md b/README.md index 08b28bd..c18de4c 100644 --- a/README.md +++ b/README.md @@ -115,12 +115,10 @@ SAMBRS_TEST_SHARE=\\server\share SAMBRS_TEST_USERNAME=DOMAIN\user SAMBRS_TEST_PASSWORD=... -cargo test -- --include-ignored +cargo test -- --include-ignored --test-threads=1 ``` -The tests mount real drive letters and must run single-threaded. A git -checkout sets `RUST_TEST_THREADS=1` through `.cargo/config.toml`; set it -manually when running from a packaged copy. +The tests mount real drive letters and must run single-threaded. ## Migrating from 0.1 diff --git a/src/enumerate.rs b/src/enumerate.rs index 8ae8fd9..bc47d54 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -12,8 +12,8 @@ use crate::error::{Error, Result, wnet_extended_error}; use crate::strings::{from_pwstr, len_u32, to_wide}; -use crate::trace::{debug, trace}; use std::collections::VecDeque; +use tracing::{debug, trace}; use windows_sys::Win32::Foundation::{ ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, HANDLE, NO_ERROR, }; @@ -31,25 +31,6 @@ pub struct NetResource { pub provider: Option, } -impl NetResource { - /// # Safety - /// The string pointers in `raw` must each be null or point to a valid - /// nul-terminated UTF-16 string — as they are in a `NETRESOURCEW` - /// returned by `WNetEnumResourceW` while the enumeration buffer is - /// alive. - unsafe fn from_raw(raw: &WNet::NETRESOURCEW) -> Self { - // SAFETY: guaranteed by caller. - unsafe { - Self { - local_name: from_pwstr(raw.lpLocalName), - remote_name: from_pwstr(raw.lpRemoteName), - comment: from_pwstr(raw.lpComment), - provider: from_pwstr(raw.lpProvider), - } - } - } -} - /// Iterator over enumerated [`NetResource`] entries. Closes the enumeration /// handle on drop. #[derive(Debug)] @@ -114,16 +95,21 @@ impl Resources { } // SAFETY: on success the buffer starts with `count` // NETRESOURCEW entries; the strings they point to live in - // `self.buf` and are copied out immediately by - // `from_raw`, before the buffer is reused. + // `self.buf` and are copied before the buffer is reused. let entries = unsafe { std::slice::from_raw_parts( self.buf.as_ptr().cast::(), count as usize, ) }; - self.batch - .extend(entries.iter().map(|e| unsafe { NetResource::from_raw(e) })); + self.batch.extend(entries.iter().map(|raw| unsafe { + NetResource { + local_name: from_pwstr(raw.lpLocalName), + remote_name: from_pwstr(raw.lpRemoteName), + comment: from_pwstr(raw.lpComment), + provider: from_pwstr(raw.lpProvider), + } + })); return Ok(()); } ERROR_NO_MORE_ITEMS => { @@ -151,12 +137,7 @@ impl Drop for Resources { } } -fn open( - scope: u32, - resource_type: u32, - usage: u32, - root_remote: Option<&str>, -) -> Result { +fn open(scope: u32, root_remote: Option<&str>) -> Result { let remote = root_remote.map(to_wide).transpose()?; let root = remote.as_ref().map(|remote| WNet::NETRESOURCEW { dwScope: 0, @@ -174,8 +155,8 @@ fn open( let status = unsafe { WNet::WNetOpenEnumW( scope, - resource_type, - usage, + WNet::RESOURCETYPE_DISK, + 0, root.as_ref().map_or(std::ptr::null(), std::ptr::from_ref), &raw mut handle, ) @@ -200,7 +181,7 @@ fn open( /// See [`Error`]. pub fn connections() -> Result { trace!("enumerating active disk-share connections"); - open(WNet::RESOURCE_CONNECTED, WNet::RESOURCETYPE_DISK, 0, None) + open(WNet::RESOURCE_CONNECTED, None) } /// Remembered (persistent) connections (`RESOURCE_REMEMBERED`) — restored at @@ -210,7 +191,7 @@ pub fn connections() -> Result { /// See [`Error`]. pub fn remembered() -> Result { trace!("enumerating remembered disk-share mappings"); - open(WNet::RESOURCE_REMEMBERED, WNet::RESOURCETYPE_DISK, 0, None) + open(WNet::RESOURCE_REMEMBERED, None) } /// The disk shares a server exposes (`RESOURCE_GLOBALNET` rooted at `server`, @@ -224,10 +205,5 @@ pub fn remembered() -> Result { /// be found, or `ERROR_ACCESS_DENIED` when it refuses anonymous enumeration. pub fn server_shares(server: &str) -> Result { trace!("enumerating disk shares on {server}"); - open( - WNet::RESOURCE_GLOBALNET, - WNet::RESOURCETYPE_DISK, - 0, - Some(server), - ) + open(WNet::RESOURCE_GLOBALNET, Some(server)) } diff --git a/src/error.rs b/src/error.rs index 5b1735d..866a17f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -92,7 +92,7 @@ pub(crate) fn wnet_extended_error() -> Error { crate::strings::len_u32(provider.len()), ) }; - crate::trace::debug!("WNetGetLastErrorW returned {status} (provider status {code})"); + tracing::debug!("WNetGetLastErrorW returned {status} (provider status {code})"); if status == NO_ERROR { Error::ExtendedError { code, diff --git a/src/lib.rs b/src/lib.rs index e65b8c5..9cd81de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,6 @@ mod error; mod options; mod strings; mod target; -mod trace; pub mod enumerate; pub mod query; diff --git a/src/query.rs b/src/query.rs index 5fc413c..e121c6a 100644 --- a/src/query.rs +++ b/src/query.rs @@ -4,7 +4,7 @@ use crate::error::{Error, Result, wnet_extended_error}; use crate::strings::{from_pwstr, len_u32, opt_ptr, to_wide}; -use crate::trace::{debug, trace}; +use tracing::{debug, trace}; use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, NO_ERROR}; use windows_sys::Win32::NetworkManagement::WNet; diff --git a/src/target.rs b/src/target.rs index 309ccee..e306782 100644 --- a/src/target.rs +++ b/src/target.rs @@ -1,7 +1,7 @@ use crate::error::{Error, Result, check_wnet}; use crate::options::{ConnectOptions, DisconnectOptions, DriveLetter}; use crate::strings::{WideSecret, from_wide_buf, len_u32, opt_ptr, secret_ptr, to_wide}; -use crate::trace::{debug, trace}; +use tracing::{debug, trace}; use windows_sys::Win32::NetworkManagement::WNet; /// The wide-string buffers for one connect call, converted from an diff --git a/src/trace.rs b/src/trace.rs deleted file mode 100644 index 95cc687..0000000 --- a/src/trace.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) use tracing::{debug, trace}; From bd27bc67013fcec330e4df9216a85923a0a1914f Mon Sep 17 00:00:00 2001 From: Samuel Van der Stappen Date: Sun, 12 Jul 2026 23:20:05 +0000 Subject: [PATCH 42/42] Remove unused SMB API surface --- CHANGELOG.md | 2 +- README.md | 2 +- src/enumerate.rs | 4 ---- src/query.rs | 18 +++++++----------- src/target.rs | 8 +------- tests/integration.rs | 14 +------------- 6 files changed, 11 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 788aeda..abf3987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ disk shares. - `ConnectOptions::require_integrity` for SMB signing and `require_privacy` for SMB encryption. - RAII `Connection` guards for temporary drive mappings, with explicit - disconnect and leak operations. + disconnection. - Focused queries for mapped-drive targets, connection users, and universal UNC paths. - Disk-focused enumeration of active connections, remembered mappings, and a diff --git a/README.md b/README.md index c18de4c..c1e9e59 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { } let remote = sambrs::query::get_connection("D:")?; -let user = sambrs::query::get_user(Some("D:"))?; +let user = sambrs::query::get_user("D:")?; let unc = sambrs::query::get_universal_name(r"D:\folder\file.txt")?; # Ok(()) # } diff --git a/src/enumerate.rs b/src/enumerate.rs index bc47d54..1e0b2bb 100644 --- a/src/enumerate.rs +++ b/src/enumerate.rs @@ -27,8 +27,6 @@ pub struct NetResource { pub local_name: Option, /// Remote name, e.g. `\\server\share`. pub remote_name: Option, - pub comment: Option, - pub provider: Option, } /// Iterator over enumerated [`NetResource`] entries. Closes the enumeration @@ -106,8 +104,6 @@ impl Resources { NetResource { local_name: from_pwstr(raw.lpLocalName), remote_name: from_pwstr(raw.lpRemoteName), - comment: from_pwstr(raw.lpComment), - provider: from_pwstr(raw.lpProvider), } })); return Ok(()); diff --git a/src/query.rs b/src/query.rs index e121c6a..ff374e6 100644 --- a/src/query.rs +++ b/src/query.rs @@ -3,7 +3,7 @@ //! on redirected drives. use crate::error::{Error, Result, wnet_extended_error}; -use crate::strings::{from_pwstr, len_u32, opt_ptr, to_wide}; +use crate::strings::{from_pwstr, len_u32, to_wide}; use tracing::{debug, trace}; use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, ERROR_MORE_DATA, NO_ERROR}; use windows_sys::Win32::NetworkManagement::WNet; @@ -50,20 +50,16 @@ pub fn get_connection(drive: &str) -> Result { /// The user name used to establish a connection, via `WNetGetUserW`. /// -/// `connection` is a local device (`"Z:"`) or a remote name; `None` returns -/// the name of the current user of the process. +/// `connection` is a local device (`"Z:"`) or a remote name. /// /// # Errors /// `ERROR_NOT_CONNECTED` if the name is not a connected resource. -pub fn get_user(connection: Option<&str>) -> Result { - trace!( - "querying user for {}", - connection.unwrap_or("") - ); - let name = connection.map(to_wide).transpose()?; - // SAFETY: `name` (when present) outlives the call. +pub fn get_user(connection: &str) -> Result { + trace!("querying user for {connection}"); + let name = to_wide(connection)?; + // SAFETY: `name` outlives the call. let user = wide_out("WNetGetUserW", |buf, len| unsafe { - WNet::WNetGetUserW(opt_ptr(name.as_deref()), buf, len) + WNet::WNetGetUserW(name.as_ptr(), buf, len) })?; debug!("WNetGetUserW resolved user {user}"); Ok(user) diff --git a/src/target.rs b/src/target.rs index e306782..2c53f8f 100644 --- a/src/target.rs +++ b/src/target.rs @@ -376,8 +376,7 @@ fn is_drive(name: &str) -> bool { /// by remote name, which takes every deviceless connection to the resource /// down with it.) /// -/// Use [`Connection::disconnect`] for explicit error handling, or -/// [`Connection::leak`] to keep the connection open past the guard. +/// Use [`Connection::disconnect`] for explicit error handling. #[derive(Debug)] #[must_use = "dropping the guard disconnects the connection immediately"] pub struct Connection { @@ -394,11 +393,6 @@ impl Connection { &self.device } - /// Consume the guard without disconnecting, keeping the connection open. - pub fn leak(mut self) { - self.armed = false; - } - /// Disconnect now, with explicit error handling. /// /// # Errors diff --git a/tests/integration.rs b/tests/integration.rs index b3f6592..648337a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -150,18 +150,6 @@ fn force_disconnect_with_open_file_works() { // ── RAII guard ────────────────────────────────────────────────────────────── -#[test] -#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] -fn guard_leak_keeps_the_connection() { - let target = target(Some(DriveLetter::V)); - target - .connect_guarded(ConnectOptions::new()) - .unwrap() - .leak(); - assert!(drive_exists(DriveLetter::V)); - target.disconnect().unwrap(); -} - // The ownership property behind the guard design: a guard cancels only the // device it owns, so dropping it must not tear down an independent deviceless // connection to the same resource. @@ -234,7 +222,7 @@ fn get_connection_returns_the_remote_name() { fn get_user_returns_a_user() { let target = target(Some(DriveLetter::W)); target.connect().unwrap(); - let user = query::get_user(Some("W:")).unwrap(); + let user = query::get_user("W:").unwrap(); assert!(!user.is_empty()); target.disconnect().unwrap(); }