Skip to content

Commit beb9dc4

Browse files
committed
fix: probe the broker socket before reporting Ready; docs match advisory helper-dir check
osascript exits 0 as soon as the consent prompt resolves and the shell backgrounds the broker, so the launcher reported Ready from the SPAWN alone. A broker that died immediately then looked healthy - the exact silent shape the sun_path overflow produced. Probe the socket (connect-and-drop; a dead broker leaves a stale socket FILE behind) and report Pending inside a 15s bind grace, then Disabled, so the UI shows degraded rather than an undiagnosable eternal Pending. Also update DESIGN s5.3.2 for #211: the co-installation helper-dir check is advisory, not fatal, so locked-file backup works on a normal drag-install and the residual is logged as DEGRADED.
1 parent 881ee35 commit beb9dc4

3 files changed

Lines changed: 240 additions & 22 deletions

File tree

README.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Notes:
9999
- ³⁸ Windows and macOS only; Linux needs third-party tools.
100100
- ³⁹ `bench/` runs Driven's real engine and rclone over identical seeded fixtures against a live Drive account, reporting wall time, throughput, API calls, CPU time, and peak memory for a cold and an incremental pass. See [`bench/README.md`](bench/README.md) for scales, costs, and what is and is not apples-to-apples.
101101
- ⁴⁰ The others publish unit-test microbenchmarks, internal tuning harnesses (Duplicati's unreleased AutoTune), or vendor marketing numbers, rather than a runnable end-to-end suite. Backblaze does publish a quarterly benchmark, but of B2 object storage rather than the backup client.
102-
- ⁴¹ Driven's checkmark is scoped to Windows, where a VSS snapshot lets a locked file (Outlook PST, running DB, VM disk) back up while it is held open. Neither macOS nor Linux backs a file up through a lock today - on macOS and Linux, a file Driven cannot open is instead classified precisely as a transient lock (`local.file_locked`) versus a macOS Full Disk Access denial (`local.permission_denied`) and skipped with a clear reason in the activity log, rather than misreported as a disk error. A macOS APFS-snapshot bypass equivalent to Windows VSS exists as a broker crate but is not yet wired into the backup path, so it does not do anything for a user yet; there is no Linux equivalent planned. See `design/DESIGN.md` §5.3 and §5.3.2.
102+
- ⁴¹ Driven's checkmark is scoped to Windows, where a VSS snapshot lets a locked file (Outlook PST, running DB, VM disk) back up while it is held open. macOS has an equivalent behind an opt-in setting (Settings > Rules): a small privileged helper mounts a read-only APFS local snapshot so a *busy* file can be read: it is off by default, and it does nothing for a Full Disk Access denial. On both macOS and Linux, a file Driven cannot open is in any case classified precisely as a transient lock (`local.file_locked`) versus a macOS Full Disk Access denial (`local.permission_denied`) and skipped with a clear reason in the activity log, rather than misreported as a disk error. Linux has no snapshot equivalent and none is planned. See `design/DESIGN.md` §5.3 and §5.3.2.
103103

104104
Competitor rows were verified in July 2026 against rclone 1.74.4, restic 0.19.1,
105105
Duplicati 2.3.0.4, Backblaze Personal Backup 10.0.2, and Drive for desktop 128.0.
@@ -128,8 +128,9 @@ These move: check each project's current docs before relying on a cell.
128128
files, VM disks) still back up. On macOS and Linux, a file Driven cannot open
129129
is classified precisely - a transient lock versus a macOS Full Disk Access
130130
denial - and skipped with a clear reason rather than reported as a generic
131-
disk error; an APFS-snapshot bypass equivalent to Windows VSS is in
132-
development for macOS but not yet available.
131+
disk error. macOS can also back up a *busy* file through an opt-in APFS
132+
snapshot (Settings > Rules), which does not help with a Full Disk Access
133+
denial; there is no Linux equivalent.
133134
- In-app restore browser with full-text file-name search and streaming decrypt.
134135
- Activity dashboard with a live tail and filterable history.
135136
- Rolling local log files covering both the backend and the webview console,
@@ -208,15 +209,47 @@ is planned for a future release. Until then, grant it manually if you want
208209
those folders backed up; everything else backs up normally without it.
209210

210211
**A locked-file snapshot is not a substitute for Full Disk Access, and Driven
211-
never tries to make it one.** Driven's locked-file handling (Windows VSS
212-
today; a macOS APFS-snapshot equivalent is in development, see the table
213-
above) exists to read around a file that is transiently *busy* - open in
212+
never tries to make it one.** Driven's locked-file handling (Windows VSS,
213+
and the opt-in macOS APFS snapshot described below) exists to read around a file that is transiently *busy* - open in
214214
another program. It does not and cannot read around a TCC *denial*, because a
215215
snapshot preserves the original file's permissions and is itself subject to
216216
the same TCC check. The historical `-o noowners` mount trick that could bypass
217217
this (CVE-2020-9771) is long patched and is now a signature EDR products flag
218218
as suspicious; Driven does not use it and never will.
219219

220+
#### macOS locked-file backup (APFS snapshot, opt-in)
221+
222+
Two different things stop a file being backed up on macOS, and they have
223+
different fixes:
224+
225+
| Situation | Reported as | Fix |
226+
|-----------|-------------|-----|
227+
| The file is held open / busy - a live database, a VM disk, a mail store | `local.file_locked` | Turn on **Back up locked files using an APFS snapshot** in Settings > Rules |
228+
| macOS privacy protection denies the read | `local.permission_denied` | Grant Full Disk Access (above). Nothing else works. |
229+
230+
The APFS snapshot option is **off by default**. Turning it on asks for your
231+
administrator password once per session, and from then on Driven reads busy
232+
files out of a read-only APFS local snapshot mounted by a small privileged
233+
helper - the app itself stays un-elevated, and the helper only ever mounts and
234+
unmounts. It works without Time Machine being set up.
235+
236+
> **Note on drag-installed copies.** One of the helper's defence-in-depth
237+
> checks is weaker when Driven is installed the usual way. The helper confirms
238+
> that whatever is talking to it sits next to it in the same folder - which only
239+
> proves much if you could not write to that folder yourself. Dragging an app
240+
> out of a `.dmg` makes **you** the owner of everything inside it (this is true
241+
> even when you drag it into `/Applications`; only `.pkg` and App Store installs
242+
> land root-owned), so on a normal Driven install that check is advisory and the
243+
> helper records a `DEGRADED` line in its own log instead of enforcing it.
244+
>
245+
> Locked-file backup still works, and the checks that carry the real weight are
246+
> unaffected: the helper only talks to your own user account, only mounts
247+
> volumes Driven listed at launch, and only ever makes read-only mounts that
248+
> preserve the original file ownership. Someone who defeated the folder check
249+
> would already have to be running as you, and would gain a read-only copy of
250+
> files they could already read. Installing from a `.pkg` would restore the
251+
> check to full strength; it is an improvement, not a prerequisite.
252+
220253
#### macOS auto-updater caveat
221254

222255
Because the macOS build is not signed with a Developer ID, the in-app

design/DESIGN.md

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -745,21 +745,49 @@ nor replace the directory; if they pre-create it, it is owned by them and the
745745
broker's own "socket parent must be owned by the peer uid with mode 0700"
746746
check refuses to serve, so the failure is closed rather than silent.
747747

