Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion crates/uffs-update/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(())
}

Expand Down
89 changes: 89 additions & 0 deletions crates/uffs-update/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
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.
Expand Down Expand Up @@ -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");
}
}
11 changes: 11 additions & 0 deletions crates/uffs-winsvc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
9 changes: 9 additions & 0 deletions crates/uffs-winsvc/src/stub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
45 changes: 45 additions & 0 deletions crates/uffs-winsvc/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<TOKEN_ELEVATION>()).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
}
9 changes: 6 additions & 3 deletions docs/user-manual/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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` |

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions docs/user-manual/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down Expand Up @@ -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 <pattern>` 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
Expand Down
7 changes: 6 additions & 1 deletion docs/user-manual/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading