diff --git a/aw-datastore/src/worker.rs b/aw-datastore/src/worker.rs index 8e37bac1..51b1f168 100644 --- a/aw-datastore/src/worker.rs +++ b/aw-datastore/src/worker.rs @@ -84,6 +84,12 @@ pub enum Command { Close(), } +/// Key the webui writes via POST /0/settings/privacy_filters. +/// The worker's in-memory PrivacyFilterEngine is the only thing that actually +/// filters inserts/heartbeats, so every write/delete of this key (and startup) +/// must reload the engine. RefreshPrivacyFilter exists for explicit reloads. +const PRIVACY_FILTERS_KEY: &str = "settings.privacy_filters"; + fn _unwrap_empty_response(response: Response) -> Result<(), DatastoreError> { match response { Response::Empty() => Ok(()), @@ -117,6 +123,24 @@ impl DatastoreWorker { } } + /// Replace the in-memory engine from `settings.privacy_filters`. + /// Only an absent key clears the engine, so deleting the setting actually + /// disables filtering. Parse errors and query errors keep the previous + /// engine: unfiltering on a bad save or a transient database error would + /// silently store the events these rules exist to keep out. + fn reload_privacy_engine(&mut self, ds: &DatastoreInstance, conn: &Connection) { + match ds.get_key_value(conn, PRIVACY_FILTERS_KEY) { + Ok(json_str) => match PrivacyFilterEngine::from_json(&json_str) { + Ok(engine) => self.privacy_engine = engine, + Err(e) => warn!("Failed to parse privacy_filters setting: {e}"), + }, + Err(DatastoreError::NoSuchKey(_)) => { + self.privacy_engine = PrivacyFilterEngine::new(vec![]); + } + Err(e) => warn!("Failed to load privacy_filters setting: {e:?}"), + } + } + fn work_loop(&mut self, method: DatastoreMethod) { // Open SQLite connection let mut conn = match &method { @@ -165,6 +189,11 @@ impl DatastoreWorker { let mut ds = DatastoreInstance::new(&conn, true).unwrap(); + // Load persisted privacy filters before serving inserts. The engine + // starts empty; without this, rules saved in a previous process sit + // unused until something happens to send RefreshPrivacyFilter. + self.reload_privacy_engine(&ds, &conn); + // Ensure legacy import if self.legacy_import { let transaction = match conn.transaction_with_behavior(TransactionBehavior::Immediate) { @@ -200,6 +229,13 @@ impl DatastoreWorker { continue; } }; + // Snapshot BEFORE the request loop. SetKeyValue/DeleteKeyValue + // reload the engine from the still-open transaction so a later + // insert in the same batch is filtered. If commit fails we restore + // this snapshot — not "keep current" (that is the rolled-back + // view) and not a durable re-query (a read error would leave the + // uncommitted engine in place: fail-open after a rolled-back delete). + let privacy_engine_at_tx_start = self.privacy_engine.clone(); tx.set_drop_behavior(DropBehavior::Commit); self.uncommitted_events = 0; @@ -263,6 +299,11 @@ impl DatastoreWorker { // know to retry. Rolled-back events create a gap in the timeline; // watchers will resume sending heartbeats from current state, but the // specific batch of events is permanently lost. + // + // Restore the pre-transaction engine. Reloading from the durable + // connection is not enough: if that read fails, keep-on-error would + // preserve the uncommitted engine (empty after a rolled-back delete). + self.privacy_engine = privacy_engine_at_tx_start; if let Some((sender, _)) = deferred_ack.take() { sender.respond(Err(DatastoreError::InternalError(format!( "Failed to commit datastore transaction: {err}" @@ -386,7 +427,12 @@ impl DatastoreWorker { Err(e) => Err(e), }, Command::SetKeyValue(key, data) => match ds.insert_key_value(tx, &key, &data) { - Ok(()) => Ok(Response::Empty()), + Ok(()) => { + if key == PRIVACY_FILTERS_KEY { + self.reload_privacy_engine(ds, tx); + } + Ok(Response::Empty()) + } Err(e) => Err(e), }, Command::GetKeyValue(key) => match ds.get_key_value(tx, &key) { @@ -394,21 +440,16 @@ impl DatastoreWorker { Err(e) => Err(e), }, Command::DeleteKeyValue(key) => match ds.delete_key_value(tx, &key) { - Ok(()) => Ok(Response::Empty()), + Ok(()) => { + if key == PRIVACY_FILTERS_KEY { + self.reload_privacy_engine(ds, tx); + } + Ok(Response::Empty()) + } Err(e) => Err(e), }, Command::RefreshPrivacyFilter() => { - // Reload privacy filter rules from settings - match ds.get_key_value(tx, "settings.privacy_filters") { - Ok(json_str) => match PrivacyFilterEngine::from_json(&json_str) { - Ok(engine) => self.privacy_engine = engine, - Err(e) => warn!("Failed to parse privacy_filters setting: {e}"), - }, - Err(_) => { - // Settings key absent — clear rules so removing the key disables filtering - self.privacy_engine = PrivacyFilterEngine::new(vec![]); - } - } + self.reload_privacy_engine(ds, tx); Ok(Response::Empty()) } Command::RenameBucket(old_id, new_id) => match ds.rename_bucket(tx, &old_id, &new_id) { diff --git a/aw-datastore/tests/datastore.rs b/aw-datastore/tests/datastore.rs index 9c05740c..1edbb004 100644 --- a/aw-datastore/tests/datastore.rs +++ b/aw-datastore/tests/datastore.rs @@ -832,4 +832,91 @@ mod datastore_tests { let _ = fs::remove_file(&db_path); } + + fn privacy_event(title: &str) -> Event { + Event { + id: None, + timestamp: Utc::now(), + duration: Duration::seconds(1), + data: json_map! {"title": json!(title), "app": json!("Firefox")}, + } + } + + /// Saving privacy_filters used to persist the JSON and never load it into + /// the worker's engine (ActivityWatch/aw-server-rust#659). A drop rule + /// must actually drop a matching insert without an explicit refresh call. + #[test] + fn test_privacy_filter_applies_after_setting_saved() { + let ds = Datastore::new_in_memory(false); + let bucket = create_test_bucket(&ds); + + let drop_secret = + r#"[{"enabled":true,"field":"title","pattern":"(?i)secret","action":"drop"}]"#; + ds.set_key_value("settings.privacy_filters", drop_secret) + .unwrap(); + + ds.insert_events( + &bucket.id, + &[privacy_event("my secret file"), privacy_event("readme.md")], + ) + .unwrap(); + + let events = ds.get_events(&bucket.id, None, None, None).unwrap(); + assert_eq!( + events.len(), + 1, + "drop rule should discard the matching event" + ); + assert_eq!(events[0].data.get("title").unwrap(), "readme.md"); + + ds.delete_key_value("settings.privacy_filters").unwrap(); + ds.insert_events(&bucket.id, &[privacy_event("another secret")]) + .unwrap(); + let events = ds.get_events(&bucket.id, None, None, None).unwrap(); + assert_eq!( + events.len(), + 2, + "deleting the setting should stop filtering" + ); + } + + /// Rules must load at worker startup, not only after a later SetKeyValue. + #[test] + fn test_privacy_filter_survives_datastore_reload() { + let mut db_path = get_cache_dir().unwrap(); + db_path.push(format!( + "datastore-unittest-privacy-filters-{}.db", + std::process::id() + )); + let db_path_str = db_path.to_str().unwrap().to_string(); + let _ = std::fs::remove_file(&db_path); + + let drop_secret = + r#"[{"enabled":true,"field":"title","pattern":"(?i)secret","action":"drop"}]"#; + { + let ds = Datastore::new(db_path_str.clone(), false); + create_test_bucket(&ds); + ds.set_key_value("settings.privacy_filters", drop_secret) + .unwrap(); + ds.force_commit().unwrap(); + ds.close(); + } + { + let ds = Datastore::new(db_path_str, false); + ds.insert_events("testid", &[privacy_event("top secret notes")]) + .unwrap(); + let events = ds.get_events("testid", None, None, None).unwrap(); + assert!( + events.is_empty(), + "persisted drop rule must apply after reopen, got {events:?}" + ); + ds.close(); + } + + // Windows can still hold the SQLite handle after close() while the + // worker thread unwinds (ERROR_SHARING_VIOLATION). Cleanup is best-effort. + let _ = std::fs::remove_file(&db_path); + let _ = std::fs::remove_file(db_path.with_extension("db-wal")); + let _ = std::fs::remove_file(db_path.with_extension("db-shm")); + } } diff --git a/aw-server/src/endpoints/settings.rs b/aw-server/src/endpoints/settings.rs index d678687b..0520911b 100644 --- a/aw-server/src/endpoints/settings.rs +++ b/aw-server/src/endpoints/settings.rs @@ -124,7 +124,15 @@ pub fn setting_set( let result = datastore.set_key_value(&setting_key, &value_str); match result { - Ok(_) => Ok(Status::Created), + Ok(_) => { + // Worker also reloads on SetKeyValue of this key; this second + // RefreshPrivacyFilter is belt-and-suspenders so the HTTP path + // still works if a future writer bypasses that hook. + if setting_key == "settings.privacy_filters" { + let _ = datastore.refresh_privacy_filter(); + } + Ok(Status::Created) + } Err(err) => Err(err.into()), } } @@ -137,7 +145,12 @@ pub fn setting_delete(state: &State, key: String) -> Result<(), Htt let result = datastore.delete_key_value(&setting_key); match result { - Ok(_) => Ok(()), + Ok(_) => { + if setting_key == "settings.privacy_filters" { + let _ = datastore.refresh_privacy_filter(); + } + Ok(()) + } Err(err) => Err(err.into()), } }