diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 8af59dd..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[env] -RUST_TEST_THREADS = "1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c74d416 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # 2026-07 + with: + 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 + run: cargo test -- --include-ignored --test-threads=1 + env: + SAMBRS_TEST_SHARE: \\localhost\sambrs-test + SAMBRS_TEST_PASSWORD: Sambrs-CI-Pass-1! + + msrv: + name: MSRV check (Rust 1.85, Windows target) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # 2026-07 + 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 + + lint: + name: Lint and docs (Windows target) + runs-on: ubuntu-latest + steps: + - 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 + run: cargo fmt --check + + - name: clippy (pedantic is enforced in the crate root) + run: cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings + + - name: rustdoc + run: cargo doc --target x86_64-pc-windows-msvc --no-deps + env: + RUSTDOCFLAGS: -D warnings 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..abf3987 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +## 0.2.0 (unreleased) + +A breaking rework into a safe, opinionated Windows client for existing SMB +disk shares. + +### Added + +- `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 + disconnection. +- 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 + +- 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 + +- Description and examples; edition 2024; initial `WNetAddConnection2A` / + `WNetCancelConnection2A` wrapper with typed errors. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..a151b3b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Existing SMB Resource Access + +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 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 owned temporary drive mapping whose lifetime controls when the mapping is disconnected. +_Avoid_: SMB target, remembered mapping + +**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.lock b/Cargo.lock index 83ebfab..7f89660 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" @@ -14,62 +14,13 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" -[[package]] -name = "proc-macro2" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" -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.1.2" +version = "0.2.0" dependencies = [ - "thiserror", "tracing", "windows-sys", -] - -[[package]] -name = "syn" -version = "2.0.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "1.0.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "zeroize", ] [[package]] @@ -79,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" @@ -104,26 +43,27 @@ dependencies = [ ] [[package]] -name = "unicode-ident" -version = "1.0.12" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +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 +76,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..121a833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,30 @@ [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, 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"] -[dependencies] -thiserror = "1" -tracing = "0.1" -windows-sys = { version = "0.52", features = [ - "Win32_NetworkManagement_WNet", +[target.'cfg(windows)'.dependencies] +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", +] + +[package.metadata.docs.rs] +default-target = "x86_64-pc-windows-msvc" diff --git a/README.md b/README.md index 73936ef..c1e9e59 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,149 @@ -# 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) + +Safe, opinionated Windows SMB client operations for existing disk shares. Sam -> SMB -> Rust -> Samba is taken!? -> sambrs -## Features +`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. -- 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. +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 -Add this to your `Cargo.toml`: +The crate is Windows-only, so cross-platform applications should make the +dependency conditional: ```toml -[dependencies] -sambrs = "0.1" +[target.'cfg(windows)'.dependencies] +sambrs = "0.2" ``` +MSRV is Rust 1.85 (edition 2024). + ## Usage -Instantiate an `SmbShare` with an optional local Windows mount point and establish -a connection. +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, DisconnectOptions, DriveLetter, SmbTarget}; + +fn main() -> Result<(), Box> { + let target = SmbTarget::new(r"\\server.local\share") + .credentials(r"LOGONDOMAIN\user", "pass") + .mount_on(DriveLetter::D); + + target.connect_with( + ConnectOptions::new() + .persist(true) + .require_privacy(true), + )?; + + println!("{}", std::fs::metadata(r"D:\")?.is_dir()); + + target.disconnect_with(DisconnectOptions::new().forget(true))?; + Ok(()) +} +``` + +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> { +let target = sambrs::SmbTarget::new(r"\\server.local\share"); +target.connect()?; +# Ok(()) +# } +``` -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. +Use a guard for an automatically cleaned-up temporary mapping: -```rust -use sambrs::SmbShare; +```no_run +# fn main() -> Result<(), sambrs::Error> { +let target = sambrs::SmbTarget::new(r"\\server.local\share"); +let connection = target.connect_auto_guarded(sambrs::ConnectOptions::new())?; +println!("mapped on {}", connection.device()); +# Ok(()) +# } +``` -fn main() { - let share = SmbShare::new(r"\\server\share", "user", "pass", Some('D')); +Guarded connections cannot be persistent because persistence outlives the +guard. - match share.connect(false, false) { - Ok(()) => println!("Connected successfully!"), - Err(e) => eprintln!("Failed to connect: {}", e), - } +Inspect existing resources through the focused query and enumeration modules: - // use std::fs as if D:\ was a local directory - dbg!(std::fs::metadata(r"D:\").unwrap().is_dir()); +```no_run +# fn main() -> Result<(), sambrs::Error> { +for resource in sambrs::enumerate::server_shares(r"\\fileserver")? { + println!("{:?}", resource?.remote_name); } + +let remote = sambrs::query::get_connection("D:")?; +let user = sambrs::query::get_user("D:")?; +let unc = sambrs::query::get_universal_name(r"D:\folder\file.txt")?; +# Ok(()) +# } ``` +## 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=... + +cargo test -- --include-ignored --test-threads=1 +``` + +The tests mount real drive letters and must run single-threaded. + +## Migrating from 0.1 + +`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))` | +| `share.disconnect(persist, force)` | `target.disconnect_with(DisconnectOptions::new().forget(persist).force(force))` | +| `Error::CStringConversion` | `Error::InteriorNul` | + +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](LICENSE) file -for more details. +This project is licensed under the MIT License. See the +[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/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. diff --git a/src/enumerate.rs b/src/enumerate.rs new file mode 100644 index 0000000..1e0b2bb --- /dev/null +++ b/src/enumerate.rs @@ -0,0 +1,205 @@ +//! 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 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, +}; +use windows_sys::Win32::NetworkManagement::WNet; + +/// An owned snapshot of one disk-share resource. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct NetResource { + /// 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, +} + +/// Iterator over enumerated [`NetResource`] entries. Closes the enumeration +/// handle on drop. +#[derive(Debug)] +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, +} + +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<()> { + // 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(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, + self.buf.as_mut_ptr().cast(), + &raw mut size, + ) + }; + debug!("WNetEnumResourceW returned {status} (entries={count}, bytes={size})"); + 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 + // `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(|raw| unsafe { + NetResource { + local_name: from_pwstr(raw.lpLocalName), + remote_name: from_pwstr(raw.lpRemoteName), + } + })); + 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 => { + self.buf = vec![0u64; (size as usize).div_ceil(size_of::())]; + } + ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), + code => return Err(Error::Windows(code)), + } + } + Err(Error::Windows(ERROR_MORE_DATA)) + } +} + +impl Drop for Resources { + fn drop(&mut self) { + // SAFETY: the handle came from WNetOpenEnumW and is closed only here. + let status = unsafe { WNet::WNetCloseEnum(self.handle) }; + debug!("WNetCloseEnum returned {status}"); + } +} + +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, + 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, + WNet::RESOURCETYPE_DISK, + 0, + root.as_ref().map_or(std::ptr::null(), std::ptr::from_ref), + &raw mut handle, + ) + }; + debug!("WNetOpenEnumW returned {status}"); + match status { + 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()), + code => Err(Error::Windows(code)), + } +} + +/// Currently connected resources (`RESOURCE_CONNECTED`) — every active +/// redirection and deviceless connection of the calling user. +/// +/// # Errors +/// See [`Error`]. +pub fn connections() -> Result { + trace!("enumerating active disk-share connections"); + open(WNet::RESOURCE_CONNECTED, None) +} + +/// Remembered (persistent) connections (`RESOURCE_REMEMBERED`) — restored at +/// logon whether or not they are currently connected. +/// +/// # Errors +/// See [`Error`]. +pub fn remembered() -> Result { + trace!("enumerating remembered disk-share mappings"); + open(WNet::RESOURCE_REMEMBERED, None) +} + +/// 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. +/// +/// # Errors +/// `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, Some(server)) +} diff --git a/src/error.rs b/src/error.rs index 345387f..866a17f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,84 +1,109 @@ -use std::ffi::NulError; -use thiserror::Error; +use windows_sys::Win32::Foundation::{ERROR_EXTENDED_ERROR, NO_ERROR}; +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. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { - #[error("Failed to convert share to CString")] - CStringConversion(#[from] NulError), + /// Input contains an interior NUL and cannot cross the Windows API boundary. + InteriorNul, + /// The character is not an ASCII drive letter. + InvalidDriveLetter(char), + /// 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 { + code: u32, + description: String, + provider: String, + }, +} + +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::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, + 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 { + #[allow(clippy::cast_possible_wrap)] + std::io::Error::from_raw_os_error(code as i32).to_string() +} + +/// 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::Windows(code)), + } +} - #[error("The caller does not have access to the network resource.")] - AccessDenied, - #[error( - "The local device specified by the lpLocalName member is already connected to a network resource." - )] - AlreadyAssigned, - #[error("The type of local device and the type of network resource 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." - )] - 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." - )] - BadNetName, - #[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." - )] - BadProvider, - #[error("The specified user name is not valid.")] - BadUsername, - #[error("The router or provider is busy, possibly initializing. The caller should retry.")] - 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." - )] - Cancelled, - #[error("The system is unable to open the user profile 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." - )] - 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." - )] - 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." - )] - InvalidParameter, - #[error("The specified password is invalid and the CONNECT_INTERACTIVE flag is not set.")] - InvalidPassword, - #[error("A logon failure because of an unknown user name or a 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." - )] - NoNetOrBadPath, - #[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." - )] - SessionCredentialConflict, - #[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." - )] - NotConnected, - #[error("There are open files, and the fForce parameter is FALSE.")] - OpenFiles, - #[error("Unknown error {0}.")] - Other(u32), +/// 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()), + ) + }; + tracing::debug!("WNetGetLastErrorW returned {status} (provider status {code})"); + 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..9cd81de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,357 +1,15 @@ +#![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. -//! -//! Sam -> SMB -> Rust -> Samba is taken!? -> sambrs -//! -//! # How To -//! -//! 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. -//! -//! ```no_run -//! use sambrs::SmbShare; -//! -//! let share = SmbShare::new(r"\\server\share", "user", "pass", Some('D')); -//! -//! match share.connect(false, false) { -//! Ok(()) => println!("Connected successfully!"), -//! Err(e) => eprintln!("Failed to connect: {}", e), -//! } -//! -//! // use std::fs as if D:\ was a local directory -//! dbg!(std::fs::metadata(r"D:\").unwrap().is_dir()); -//! ``` +#![doc = include_str!("../README.md")] mod error; +mod options; +mod strings; +mod target; -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); - - // 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()?; +pub mod enumerate; +pub mod query; - 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}; +pub use target::{Connection, SmbTarget, cancel_connection}; diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..f4a893a --- /dev/null +++ b/src/options.rs @@ -0,0 +1,204 @@ +use crate::error::Error; +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_BAD_DEVICE`. +/// +/// ``` +/// 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 + } +} + +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()) + } +} + +/// Options for [`SmbTarget::connect_with`](crate::SmbTarget::connect_with). +/// +/// 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 { + pub(crate) flags: u32, +} + +impl Default for ConnectOptions { + fn default() -> Self { + Self::new() + } +} + +impl ConnectOptions { + pub const fn new() -> Self { + Self { + flags: WNet::CONNECT_TEMPORARY | WNet::CONNECT_UPDATE_RECENT, + } + } + + fn flag(mut self, flag: u32, yes: bool) -> Self { + if yes { + self.flags |= flag; + } else { + self.flags &= !flag; + } + self + } + + pub(crate) fn is_persistent(self) -> bool { + self.flags & WNet::CONNECT_UPDATE_PROFILE != 0 + } + + /// 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) + } + + /// Fail if SMB signing cannot be enforced. + pub fn require_integrity(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_REQUIRE_INTEGRITY, yes) + } + + /// Fail if SMB encryption cannot be enforced. + pub fn require_privacy(self, yes: bool) -> Self { + self.flag(WNet::CONNECT_REQUIRE_PRIVACY, yes) + } +} + +/// Options for [`SmbTarget::disconnect_with`](crate::SmbTarget::disconnect_with) +/// and [`cancel_connection`](crate::cancel_connection). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[must_use] +pub struct DisconnectOptions { + pub(crate) force: bool, + pub(crate) forget: bool, +} + +impl DisconnectOptions { + pub fn new() -> Self { + Self::default() + } + + /// Disconnect even if files or jobs remain open on the mapping. + pub fn force(mut self, yes: bool) -> Self { + self.force = yes; + self + } + + /// 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 + } +} + +#[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.to_string(), "A:"); + assert_eq!(DriveLetter::Z.to_string(), "Z:"); + } + + #[test] + fn default_options_are_temporary_and_suppress_recent_deviceless_connections() { + assert_eq!( + ConnectOptions::new().flags, + WNet::CONNECT_TEMPORARY | WNet::CONNECT_UPDATE_RECENT + ); + } + + #[test] + fn persist_replaces_temporary() { + assert_eq!( + ConnectOptions::new().persist(true).flags, + WNet::CONNECT_UPDATE_PROFILE | WNet::CONNECT_UPDATE_RECENT + ); + } + + #[test] + fn security_options_map() { + assert_eq!( + ConnectOptions::new() + .require_integrity(true) + .require_privacy(true) + .flags, + WNet::CONNECT_TEMPORARY + | WNet::CONNECT_UPDATE_RECENT + | WNet::CONNECT_REQUIRE_INTEGRITY + | WNet::CONNECT_REQUIRE_PRIVACY + ); + } + + #[test] + fn disconnect_options_map() { + assert!(!DisconnectOptions::new().forget); + assert!(!DisconnectOptions::new().force); + assert!(DisconnectOptions::new().forget(true).forget); + assert!(DisconnectOptions::new().force(true).force); + } +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..ff374e6 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,110 @@ +//! 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, 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; + +/// Call a `WNet` function that fills a wide-string output buffer, growing the +/// buffer when Windows reports `ERROR_MORE_DATA`. +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()); + 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], + ERROR_EXTENDED_ERROR => return Err(wnet_extended_error()), + code => return Err(Error::Windows(code)), + } + } + Err(Error::Windows(ERROR_MORE_DATA)) +} + +/// The remote name a mapped drive 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_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(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("WNetGetConnectionW", |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. +/// +/// # Errors +/// `ERROR_NOT_CONNECTED` if the name is not a connected resource. +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(name.as_ptr(), buf, len) + })?; + debug!("WNetGetUserW resolved user {user}"); + Ok(user) +} + +/// 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_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]; + 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, + ) + }; + debug!("WNetGetUniversalNameW returned {status}"); + 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::Windows(code)), + } + } + Err(Error::Windows(ERROR_MORE_DATA)) +} diff --git a/src/strings.rs b/src/strings.rs new file mode 100644 index 0000000..f7fd66b --- /dev/null +++ b/src/strings.rs @@ -0,0 +1,111 @@ +//! 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). +/// 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()); + wide.push(0); + Ok(wide) +} + +/// A nul-terminated UTF-16 buffer that is wiped on drop. +pub(crate) type WideSecret = zeroize::Zeroizing>; + +/// 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. +pub(crate) fn secret_ptr(buf: Option<&WideSecret>) -> *const u16 { + buf.map_or(std::ptr::null(), |b| b.as_ptr()) +} + +/// 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/target.rs b/src/target.rs new file mode 100644 index 0000000..2c53f8f --- /dev/null +++ b/src/target.rs @@ -0,0 +1,489 @@ +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 tracing::{debug, trace}; +use windows_sys::Win32::NetworkManagement::WNet; + +/// 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 +/// buffers owned here. +struct ConnectArgs { + remote: Vec, + local: Option>, + username: Option>, + password: Option, +} + +impl ConnectArgs { + fn new(target: &SmbTarget) -> Result { + 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()?, + username: target.username.as_deref().map(to_wide).transpose()?, + password, + }) + } + + /// 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: 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: std::ptr::null_mut(), + } + } +} + +/// 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. +/// +/// ```no_run +/// use sambrs::{DriveLetter, SmbTarget}; +/// +/// let target = SmbTarget::new(r"\\server\share") +/// .credentials("user", "pass") +/// .mount_on(DriveLetter::D); +/// +/// target.connect()?; +/// // use std::fs as if D:\ was a local directory +/// assert!(std::fs::metadata(r"D:\")?.is_dir()); +/// target.disconnect()?; +/// # Ok::<(), Box>(()) +/// ``` +pub struct SmbTarget { + remote: String, + username: Option, + password: Option, + local: Option, +} + +// Deliberately manual: must never leak the password. +impl std::fmt::Debug for SmbTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SmbTarget") + .field("remote", &self.remote) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "")) + .field("local", &self.local) + .finish() + } +} + +impl Drop for SmbTarget { + fn drop(&mut self) { + use zeroize::Zeroize; + self.password.zeroize(); + } +} + +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(), + username: None, + password: None, + local: None, + } + } + + /// 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. + use zeroize::Zeroize; + self.password.zeroize(); + self.password = Some(password.into()); + self + } + + /// 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()); + 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. + /// + /// Connecting multiple times works fine in deviceless mode but fails with + /// `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. + /// + /// # Errors + /// Returns [`Error`] for invalid options or a failed Windows call. + pub fn connect(&self) -> Result<()> { + self.connect_with(ConnectOptions::new()) + } + + /// Connect with explicit [`ConnectOptions`]. + /// + /// # Errors + /// [`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 {} 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 + // owned by `args`, which lives until the end of this function. + let status = unsafe { + WNet::WNetAddConnection2W( + &raw const resource, + secret_ptr(args.password.as_ref()), + opt_ptr(args.username.as_deref()), + flags, + ) + }; + + debug!("WNetAddConnection2W returned {status}"); + check_wnet(status) + } + + /// Connect and let Windows pick a free drive, via `WNetUseConnectionW` + /// with `CONNECT_REDIRECT`. + /// + /// 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. + /// + /// # Errors + /// See [`Error`]. + pub fn connect_auto(&self, options: ConnectOptions) -> Result { + let flags = options.flags | WNet::CONNECT_REDIRECT; + let args = ConnectArgs::new(self)?; + let resource = args.resource(); + + 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 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 + // 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: `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(args.password.as_ref()), + opt_ptr(args.username.as_deref()), + 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 + /// dropped. + /// + /// 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 + /// canceling by remote name tears down **every** deviceless connection + /// to the resource in this logon session, including ones the guard never + /// made — so deviceless targets are rejected here. Use + /// [`connect_auto_guarded`](Self::connect_auto_guarded) to have Windows + /// pick the device instead. + /// + /// # Errors + /// [`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::GuardRequiresDrive); + }; + let device = device.to_string(); + self.connect_with(options)?; + Ok(Connection { + device, + armed: true, + }) + } + + /// [`connect_auto`](Self::connect_auto) with an RAII [`Connection`] + /// 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 + /// [`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, + armed: true, + }) + } + + /// Disconnect with default options: non-forced, keeping any persistence. + /// + /// 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 + /// Returns [`Error`] when the Windows call fails. + 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 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 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} (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 + } else { + 0 + }; + let status = + unsafe { WNet::WNetCancelConnection2W(wide.as_ptr(), flags, i32::from(options.force)) }; + debug!("WNetCancelConnection2W returned {status}"); + 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). +/// +/// 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 +/// by remote name, which takes every deviceless connection to the resource +/// down with it.) +/// +/// Use [`Connection::disconnect`] for explicit error handling. +#[derive(Debug)] +#[must_use = "dropping the guard disconnects the connection immediately"] +pub struct Connection { + /// The mapped drive this guard exclusively owns. + device: String, + armed: bool, +} + +impl Connection { + /// 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 { + &self.device + } + + /// Disconnect now, with explicit error handling. + /// + /// # Errors + /// See [`Error`]. + pub fn disconnect(mut self, options: DisconnectOptions) -> Result<()> { + self.armed = false; + cancel_connection(&self.device, options) + } +} + +impl Drop for Connection { + fn drop(&mut self) { + if self.armed { + if let Err(e) = cancel_connection(&self.device, DisconnectOptions::new()) { + debug!("failed to disconnect {} on guard drop: {e}", self.device); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configures_target_fluently() { + let target = SmbTarget::new(r"\\server\share") + .username("user") + .password("secret-value") + .mount_on(DriveLetter::D); + assert_eq!(target.username.as_deref(), Some("user")); + assert_eq!(target.password.as_deref(), Some("secret-value")); + assert_eq!(target.local.as_deref(), Some("D:")); + let debug = format!("{target:?}"); + assert!(debug.contains("user")); + assert!(!debug.contains("secret-value")); + } + + #[test] + 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!( + 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/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..648337a --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,279 @@ +//! Live SMB integration tests; see the README's Testing section. +#![cfg(windows)] + +use sambrs::{ + ConnectOptions, DisconnectOptions, DriveLetter, Error, SmbTarget, cancel_connection, enumerate, + 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, +}; +const SHARE: &str = "SAMBRS_TEST_SHARE"; +const USERNAME: &str = "SAMBRS_TEST_USERNAME"; +const PASSWORD: &str = "SAMBRS_TEST_PASSWORD"; + +fn required_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set")) +} + +fn target(mount: Option) -> SmbTarget { + 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 { + std::path::Path::new(&format!(r"{letter}\")).is_dir() +} + +// ── connect / disconnect ──────────────────────────────────────────────────── + +// 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() { + let target = SmbTarget::new(required_env(SHARE)) + .credentials(required_env(USERNAME), "definitely-the-wrong-password-1"); + let result = target.connect(); + assert!( + matches!( + result, + Err(Error::Windows( + ERROR_INVALID_PASSWORD | ERROR_LOGON_FAILURE | ERROR_ACCESS_DENIED + )) + ), + "unexpected result: {result:?}" + ); +} + +#[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(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 + // ERROR_SEM_TIMEOUT (121). + assert!( + matches!( + result, + Err(Error::Windows( + ERROR_BAD_NET_NAME | ERROR_NO_NET_OR_BAD_PATH | ERROR_NO_NETWORK | 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 target = target(None); + target.connect().unwrap(); + // Reconnecting a deviceless connection is fine. + target.connect().unwrap(); + assert!(std::path::Path::new(&required_env(SHARE)).is_dir()); + target.disconnect().unwrap(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn mounted_reconnect_fails_with_already_assigned() { + let target = target(Some(DriveLetter::S)); + target.connect().unwrap(); + assert_eq!( + target.connect(), + Err(Error::Windows(ERROR_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() { + let one = target(Some(DriveLetter::S)); + let two = target(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 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!(target.disconnect(), Err(Error::Windows(ERROR_OPEN_FILES))); + target + .disconnect_with(DisconnectOptions::new().force(true)) + .unwrap(); + drop(file); + assert!(!drive_exists(DriveLetter::U)); + // Clean up the file via a fresh connection. + let target = self::target(Some(DriveLetter::U)); + target.connect().unwrap(); + let _ = std::fs::remove_file(r"U:\sambrs-force-disconnect.txt"); + target.disconnect().unwrap(); +} + +// ── RAII guard ────────────────────────────────────────────────────────────── + +// 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 guard_drop_leaves_other_connections_alone() { + let deviceless = target(None); + deviceless.connect().unwrap(); + let mounted = target(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 ERROR_NOT_CONNECTED if the guard's drop tore it down. + deviceless.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 target = target(None); + let access_name = target.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(); +} + +#[test] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn connect_auto_guarded_owns_the_assigned_device() { + let target = target(None); + let device; + { + let guard = target.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] +#[ignore = "requires a live SMB share; set SAMBRS_TEST_* and run with --include-ignored"] +fn get_connection_returns_the_remote_name() { + let target = target(Some(DriveLetter::W)); + target.connect().unwrap(); + let remote = query::get_connection("W:").unwrap(); + assert!( + remote.eq_ignore_ascii_case(&required_env(SHARE)), + "{remote} != {}", + required_env(SHARE) + ); + 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 target = target(Some(DriveLetter::W)); + target.connect().unwrap(); + let user = query::get_user("W:").unwrap(); + assert!(!user.is_empty()); + 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 target = target(Some(DriveLetter::W)); + target.connect().unwrap(); + let unc = query::get_universal_name(r"W:\").unwrap(); + assert!( + unc.to_ascii_lowercase() + .starts_with(&required_env(SHARE).to_ascii_lowercase()), + "{unc} does not start with {}", + required_env(SHARE) + ); + target.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 target = target(Some(DriveLetter::X)); + target.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(&required_env(SHARE))) + }); + target.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 = 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. + 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))); + target.disconnect().unwrap(); + assert!(found, "test share not found in {server_root}'s share list"); +}