Skip to content

Commit c93bbe0

Browse files
pmaxhoganclaude
andcommitted
fix(app): M6 recheck-1 backend - atomic add_source rollback, preview token, fake picker, fatal creds, boot config, overlap
R1-P1-1: add_source now splits master-key prep (generate keychain key + encode phrase, no account stamp) from the atomic DB write; on a DB failure when a key was just generated it deletes the keychain master key so the account stays unprovisioned and a retry re-reveals the phrase. R1-P1-2: preview_exclusions no longer walks a raw webview path - the DTO takes a dialog token (resolved via a new non-consuming AppState::peek_dialog_token so add_source keeps its single-use TAKE) XOR a source_id (path resolved from SQLite); neither/bad-token is rejected. R1-P1-3: pick_drive_folder honours remote_mode via select_picker_store - Fake builds an InMemoryRemoteStore (no real store, no keychain), Real builds the live GoogleDriveStore. R1-P1-4: store_client_creds is FATAL (returns CommandResult); fresh-add rolls back the stored refresh token on creds failure, reauth persists creds before flipping the account to ok - no account that cannot refresh. R1-P2-1: build_account loads the persisted OrchestratorConfig at cold start instead of ::default(), so saved settings apply without a live edit. R1-P2-2: add_source rejects a root overlapping (ancestor/descendant/ identical to) any existing source root per DESIGN s5.2.2; siblings allowed. Tests: peek non-consuming + TTL, overlap nested/ancestor/identical rejected + sibling allowed, fake picker lists without creds, cold-start config reflects persisted non-default settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CyiRqk2DVwmJjEu5gcD1m
1 parent ea9e36f commit c93bbe0

5 files changed

Lines changed: 485 additions & 78 deletions

File tree

src-tauri/src/app_state.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,22 @@ impl AppState {
316316
Some(binding.path)
317317
}
318318

319+
/// C1 / R1-P1-2: PEEK (non-consuming) the path bound to `token`, if it
320+
/// exists and has not expired. Unlike [`Self::take_dialog_token`] this does
321+
/// NOT consume the token, so a read-only, idempotent, repeatable command
322+
/// (`preview_exclusions`, which the user re-runs as they tweak globs) can
323+
/// resolve the dialog-derived path without spending the single use the
324+
/// subsequent `add_source` write needs. The TTL still bounds replay; only a
325+
/// path-bearing WRITE consumes the token.
326+
pub fn peek_dialog_token(&self, token: &str) -> Option<std::path::PathBuf> {
327+
let map = self.lock_dialog_tokens();
328+
let binding = map.get(token)?;
329+
if std::time::Instant::now().duration_since(binding.minted_at) >= DIALOG_TOKEN_TTL {
330+
return None;
331+
}
332+
Some(binding.path.clone())
333+
}
334+
319335
/// Lock the dialog-token map, recovering a poisoned lock.
320336
fn lock_dialog_tokens(&self) -> std::sync::MutexGuard<'_, HashMap<String, DialogTokenBinding>> {
321337
self.dialog_tokens.lock().unwrap_or_else(|e| e.into_inner())
@@ -862,6 +878,32 @@ mod tests {
862878
let _ = std::fs::remove_dir_all(dir);
863879
}
864880