748-
**Install layout decides whether the feature works at all.** The broker
749-
refuses to serve when the helper's own directory is writable by the client
750-
uid, because the peer check ("the caller's executable sits next to mine") is
751-
meaningless if the caller can drop a binary there. Measured on macOS 26.6:
752-
every app drag-installed from a DMG - including into `/Applications`, not
753-
merely `~/Applications` - has `Contents/MacOS` owned by the installing USER
754-
(verified across AltTab, Audacity, Chrome, Claude, Discord, Docker, GIMP),
755-
whereas pkg- and App-Store-installed apps (Cloudflare WARP, GarageBand) are
756-
`root:wheel`. Driven currently ships a `.dmg` whose documented install is a
757-
drag to `/Applications`, so on a stock install the broker exits at startup
758-
and locked-file backup never engages. Shipping this feature in a usable state
759-
therefore requires a `.pkg` (or a post-install `chown root:wheel` step) that
760-
lands the bundle root-owned. This is a deliberate security property of the
761-
broker, not a bug to work around: the alternative is a co-installation check
762-
that looks like authentication and is not.
748+
**Install layout weakens one check but does not disable the feature.** The
749+
broker's co-installation check ("the caller's executable sits next to mine")
750+
is only strong if the peer user cannot WRITE to the helper's directory.
751+
Measured on macOS 26.6, essentially no drag-installed bundle satisfies that:
752+
every app installed by dragging out of a `.dmg` - including into
753+
`/Applications`, not merely `~/Applications` - has `Contents/MacOS` owned by
754+
the installing USER (verified across AltTab, Audacity, Chrome, Claude,
755+
Discord, Docker, GIMP), whereas pkg- and App-Store-installed apps (Cloudflare
756+
WARP, GarageBand) are `root:wheel`. Driven ships a `.dmg`, so this is the
757+
normal case, not the exotic one.
758+
759+
The check is therefore **advisory** rather than fatal: the broker serves, and
760+
writes a `DEGRADED:` line to its root-owned audit log naming the directory and
761+
the uid. The reasoning is that the attacker it guards against is ALREADY the
762+
same uid - `getpeereid` is not bypassable by planting a binary - so defeating
763+
co-installation buys them a read-only, `nosuid`, `nodev`,
764+
ownership-preserving mount of an allow-listed volume at a broker-chosen
765+
mountpoint. That is a point-in-time copy of files that uid could already read,
766+
with TCC still applying to their own process. Trading locked-file backup away
767+
for every drag-install user would not have been a good exchange. The
768+
load-bearing checks are the peer uid, the volume allow-list, the snapshot-name
769+
validation and the mount options; this one is defence in depth.
770+
771+
A `.pkg`-style install that lands the bundle root-owned restores the check to
772+
full strength and silences the `DEGRADED:` line, and remains the better
773+
end state - but it is an improvement, not a prerequisite.
774+
775+
**`Ready` means the socket answered, not that a process was spawned.**
776+
`osascript` exits 0 as soon as the consent prompt resolves and the shell
777+
backgrounds the broker, so the launcher's own `Ready` reflects the SPAWN
778+
alone. If the broker then dies immediately - a failed pre-flight check, a bad
779+
sidecar, a socket path that will not bind - the user gets an administrator
780+
prompt followed by a healthy-looking status while nothing ever mounts. That
781+
silent shape is precisely what the `sun_path` overflow above produced, and it
782+
is the difference between a bug found in minutes and one found never. The app
783+
manager therefore probes the socket before passing `Ready` upward: a
784+
connect-and-drop liveness check (existence is not enough - a dead broker
785+
leaves its socket file behind, and connecting to that fails `ECONNREFUSED`).
786+
An unbacked `Ready` reports `Pending` - a transient skip, retried next cycle -
787+
for a 15s grace window covering the bind, and then `Disabled`, which makes
788+
`helper_launchable` false and surfaces as degraded in the UI rather than as an
789+
eternal `Pending` nobody can diagnose. Probing stops at the first success, so
790+
a healthy session pays a couple of connects, not one per status poll.
763791

764792
**Not a TCC bypass.** Worth restating because it is the single most likely
765793
misreading of this whole section: an APFS snapshot lets Driven read a file

src-tauri/src/apfs_helper.rs

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ pub struct ApfsHelperManager {
8282
/// [`HelperLauncher::launch_status`] on a locked file, and the enable-toggle
8383
/// [`Self::launch_now`] - and cleared whenever the launcher is replaced.
8484
launch_attempted: AtomicBool,
85+
/// Set once the broker's socket has actually answered, after which the
86+
/// liveness probe in [`Self::confirm_ready`] is skipped for the session.
87+
broker_confirmed: AtomicBool,
88+
/// When the launcher FIRST claimed `Ready` without a live socket behind it,
89+
/// so the grace window in [`Self::confirm_ready`] can expire.
90+
ready_since: Mutex<Option<std::time::Instant>>,
91+
}
92+
93+
/// How long a launcher-reported `Ready` may go unbacked by a live socket before
94+
/// the broker is declared dead. Generous: it only has to cover the gap between
95+
/// `osascript` returning and the broker binding its socket, and erring long
96+
/// costs a few extra transient skips while erring short would call a healthy
97+
/// broker dead.
98+
const BROKER_READY_GRACE: std::time::Duration = std::time::Duration::from_secs(15);
99+
100+
/// Whether a unix-domain socket at `path` has a LISTENER behind it right now.
101+
///
102+
/// Existence alone is not enough: a broker that died leaves its socket file on
103+
/// disk, and connecting to that stale file fails with `ECONNREFUSED`. The
104+
/// connection is dropped immediately without speaking the protocol - this is a
105+
/// liveness probe, not a handshake, and [`crate::apfs_helper`] never treats it
106+
/// as authentication (the client's own root-peer check does that).
107+
#[cfg(unix)]
108+
fn socket_is_live(path: &Path) -> bool {
109+
std::os::unix::net::UnixStream::connect(path).is_ok()
110+
}
111+
112+
/// Off unix there are no unix-domain sockets, and the launcher can never report
113+
/// `Ready` there anyway (the osascript stub always fails).
114+
#[cfg(not(unix))]
115+
fn socket_is_live(_path: &Path) -> bool {
116+
false
85117
}
86118

