Skip to content
Open
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
67 changes: 54 additions & 13 deletions aw-datastore/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(()),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -386,29 +427,29 @@ 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);
Comment thread
TimeToBuildBob marked this conversation as resolved.
}
Ok(Response::Empty())
}
Err(e) => Err(e),
},
Command::GetKeyValue(key) => match ds.get_key_value(tx, &key) {
Ok(result) => Ok(Response::KeyValue(result)),
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) {
Expand Down
87 changes: 87 additions & 0 deletions aw-datastore/tests/datastore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
17 changes: 15 additions & 2 deletions aw-server/src/endpoints/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}
Expand All @@ -137,7 +145,12 @@ pub fn setting_delete(state: &State<ServerState>, 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()),
}
}
Loading