881+
#[tokio::test]
882+
async fn peek_dialog_token_is_non_consuming() {
883+
// R1-P1-2: `preview_exclusions` PEEKS the dialog token (non-consuming) so
884+
// the user can re-run the preview as they tweak globs AND the subsequent
885+
// `add_source` still has the single TAKE it needs. Peeking N times then
886+
// taking once must all resolve the same path; a take after that is
887+
// rejected.
888+
let (state, dir) = temp_state().await;
889+
let app_state = AppState::new(state, HashMap::new(), RemoteMode::Fake);
890+
let path = std::path::PathBuf::from("/home/u/preview-root");
891+
let token = app_state.mint_dialog_token(path.clone());
892+
893+
// Multiple peeks all resolve the path WITHOUT consuming the token.
894+
assert_eq!(app_state.peek_dialog_token(&token), Some(path.clone()));
895+
assert_eq!(app_state.peek_dialog_token(&token), Some(path.clone()));
896+
// The single TAKE (what add_source uses) still works after the peeks.
897+
assert_eq!(app_state.take_dialog_token(&token), Some(path));
898+
// Now consumed: a further peek AND take both return None.
899+
assert_eq!(app_state.peek_dialog_token(&token), None);
900+
assert_eq!(app_state.take_dialog_token(&token), None);
901+
// An unknown token never resolves.
902+
assert_eq!(app_state.peek_dialog_token("nope"), None);
903+
904+
let _ = std::fs::remove_dir_all(dir);
905+
}
906+
865907
#[tokio::test]
866908
async fn dialog_tokens_are_distinct_per_mint() {
867909
// Two mints yield distinct tokens each bound to its own path.

src-tauri/src/assembly.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,15 @@ async fn build_account(
316316
};
317317

318318
// --- pacer: real AIMD pacer seeded from the account's config -------------
319-
// M5 uses the default orchestrator config (the persisted per-account
320-
// settings UI is M6); the bandwidth cap is therefore unset here.
321-
let config = OrchestratorConfig::default();
319+
// R1-P2-1: load the PERSISTED SPEC s22 settings (scan cadence, bandwidth
320+
// cap, metered/battery gates, VSS mode) so a cold start honours the user's
321+
// saved settings, not the hard defaults. Before this fix the orchestrator
322+
// always booted with `OrchestratorConfig::default()` and only picked up the
323+
// persisted values after a live settings edit. A read/parse failure falls
324+
// back to the conservative default (reconfigure-style best-effort).
325+
let config = crate::commands::settings::load_orchestrator_config(state.as_ref())
326+
.await
327+
.unwrap_or_default();
322328
let pacer: Arc<dyn Pacer> = Arc::new(AimdPacer::with_ceilings(
323329
clock.clone(),
324330
config.bandwidth_cap_mbps.map(f64::from),
@@ -775,3 +781,66 @@ struct AccountSyncStatusEvent {
775781
account_id: String,
776782
state: driven_core::types::OrchestratorState,
777783
}
784+
785+
#[cfg(test)]
786+
mod tests {
787+
use driven_core::orchestrator::OrchestratorConfig;
788+
use driven_core::state::sqlite::SqliteStateRepo;
789+
use driven_core::state::StateRepo;
790+
791+
/// R1-P2-1: cold-start orchestrators must build their [`OrchestratorConfig`]
792+
/// from the PERSISTED SPEC s22 settings, not the hard defaults. `build_account`
793+
/// now reads `commands::settings::load_orchestrator_config` at assembly time
794+
/// (replacing the old `OrchestratorConfig::default()`); this asserts that a
795+
/// persisted NON-DEFAULT setting is reflected in the config that path yields,
796+
/// so a fresh boot honours the user's saved settings without a live edit.
797+
#[tokio::test]
798+
async fn cold_start_config_reflects_persisted_non_default_setting() {
799+
let nonce = std::time::SystemTime::now()
800+
.duration_since(std::time::UNIX_EPOCH)
801+
.map(|d| d.as_nanos())
802+
.unwrap_or(0);
803+
let dir = std::env::temp_dir().join(format!("driven-assembly-cfg-{nonce}"));
804+
std::fs::create_dir_all(&dir).unwrap();
805+
let repo = SqliteStateRepo::open(&dir.join("state.db"))
806+
.await
807+
.expect("open repo");
808+
809+
// The hard default scan cadence is 600s; persist a DISTINCT non-default
810+
// value so a cold start that ignored persisted settings would fail this.
811+
let default_cfg = OrchestratorConfig::default();
812+
let persisted_scan_secs: u64 = 123;
813+
assert_ne!(
814+
default_cfg.scan_interval_secs, persisted_scan_secs,
815+
"fixture must differ from the default to prove the persisted value wins"
816+
);
817+
let global = serde_json::json!({
818+
"auto_start_on_login": false,
819+
"default_concurrent_uploads": serde_json::Value::Null,
820+
"bandwidth_cap_mbps": 7,
821+
"skip_on_battery": false,
822+
"skip_on_metered": false,
823+
"scan_interval_secs": persisted_scan_secs,
824+
"deep_verify_interval_secs": 604_800,
825+
"io_priority": "low",
826+
"log_level": "info",
827+
});
828+
repo.set_setting("global", &global)
829+
.await
830+
.expect("seed global");
831+
832+
// The EXACT function `build_account` reads at cold start.
833+
let cfg = crate::commands::settings::load_orchestrator_config(&repo)
834+
.await
835+
.expect("load config");
836+
assert_eq!(
837+
cfg.scan_interval_secs, persisted_scan_secs,
838+
"cold-start config must reflect the persisted scan cadence (R1-P2-1)"
839+
);
840+
assert_eq!(cfg.bandwidth_cap_mbps, Some(7));
841+
assert!(!cfg.skip_on_battery);
842+
assert!(!cfg.skip_on_metered);
843+
844+
let _ = std::fs::remove_dir_all(dir);
845+
}
846+
}

src-tauri/src/commands/accounts.rs

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,15 @@ pub async fn finish_add_account(
415415
let profile = fetch_google_userinfo(&tokens.access_token).await;
416416

417417
let (account_id, dto) = if let Some(account_id) = reauth_account {
418-
// Reauth path: re-store the refresh token + client creds + flip the
418+
// Reauth path: re-store the refresh token + client creds, THEN flip the
419419
// existing account back to Ok, refreshing the profile if we got one.
420+
// R1-P1-4: persisting the client creds is FATAL and happens BEFORE the
421+
// account is flipped to Ok - if it fails the account stays in its prior
422+
// (needs_reauth) state rather than being marked Ok with un-refreshable
423+
// creds. The refresh token re-store is harmless to leave (the same
424+
// account, same key) and is overwritten on the next successful reauth.
420425
store_refresh_token(account_id, &tokens.refresh_token)?;
421-
store_client_creds(account_id, &creds);
426+
store_client_creds(account_id, &creds)?;
422427

423428
let rows = state
424429
.state()
@@ -452,9 +457,21 @@ pub async fn finish_add_account(
452457
} else {
453458
// Fresh add: allocate the id, store the token + client creds, write the
454459
// row with the real Google email/name (A5).
460+
// R1-P1-4: the token AND client creds are persisted BEFORE the account
461+
// row is written, and persisting the client creds is FATAL: an account
462+
// whose BYO client creds did not persist could never refresh its own
463+
// token (the refresh is bound to the minting client). If the creds store
464+
// fails, roll back the just-stored refresh token so NO half-account
465+
// (token without creds, or a row that cannot refresh) is left behind.
455466
let account_id = AccountId::new_v4();
456467
store_refresh_token(account_id, &tokens.refresh_token)?;
457-
store_client_creds(account_id, &creds);
468+
if let Err(err) = store_client_creds(account_id, &creds) {
469+
if let Err(del) = KeyringTokenStore::new(account_id.to_string()).delete_refresh_token()
470+
{
471+
tracing::error!(target: TARGET, account_id = %account_id, error = %del, "failed to roll back refresh token after client-creds persist failure");
472+
}
473+
return Err(err);
474+
}
458475

459476
// A5: prefer the real Google email; else the user label; else a stable
460477
// fallback. The display name prefers the user-supplied label, else the
@@ -520,19 +537,29 @@ pub async fn finish_add_account(
520537
Ok(dto)
521538
}
522539

523-
/// Persist the per-account BYO OAuth client creds in the keychain (A1).
524-
/// Best-effort: a keychain write failure is logged (NEVER the secret) but does
525-
/// not fail the finish - the account is already saved; a missing client-creds
526-
/// entry falls back to the env/default client on next refresh.
527-
fn store_client_creds(account_id: AccountId, creds: &(String, String)) {
540+
/// Persist the per-account BYO OAuth client creds in the keychain (A1; R1-P1-4).
541+
///
542+
/// FATAL, not best-effort: a refresh token is bound to the client that minted
543+
/// it, so an account whose client creds were NOT persisted will fail EVERY
544+
/// refresh after restart (it falls back to the env/default client, which did not
545+
/// mint the token -> `invalid_client`). `finish_add_account` therefore aborts +
546+
/// rolls the account back when this fails, rather than leaving behind an account
547+
/// that can never refresh its own token. The error maps to `crypto.key_missing`
548+
/// (the keychain-write failure class); the secret is NEVER logged or embedded.
549+
fn store_client_creds(account_id: AccountId, creds: &(String, String)) -> CommandResult<()> {
528550
use driven_drive::google::token_store::{ClientCreds, ClientCredsStore};
529551
let record = ClientCreds {
530552
client_id: creds.0.clone(),
531553
client_secret: creds.1.clone(),
532554
};
533-
if let Err(e) = ClientCredsStore::new(account_id.to_string()).store(&record) {
534-
tracing::warn!(target: TARGET, account_id = %account_id, error = %e, "failed to persist BYO client creds in keychain");
535-
}
555+
ClientCredsStore::new(account_id.to_string())
556+
.store(&record)
557+
.map_err(|e| {
558+
CommandError::with_code(
559+
ErrorCode::CryptoKeyMissing,
560+
format!("failed to persist BYO OAuth client creds in keychain: {e}"),
561+
)
562+
})
536563
}
537564

538565
/// The subset of the Google userinfo response Driven persists (A5).

src-tauri/src/commands/dtos.rs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,14 +246,26 @@ pub struct DriveFolderListing {
246246

247247
/// Request body for `preview_exclusions` (SPEC s11.2 `ExclusionPreviewRequest`).
248248
///
249-
/// `local_path` MUST be a dialog-derived path (SPEC s11.6.1) - the preview
250-
/// walks the local tree, so the same untrusted-path rule applies as
251-
/// `add_source`.
252-
#[derive(Debug, Clone, Serialize, Deserialize)]
249+
/// R1-P1-2 (SPEC s11.6.1): the preview WALKS the local tree, so its root must
250+
/// never be a raw webview-supplied path (a compromised renderer could enumerate
251+
/// arbitrary readable directories). The root is resolved one of two safe ways:
252+
/// - a NEW candidate source: `local_path_token` is the one-shot dialog token
253+
/// `pick_folder_dialog` minted; the backend PEEKS (non-consuming, so the later
254+
/// `add_source` keeps its single use) the path bound to it;
255+
/// - an EXISTING source: `source_id` is the source's id; the backend resolves
256+
/// `backup_sources.local_path` from SQLite.
257+
///
258+
/// Exactly one of the two must be present; a request with neither (or a token
259+
/// that does not map to a backend dialog) is REJECTED.
260+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
253261
#[serde(rename_all = "camelCase")]
254262
pub struct ExclusionPreviewRequest {
255-
/// Dialog-derived absolute local path to preview (SPEC s11.6.1).
256-
pub local_path: PathBuf,
263+
/// One-shot dialog token for a NEW candidate source's folder (from
264+
/// `pick_folder_dialog`); resolved via a non-consuming peek (R1-P1-2).
265+
pub local_path_token: Option<String>,
266+
/// An EXISTING source's id; its `local_path` is resolved from SQLite
267+
/// (R1-P1-2). Mutually exclusive with `local_path_token`.
268+
pub source_id: Option<String>,
257269
/// Whether `.gitignore` rules are honoured.
258270
pub respect_gitignore: bool,
259271
/// Candidate include globs.

0 commit comments

Comments
 (0)