87119
impl ApfsHelperManager {
@@ -119,6 +151,8 @@ impl ApfsHelperManager {
119151
enabled: AtomicBool::new(enabled),
120152
launcher: Mutex::new(launcher),
121153
launch_attempted: AtomicBool::new(false),
154+
broker_confirmed: AtomicBool::new(false),
155+
ready_since: Mutex::new(None),
122156
}
123157
}
124158

@@ -169,6 +203,10 @@ impl ApfsHelperManager {
169203
// A fresh launcher has not been triggered, so status reads must go back
170204
// to answering "not yet tried" WITHOUT touching it.
171205
self.launch_attempted.store(false, Ordering::SeqCst);
206+
// The next launch gets a fresh broker, so re-prove its liveness rather
207+
// than trusting a previous session's confirmation.
208+
self.broker_confirmed.store(false, Ordering::SeqCst);
209+
*self.ready_since.lock().unwrap_or_else(|p| p.into_inner()) = None;
172210
}
173211

174212
/// The launcher's status, but ONLY once a launch has genuinely been
@@ -180,7 +218,48 @@ impl ApfsHelperManager {
180218
if !self.is_enabled() || !self.launch_attempted.load(Ordering::SeqCst) {
181219
return None;
182220
}
183-
Some(self.current_launcher().launch_status())
221+
Some(self.confirm_ready(self.current_launcher().launch_status()))
222+
}
223+
224+
/// Downgrade a launcher-reported `Ready` that no live broker backs.
225+
///
226+
/// `osascript` exits 0 as soon as the consent prompt resolves and the shell
227+
/// BACKGROUNDS the broker (`... &`), so the launcher records `Ready` from
228+
/// the spawn alone. If the broker then dies immediately - a refused
229+
/// pre-flight check, a bad sidecar, a socket path that will not bind - the
230+
/// user sees an administrator prompt followed by a healthy status while
231+
/// nothing ever mounts. That silent shape is exactly what the `sun_path`
232+
/// overflow produced, so `Ready` is only reported once the socket actually
233+
/// answers.
234+
///
235+
/// Timing matters: the broker needs a moment to bind after the spawn, so a
236+
/// not-yet-live socket is `Pending` (a transient skip, retried next cycle)
237+
/// until [`BROKER_READY_GRACE`] elapses, and only then `Disabled` - which
238+
/// makes `helper_launchable` false and surfaces as "degraded" in the UI
239+
/// rather than as an eternal `Pending` nobody can diagnose.
240+
///
241+
/// Probing stops permanently at the first success, so a healthy session
242+
/// costs at most a few connects rather than one per status poll.
243+
fn confirm_ready(&self, status: HelperLaunchStatus) -> HelperLaunchStatus {
244+
if status != HelperLaunchStatus::Ready || self.broker_confirmed.load(Ordering::SeqCst) {
245+
return status;
246+
}
247+
if socket_is_live(&self.socket) {
248+
self.broker_confirmed.store(true, Ordering::SeqCst);
249+
return HelperLaunchStatus::Ready;
250+
}
251+
let mut first = self.ready_since.lock().unwrap_or_else(|p| p.into_inner());
252+
let started = *first.get_or_insert_with(std::time::Instant::now);
253+
if started.elapsed() < BROKER_READY_GRACE {
254+
HelperLaunchStatus::Pending
255+
} else {
256+
tracing::warn!(
257+
socket = %self.socket.display(),
258+
"apfs broker reported launched but its socket never came up; \
259+
locked-file backup is unavailable this session"
260+
);
261+
HelperLaunchStatus::Disabled
262+
}
184263
}
185264

