diff --git a/crates/uffs-update/src/main.rs b/crates/uffs-update/src/main.rs index de1bdebf1..988f6d5df 100644 --- a/crates/uffs-update/src/main.rs +++ b/crates/uffs-update/src/main.rs @@ -105,7 +105,20 @@ fn run_apply(args: &[String]) -> Result<()> { // begin (no live journal yet for this run). sweep_self_backups_if_idle(&update_dir); - let snapshot = plan::Snapshot::load(&snapshot_path)?; + let mut snapshot = plan::Snapshot::load(&snapshot_path)?; + + // Non-elevated apply skips the Access Broker: it is a LocalSystem service we + // can neither stop (to unlock its `.exe`) nor restart without admin. Drop it + // from this run — update everything else; the running broker keeps serving + // (its wire protocol is back-compatible) and catches up on the next elevated + // update. `is_elevated()` is `false` off Windows, but the snapshot has no + // broker there, so this is a no-op away from Windows. + let skipped_broker = if uffs_winsvc::is_elevated() { + None + } else { + snapshot.drop_broker() + }; + let backup_dir = update_dir.join(format!("backup-{}", std::process::id())); let journal_path = update_dir.join("journal.json"); let mut journal = orchestrate::journal_from_snapshot(journal_path, &snapshot, backup_dir); @@ -167,6 +180,14 @@ fn run_apply(args: &[String]) -> Result<()> { journal.transition(journal::UpdateState::Done, "apply.done")?; journal.archive(); println!("Applied + committed → {}", journal.to_version); + if let Some(broker_version) = skipped_broker { + println!( + "note: the Access Broker (uffs-broker {broker_version}) was left running \ + and NOT updated — refreshing the LocalSystem broker service needs \ + elevation. The running broker stays compatible; for a full refresh \ + (incl. the broker), run once from an elevated shell:\n uffs --update" + ); + } Ok(()) } diff --git a/crates/uffs-update/src/plan.rs b/crates/uffs-update/src/plan.rs index ada406830..e1712ecd2 100644 --- a/crates/uffs-update/src/plan.rs +++ b/crates/uffs-update/src/plan.rs @@ -14,6 +14,11 @@ use std::path::PathBuf; use anyhow::{Context as _, Result}; use serde::Deserialize; +/// Snapshot binary stem for the Access Broker (`uffs-broker.exe`). +const BROKER_STEM: &str = "uffs-broker"; +/// Snapshot running-component name for the Access Broker (vs. its binary stem). +const BROKER_COMPONENT: &str = "broker"; + /// A parsed Phase-B snapshot. #[derive(Debug, Clone, Deserialize)] pub(crate) struct Snapshot { @@ -84,6 +89,41 @@ impl Snapshot { self.to_version.as_deref().unwrap_or("unknown") } + /// Remove the Access Broker from this snapshot so a non-elevated apply + /// leaves it alone. + /// + /// The broker is a `LocalSystem` service: stopping it (to unlock its + /// `.exe`) and restarting it both need elevation, and its wire protocol is + /// fixed/back-compatible — so a slightly-older broker keeps serving a newer + /// daemon. Drops the `broker` running entry and every `uffs-broker` binary + /// target. Returns the broker's on-disk version (for a user hint), or + /// `None` when the snapshot had no broker at all (e.g. off Windows). + pub(crate) fn drop_broker(&mut self) -> Option { + let present = self + .running + .iter() + .any(|run| run.component == BROKER_COMPONENT) + || self + .targets + .iter() + .any(|tgt| tgt.binaries.iter().any(|bin| bin.name == BROKER_STEM)); + if !present { + return None; + } + let version = self + .targets + .iter() + .flat_map(|tgt| &tgt.binaries) + .find(|bin| bin.name == BROKER_STEM) + .and_then(|bin| bin.on_disk_version.clone()) + .unwrap_or_else(|| "?".to_owned()); + self.running.retain(|run| run.component != BROKER_COMPONENT); + for target in &mut self.targets { + target.binaries.retain(|bin| bin.name != BROKER_STEM); + } + Some(version) + } + /// Roots the **updater** owns: `unmanaged` only. `WinGet` roots are /// delegated to `winget upgrade` by `uffs-cli`; dev-build roots are /// never auto-updated. @@ -173,4 +213,53 @@ mod tests { assert_eq!(snap.prior_version(), "unknown"); assert_eq!(snap.unmanaged_targets().count(), 0); } + + #[test] + fn drop_broker_removes_broker_and_reports_version() { + const WITH_BROKER: &str = r#"{ + "to_version": "0.6.11", + "targets": [ + { "root": "C:\\uffs", "channel": "unmanaged", "binaries": [ + { "name": "uffsd", "on_disk_version": "0.6.10" }, + { "name": "uffs-broker", "on_disk_version": "0.6.10" } + ] } + ], + "running": [ + { "component": "daemon", "pid": 42 }, + { "component": "broker", "pid": 7 } + ] + }"#; + let mut snap: Snapshot = serde_json::from_str(WITH_BROKER).expect("parse"); + assert_eq!( + snap.drop_broker().as_deref(), + Some("0.6.10"), + "returns the broker's version for the user hint" + ); + assert!( + snap.targets + .iter() + .flat_map(|tgt| &tgt.binaries) + .all(|bin| bin.name != "uffs-broker"), + "broker binary target removed" + ); + assert!( + snap.running.iter().all(|run| run.component != "broker"), + "broker running entry removed" + ); + assert!( + snap.running.iter().any(|run| run.component == "daemon"), + "non-broker components are untouched" + ); + assert_eq!( + snap.drop_broker(), + None, + "a second call finds no broker left to drop" + ); + } + + #[test] + fn drop_broker_is_none_when_absent() { + let mut snap: Snapshot = serde_json::from_str(SNAP).expect("parse"); + assert_eq!(snap.drop_broker(), None, "snapshot has no broker"); + } } diff --git a/crates/uffs-winsvc/src/lib.rs b/crates/uffs-winsvc/src/lib.rs index e4d5c2c7d..4b9240154 100644 --- a/crates/uffs-winsvc/src/lib.rs +++ b/crates/uffs-winsvc/src/lib.rs @@ -154,6 +154,17 @@ pub fn pipe_serving(pipe_name: &str, timeout_ms: u32) -> bool { sys::pipe_serving(pipe_name, timeout_ms) } +/// `true` if the current process runs with an elevated (Administrator) token. +/// +/// Queries `TokenElevation` on the current process token. Any FFI failure maps +/// to `false` — the conservative answer for a privilege gate. Always `false` +/// off Windows (there is no token-elevation concept; non-Windows callers that +/// care about root use their own check). +#[must_use] +pub fn is_elevated() -> bool { + sys::is_elevated() +} + #[cfg(test)] mod tests { use super::{ServiceInfo, ServiceState}; diff --git a/crates/uffs-winsvc/src/stub.rs b/crates/uffs-winsvc/src/stub.rs index 8c88c0b6b..944e3e408 100644 --- a/crates/uffs-winsvc/src/stub.rs +++ b/crates/uffs-winsvc/src/stub.rs @@ -51,3 +51,12 @@ pub(crate) fn stop(_service: &str) -> Result<()> { pub(crate) fn pipe_serving(_pipe_name: &str, _timeout_ms: u32) -> bool { true } + +/// No Windows token here — off-Windows callers use their own root check. +#[expect( + clippy::missing_const_for_fn, + reason = "mirrors the non-const Windows impl so the public wrapper is uniform" +)] +pub(crate) fn is_elevated() -> bool { + false +} diff --git a/crates/uffs-winsvc/src/windows.rs b/crates/uffs-winsvc/src/windows.rs index d6e62843d..7b09479b3 100644 --- a/crates/uffs-winsvc/src/windows.rs +++ b/crates/uffs-winsvc/src/windows.rs @@ -179,3 +179,48 @@ pub(crate) fn pipe_serving(pipe_name: &str, timeout_ms: u32) -> bool { let ready = unsafe { WaitNamedPipeW(PCWSTR(name.as_ptr()), timeout_ms) }; ready.as_bool() } + +/// Query `TokenElevation` on the current process token. Any FFI failure maps to +/// `false` — the conservative answer for a privilege gate. +pub(crate) fn is_elevated() -> bool { + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::Security::{ + GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + // SAFETY: returns the current-process pseudo-handle; never fails. + #[expect(unsafe_code, reason = "Win32 FFI — GetCurrentProcess")] + let process = unsafe { GetCurrentProcess() }; + + let mut token = HANDLE::default(); + // SAFETY: opening our OWN process token with query access; `token` is a + // live out-param written only on success. Returns `Err` on failure. + #[expect(unsafe_code, reason = "Win32 FFI — OpenProcessToken")] + let opened = unsafe { OpenProcessToken(process, TOKEN_QUERY, core::ptr::from_mut(&mut token)) }; + if opened.is_err() { + return false; + } + + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0_u32; + // SAFETY: `token` is a valid token handle; the buffer is exactly one + // `TOKEN_ELEVATION`; `returned` is a live `u32` out-param. + #[expect(unsafe_code, reason = "Win32 FFI — GetTokenInformation")] + let ok = unsafe { + GetTokenInformation( + token, + TokenElevation, + Some(core::ptr::from_mut(&mut elevation).cast()), + u32::try_from(size_of::()).unwrap_or(0), + core::ptr::from_mut(&mut returned), + ) + } + .is_ok(); + + // SAFETY: `token` came from `OpenProcessToken` and is closed exactly once. + #[expect(unsafe_code, reason = "Win32 FFI — CloseHandle")] + let _closed = unsafe { CloseHandle(token) }; + + ok && elevation.TokenIsElevated != 0 +} diff --git a/docs/user-manual/daemon.md b/docs/user-manual/daemon.md index 4e96447a3..5c7c274af 100644 --- a/docs/user-manual/daemon.md +++ b/docs/user-manual/daemon.md @@ -73,7 +73,10 @@ uffs "*.exe" uffs --daemon start --drive C --drive D ``` -> **Note:** Live MFT access requires **Administrator privileges**. +> **Note:** Live MFT access needs elevation. Install the Access Broker **once** +> (`uffs-broker --install`, from an elevated terminal) and the daemon — its +> start/stop/restart and non-elevated updates — runs with **no UAC**; otherwise +> start it from an Administrator terminal. --- @@ -188,7 +191,7 @@ logging — see [Advanced Diagnostics](advanced-diagnostics.md) for details. | Aspect | Windows | macOS / Linux | |--------|---------|---------------| | Data source | Live NTFS MFT (auto-detected) | Offline captures (`.iocp`, `.bin`, `.mft`) | -| Privileges | Administrator required | None (reads regular files) | +| Privileges | Admin **once** (Access Broker) → then none; else Administrator | None (reads regular files) | | IPC transport | Named pipe | Unix domain socket | | Auto-discovery | All NTFS drives | Requires `--data-dir` or `--mft-file` | @@ -239,7 +242,7 @@ stdout formatting. | "Connection refused" on search | Daemon not running | Let auto-start handle it, or `uffs --daemon start` | | Stale PID file | Previous daemon crashed | `uffs --daemon kill` removes PID + socket | | First search slow after restart | MFT being loaded | Normal — ~7 s warm cache (or ~66 s cold), sub-second after | -| "Permission denied" (Windows) | Not running as Admin | Right-click terminal → "Run as administrator" | +| "Permission denied" (Windows) | No broker + not elevated | Install the Access Broker once (`uffs-broker --install`, elevated) for zero-UAC, or run the terminal as Administrator | | Multiple daemons running | Rare race condition | `uffs --daemon kill` + `uffs --daemon start` | > **More troubleshooting:** [Troubleshooting](troubleshooting.md) diff --git a/docs/user-manual/installation.md b/docs/user-manual/installation.md index 4fb64008c..31a577c27 100644 --- a/docs/user-manual/installation.md +++ b/docs/user-manual/installation.md @@ -33,7 +33,10 @@ Confirm the install: uffs --version ``` -> Live NTFS search still requires an **Administrator** terminal — see +> Live NTFS search needs elevation **once**: install the Access Broker +> (`uffs-broker --install`, one-time from an elevated terminal) and every later +> search — plus daemon start/stop and non-elevated updates — runs with **no +> UAC**. Without the broker, use an Administrator terminal. See > [§3 Platform Requirements](#3--platform-requirements). --- @@ -103,22 +106,30 @@ Get-FileHash uffs-windows-x64.exe -Algorithm SHA256 | Platform | Data source | Privileges | |----------|------------|------------| -| **Windows** | Live NTFS MFT (auto-detected) | Administrator required | +| **Windows** | Live NTFS MFT (auto-detected) | Admin **once** (Access Broker) → then none; or an Administrator terminal | | **macOS / Linux** | Offline MFT captures (`.iocp`, `.bin`, `.mft`) | None | ### Windows -The pre-built binary reads NTFS drives directly. **Administrator -privileges are required** — the MFT is a protected system structure. +The pre-built binary reads NTFS drives directly. The MFT is a protected +system structure, so reading it needs elevation — but you grant it **once**: ```powershell -# Option A: Run your terminal as Administrator +# Option A (recommended): install the Access Broker — one-time elevation, then +# NO UAC on any later search, daemon start/stop, or non-elevated update. +uffs-broker --install # run once from an elevated PowerShell + +# Option B: run your terminal as Administrator each time # Right-click Terminal → "Run as administrator" -# Option B: Use gsudo (recommended) +# Option C: per-command elevation with gsudo gsudo uffs "*.dll" ``` +With the broker installed, plain `uffs ` and `uffs --daemon …` run +unelevated — no prompts. The broker is a `LocalSystem` service that vends the +daemon a read-only volume handle, so the daemon itself never needs admin. + ### macOS / Linux macOS and Linux cannot read NTFS drives directly. You need offline diff --git a/docs/user-manual/updating.md b/docs/user-manual/updating.md index de5f10d4e..caa430a26 100644 --- a/docs/user-manual/updating.md +++ b/docs/user-manual/updating.md @@ -121,7 +121,12 @@ Add `--offline` to skip every network check (and the update redirect). try to restart it. - **Windows broker:** if `uffs --update doctor` warns the broker pipe isn't serving, install it from an elevated PowerShell with `uffs-broker --install`. - It's a self-update target too, so later updates keep it current. + The broker is a `LocalSystem` service, so refreshing **it** needs elevation: a + **non-elevated** `uffs --update` updates every binary *except* the broker — + the running broker keeps serving (its wire protocol is back-compatible) and + the update prints a reminder. Run `uffs --update` once from an **elevated** + shell to bring the broker up to date too. (Everything else — daemon included — + updates without elevation once the broker is installed.) - **Publisher: Unknown:** binaries aren't code-signed yet, so a fresh download may show a SmartScreen / UAC warning — see [Installation](installation.md) for verifying a download with `CHECKSUMS.txt`