Skip to content

Commit 33c281c

Browse files
pmaxhoganclaude
andauthored
feat(app): opt-in debug logging mode and safer, richer diagnostic bundles (#314)
## Summary Closes #309, closes #204. Part of the v2.12.0 wave (PR5). **#204 (diagnostic bundle PII/secret leaks)** - `redact_settings()` used to clone `GlobalSettings` verbatim and patch only `proxy_url`. It now builds a field-for-field `RedactedGlobalSettings` struct, so a future secret-bearing field added to `GlobalSettings` fails to *compile* here until someone decides how to redact it, instead of leaking silently. Fixes the three concrete leaks the issue named: - `pre_backup_hook` / `post_backup_hook` (command lines - a classic home for embedded secrets) are now redacted wholesale (`<hook-redacted: N chars>`), not shipped raw. - `custom_root_ca_path` is now hashed through the same `<path:hash>` scheme the rest of the bundle already uses. - `proxy_url` in PAC mode (a local file path, not a URL) is now hashed instead of only having userinfo-stripping applied (which never matched a bare path). - The issue's "also worth fixing" item (`ProxyError`'s `Display` embedding raw userinfo) was already fixed by #208 - verified via `git blame`, not touched again. **#309 (debug logging mode)** - a new Settings > Privacy & Data toggle ("Debug logging") with an always-visible amber warning panel (shown before the toggle is ever switched on, per the approved mockup), backed by: - A real runtime-reloadable tracing filter (`logging.rs`, `tracing_subscriber::reload`) - flipping the toggle now actually changes the live process's verbosity, no restart needed. This also closes a long-documented gap where `global.log_level` only ever exported `RUST_LOG` for the *next* launch; it now reloads the live filter too (deferred while debug mode is active, so it doesn't undo the debug-mode filter). - A persisted epoch-ms expiry + a boot-time reconcile and periodic watchdog (`debug_mode.rs`) that auto-turns the toggle off 24h after enabling - honoured across a restart, not just while the app keeps running. The watchdog is registered on `AppState` and joined by #312's no-orphan quit drain (`ShutdownHandles`/`drain_shutdown_handles`), the same pattern #311's bottleneck sampler uses. - A rolling log cap that widens from 25 MB to 250 MB while debug mode is on. - The diagnostic bundle gains `DEBUG_MODE.txt` and an unredacted `debug/engine_state.txt` while debug mode is on - the one deliberate exception to the #204 redaction rules, gated on the user's explicit opt-in (every other bundle file stays redacted regardless). - Every bundle now also ships `manifest.txt` (entry name + size), a small bundle-usefulness improvement. - Activity's "Export diagnostic bundle" button shows an amber "Debug data included" chip while debug mode is on. ## Also in this PR - **Rebased onto `main`** after #310-#316 merged. Re-homed the debug-mode watchdog from a detached `memlog.rs`-style task into #312's `ShutdownHandles`/`drain_shutdown_handles` no-orphan quit drain (new `DebugModeRuntime` on `AppState`, `set_debug_mode_task`/`shutdown_debug_mode_task` mirroring `set_bottleneck_task`/`shutdown_bottleneck_task`). - **CodeQL `rust/path-injection` fix** (not a dismissal): two test helpers (`settings.rs`'s pre-existing `seeded_repo()` and this PR's new `debug_mode.rs` one) hand-rolled a temp dir via `std::env::temp_dir().join(format!(...))` before feeding it to `SqliteStateRepo::open` - exactly the pattern this repo's CodeQL rule flags (see the `tempfile` dependency comment in `src-tauri/Cargo.toml`, and PR 151 precedent). Switched both to `tempfile::tempdir().keep()`, an opaque external call CodeQL's dataflow can't see into, so the taint chain never forms. - **Also carries the h2 advisory fix** (RUSTSEC-2026-0258, low severity, unbounded empty DATA frames) - `cargo update -p h2` (0.4.15 -> 0.4.16), lockfile-only, no `Cargo.toml` changes. This advisory is unrelated to this PR's own diff (`git diff` against the pre-PR base shows zero `Cargo.lock` changes before this commit) and would fail `cargo deny` repo-wide on `main` too; landing it here unblocks this PR's `cargo deny` check and delivers the fix to `main` in the same step. ## Test plan - [x] `cargo test -p driven-app --lib` - 494 passed (18 #204 redaction tests with leak-shaped fixtures, incl. one asserting the full serialized bundle JSON end-to-end; 5 debug-mode watchdog/expiry tests; 6 settings-persistence round-trip tests; 1 `AppState` debug-mode task/shutdown round-trip test) - [x] `cargo clippy --workspace --all-targets -- -D warnings` - clean - [x] `cargo fmt --all -- --check` - clean - [x] `cargo build --workspace --tests` - clean - [x] `cargo deny check` - clean (advisories ok, bans ok, licenses ok, sources ok) - [x] `pnpm lint` / `pnpm format:check` / `pnpm test:unit` (861 passed, 64 files) / `pnpm build` (vue-tsc + vite) - all clean, run in the CI job's exact order - [x] Linux visual baselines regenerated via `just visual-update` (Docker) - `privacy.png` (light+dark) plus 9 `shell.spec.ts` baselines (light+dark) that had drifted independently of this PR; all 106 visual specs pass - [x] README updated (Features list + comparison-table footnote ³⁴) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019xKUm9vH4ifb5LHR5szy1v --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d462592 commit 33c281c

38 files changed

Lines changed: 1668 additions & 68 deletions

Cargo.lock

Lines changed: 15 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Notes:
9696
- ³¹ rclone and restic are command-line tools; their GUIs are separate third-party projects (for example RcloneView, Backrest).
9797
- ³² Duplicati runs as a background service with a local web UI plus a tray helper, not a native desktop app.
9898
- ³³ restic search and selective restore are driven from the CLI (or a mounted snapshot), not an in-app browser.
99-
- ³⁴ Driven writes daily rolling logs (pruned at 14 days / 25 MB) that interleave backend tracing with the webview's own console output, and the in-app diagnostics export bundles them.
99+
- ³⁴ Driven writes daily rolling logs (pruned at 14 days / 25 MB, widened to 250 MB while the opt-in Debug logging toggle is on) that interleave backend tracing with the webview's own console output, and the in-app diagnostics export bundles them.
100100
- ³⁵ rclone logs to a file only when you pass `--log-file` (rotation via `--log-file-max-size` and friends), and has no bundle export; its bug template asks you to attach a log you produced by hand.
101101
- ³⁶ Duplicati's "Create bug report" export is a genuine one-click bundle (system info plus an obfuscated copy of the local database), and its web UI has a live log view; file logging is opt-in via `--log-file`, defaults to warnings only, and does not rotate.
102102
- ³⁷ restic has no log-file option at all - output goes to stdout, and the only file logging is an unrotated `DEBUG_LOG` env var that its contributing guide asks you to redact yourself.
@@ -189,7 +189,12 @@ These move: check each project's current docs before relying on a cell.
189189
- Rolling local log files covering both the backend and the webview console,
190190
collected into a one-click diagnostics bundle alongside a redacted summary of
191191
in-flight upload recovery state and a trailing window of process-memory
192-
samples.
192+
samples. An opt-in Debug logging toggle (Settings > Privacy & Data) raises
193+
logs to per-file / IPC-trace / engine-state detail and widens the bundle
194+
with an unredacted engine-state snapshot for the hardest bugs - it warns you
195+
up front that this logs file names, full paths, and timing data, can slow
196+
backups down, and turns itself off automatically after 24 hours; every other
197+
part of the bundle stays redacted regardless.
193198
- In-app auto-update with signed update manifests and a stable / dev channel
194199
selector.
195200
- Anonymous, opt-out telemetry (coarse counts only; never file names, paths, or

src-tauri/src/app_state.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,12 @@ pub struct AppState {
308308
/// issue #308 (2026-08-17 follow-up): live bottleneck-classification
309309
/// sampling runtime (the Activity dashboard's Bottleneck stat tile).
310310
bottleneck: BottleneckRuntime,
311+
/// issue #309: the debug-logging-mode 24h auto-off watchdog's task handle
312+
/// and shutdown signal, so the app-quit drain joins it with no orphan
313+
/// (mirrors [`UpdaterRuntime`]/[`IostatRuntime`]). No shared "hub" field
314+
/// like those two - the watchdog only reads/writes the persisted settings
315+
/// KV directly, nothing else on `AppState` needs to observe it.
316+
debug_mode: DebugModeRuntime,
311317
/// The ONE in-flight streaming exclusion preview
312318
/// ([`crate::commands::exclusion_stream`]). The exclusion editor re-previews
313319
/// on every rule edit, so without a single-slot registry a user tweaking
@@ -440,6 +446,22 @@ pub struct BottleneckRuntime {
440446
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
441447
}
442448

449+
/// issue #309: the debug-logging-mode 24h auto-off watchdog's runtime state
450+
/// held on [`AppState`] - just the task's lifecycle slots (mirrors
451+
/// [`UpdaterRuntime`]/[`TelemetryRuntime`]'s task+shutdown pair). No "hub"
452+
/// field like [`BottleneckRuntime`]/[`IostatRuntime`]: the watchdog reads and
453+
/// writes the persisted `global.debug_logging_*` settings directly via its
454+
/// `StateRepo` handle, so there is nothing else on `AppState` for another
455+
/// caller to read.
456+
#[derive(Default)]
457+
pub struct DebugModeRuntime {
458+
/// The spawned watchdog task, behind `Option` so the shutdown drain can
459+
/// TAKE + await it by value; `None` once drained / never spawned.
460+
task: std::sync::Mutex<Option<JoinHandle<()>>>,
461+
/// The shutdown signal the watchdog `select!`s on.
462+
shutdown: std::sync::Mutex<Option<watch::Sender<bool>>>,
463+
}
464+
443465
/// M9b (SPEC s16): the anonymous-telemetry runtime state held on [`AppState`].
444466
///
445467
/// `task` + `shutdown` track the single app-wide periodic-ping task so the quit
@@ -702,6 +724,7 @@ impl AppState {
702724
updater: UpdaterRuntime::default(),
703725
iostat: IostatRuntime::default(),
704726
bottleneck: BottleneckRuntime::default(),
727+
debug_mode: DebugModeRuntime::default(),
705728
exclusion_previews: Arc::default(),
706729
preview_tree_cache: Arc::default(),
707730
telemetry: TelemetryRuntime::default(),
@@ -1104,6 +1127,44 @@ impl AppState {
11041127
.take()
11051128
}
11061129

1130+
// --- issue #309: debug-logging-mode watchdog runtime ----------------------
1131+
1132+
/// Register the spawned watchdog task + its shutdown sender so the
1133+
/// app-quit drain can stop + join it with no orphan (mirrors
1134+
/// [`Self::set_bottleneck_task`]).
1135+
pub fn set_debug_mode_task(&self, task: JoinHandle<()>, shutdown: watch::Sender<bool>) {
1136+
*self
1137+
.debug_mode
1138+
.task
1139+
.lock()
1140+
.unwrap_or_else(|e| e.into_inner()) = Some(task);
1141+
*self
1142+
.debug_mode
1143+
.shutdown
1144+
.lock()
1145+
.unwrap_or_else(|e| e.into_inner()) = Some(shutdown);
1146+
}
1147+
1148+
/// Signal the watchdog to stop and TAKE its handle so the quit drain can
1149+
/// await it. Mirrors [`Self::shutdown_bottleneck_task`].
1150+
#[must_use]
1151+
pub fn shutdown_debug_mode_task(&self) -> Option<JoinHandle<()>> {
1152+
if let Some(tx) = self
1153+
.debug_mode
1154+
.shutdown
1155+
.lock()
1156+
.unwrap_or_else(|e| e.into_inner())
1157+
.take()
1158+
{
1159+
let _ = tx.send(true);
1160+
}
1161+
self.debug_mode
1162+
.task
1163+
.lock()
1164+
.unwrap_or_else(|e| e.into_inner())
1165+
.take()
1166+
}
1167+
11071168
// --- M9b telemetry runtime (SPEC s16) ----------------------------------
11081169

11091170
/// M9b: register the spawned periodic-ping task + its shutdown sender so the
@@ -2119,6 +2180,43 @@ pub(crate) mod tests {
21192180
let _ = std::fs::remove_dir_all(dir);
21202181
}
21212182

2183+
#[tokio::test]
2184+
async fn debug_mode_runtime_task_and_shutdown_round_trip() {
2185+
// Issue #309: the debug-logging-mode watchdog's runtime bookkeeping.
2186+
// No hub getter to cover (unlike bottleneck/iostat) - just the
2187+
// set/shutdown task pair, mirrors
2188+
// `bottleneck_runtime_hub_task_and_shutdown_round_trip`.
2189+
let (state, dir) = temp_state().await;
2190+
let app_state = AppState::new(
2191+
state,
2192+
HashMap::new(),
2193+
RemoteMode::Fake,
2194+
default_fake_registry(),
2195+
);
2196+
2197+
// No task registered yet: shutdown is a safe no-op.
2198+
assert!(app_state.shutdown_debug_mode_task().is_none());
2199+
2200+
// Register a task that exits promptly on the shutdown signal (the
2201+
// real watchdog's own shape), then confirm shutdown signals + hands
2202+
// back the handle so the quit drain can join it.
2203+
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
2204+
let task = tokio::spawn(async move {
2205+
let _ = shutdown_rx.changed().await;
2206+
});
2207+
app_state.set_debug_mode_task(task, shutdown_tx);
2208+
2209+
let handle = app_state
2210+
.shutdown_debug_mode_task()
2211+
.expect("the just-registered task round-trips");
2212+
handle.await.unwrap();
2213+
2214+
// Taken: a second shutdown call is again a safe no-op.
2215+
assert!(app_state.shutdown_debug_mode_task().is_none());
2216+
2217+
let _ = std::fs::remove_dir_all(dir);
2218+
}
2219+
21222220
#[tokio::test]
21232221
async fn vss_helper_manager_installs_and_shutdown_is_noop() {
21242222
// Issue #25: AppState owns the least-privilege VSS helper broker manager -

src-tauri/src/commands/dtos.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,23 @@ pub struct GlobalSettings {
968968
/// the unchanged V1 behaviour; `false` exempts EXACTLY the reachability
969969
/// pause-reason family from the gate (a captive portal still pauses).
970970
pub pause_when_offline: bool,
971+
/// Issue #309: debug logging mode. When `true` the live `tracing` filter
972+
/// is raised to a verbose directive (per-file activity, IPC traces, state
973+
/// transitions, reconcile/queue decisions) and the diagnostic bundle
974+
/// gains a debug section; both cost noticeable performance and log
975+
/// verbose paths/timings, so this defaults `false` and auto-clears itself
976+
/// (see [`Self::debug_logging_expires_at_ms`]). `serde(default)` so a
977+
/// `global` blob persisted before this field still deserialises as off.
978+
#[serde(default)]
979+
pub debug_logging_enabled: bool,
980+
/// Issue #309: the epoch-ms deadline [`Self::debug_logging_enabled`]
981+
/// auto-turns-off at (set to `now + 24h` whenever the toggle is switched
982+
/// on). Persisted (not just an in-memory timer) so a restart mid-window
983+
/// still honours the original deadline rather than granting a fresh 24h.
984+
/// `None` when debug logging is off. `serde(default)` for the same
985+
/// pre-#309 backward-compat reason as the toggle itself.
986+
#[serde(default)]
987+
pub debug_logging_expires_at_ms: Option<i64>,
971988
}
972989

973990
/// V2 schedule-window settings (DESIGN s17). Mirrors
@@ -1288,6 +1305,11 @@ pub struct GlobalSettingsPatch {
12881305
pub proxy_url: Option<Option<String>>,
12891306
/// See [`GlobalSettings::pause_when_offline`]. Absent = unchanged.
12901307
pub pause_when_offline: Option<bool>,
1308+
/// See [`GlobalSettings::debug_logging_enabled`]. Absent = unchanged;
1309+
/// present = set it (the backend computes/clears
1310+
/// `debug_logging_expires_at_ms` itself - that field is not directly
1311+
/// patchable from the webview).
1312+
pub debug_logging_enabled: Option<bool>,
12911313
}
12921314

12931315
/// Partial SPEC s22 `telemetry` settings.

0 commit comments

Comments
 (0)