186265
/// Apply a change to the `macos.apfs_snapshot` setting (called from the
@@ -422,6 +501,84 @@ mod tests {
422501
/// on every Mac, not just unusual ones. Confirmed against the real broker
423502
/// binary on macOS 26.6. Nothing else in the suite binds a socket, so only
424503
/// a length assertion catches it.
504+
/// The liveness probe must distinguish a LIVE listener from a stale socket
505+
/// FILE. A broker that died leaves its socket on disk, so an
506+
/// existence-only check would keep reporting a healthy helper forever -
507+
/// which is the exact silent shape this probe exists to prevent.
508+
#[cfg(unix)]
509+
#[test]
510+
fn socket_probe_sees_a_live_listener_and_rejects_a_stale_file() {
511+
let dir = std::env::temp_dir().join(format!("driven-apfs-probe-{}", std::process::id()));
512+
let _ = std::fs::create_dir_all(&dir);
513+
let sock = dir.join("probe.sock");
514+
let _ = std::fs::remove_file(&sock);
515+
516+
// Nothing there at all.
517+
assert!(!socket_is_live(&sock), "absent socket is not live");
518+
519+
// A real listener answers.
520+
let listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind probe socket");
521+
assert!(socket_is_live(&sock), "a bound listener must probe live");
522+
523+
// Dropping the listener leaves the FILE behind but nothing listening -
524+
// connect must now fail (ECONNREFUSED), not succeed on file existence.
525+
drop(listener);
526+
assert!(sock.exists(), "the stale socket file is still on disk");
527+
assert!(
528+
!socket_is_live(&sock),
529+
"a stale socket file must NOT probe live"
530+
);
531+
532+
let _ = std::fs::remove_file(&sock);
533+
let _ = std::fs::remove_dir(&dir);
534+
}
535+
536+
#[test]
537+
fn a_ready_launcher_without_a_live_socket_is_not_reported_ready() {
538+
// The manager's socket path is never bound in this test, so a launcher
539+
// claiming Ready is exactly the "osascript spawned it, then it died"
540+
// case. It must degrade to Pending inside the grace window rather than
541+
// telling the UI locked-file backup is healthy.
542+
let m = manager_with_real_exe(true);
543+
assert_eq!(
544+
m.confirm_ready(HelperLaunchStatus::Ready),
545+
HelperLaunchStatus::Pending,
546+
"an unbacked Ready degrades to Pending while the broker may still be binding"
547+
);
548+
// Non-Ready statuses pass through untouched.
549+
assert_eq!(
550+
m.confirm_ready(HelperLaunchStatus::Declined),
551+
HelperLaunchStatus::Declined
552+
);
553+
assert_eq!(
554+
m.confirm_ready(HelperLaunchStatus::Disabled),
555+
HelperLaunchStatus::Disabled
556+
);
557+
}
558+
559+
#[test]
560+
fn an_unbacked_ready_becomes_disabled_once_the_grace_window_expires() {
561+
let m = manager_with_real_exe(true);
562+
// Backdate the first-Ready stamp past the grace window.
563+
*m.ready_since.lock().expect("ready_since") = Some(
564+
std::time::Instant::now()
565+
.checked_sub(BROKER_READY_GRACE + std::time::Duration::from_secs(5))
566+
.expect("backdate"),
567+
);
568+
assert_eq!(
569+
m.confirm_ready(HelperLaunchStatus::Ready),
570+
HelperLaunchStatus::Disabled,
571+
"a broker whose socket never came up must end DISABLED (degraded + \
572+
diagnosable), not Pending forever"
573+
);
574+
// Disabled is what `helper_launchable` maps to false, which is what the
575+
// UI renders as degraded. That end-to-end mapping is not asserted here
576+
// on purpose: reaching it requires `launch_attempted`, and setting that
577+
// would make the accessors call the real launcher, whose NotAttempted
578+
// transition spawns an actual osascript consent prompt. See
579+
// `reading_status_never_triggers_a_consent_prompt` for that guard.
580+
}
581+
425582
#[test]
426583
fn socket_path_fits_in_sockaddr_un() {
427584
// Darwin's sun_path is 104 bytes including the NUL terminator.

0 commit comments

Comments
 (0)