Files
upstream-mirror c5c5af8cde
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 22:29:06 +05:00

376 lines
12 KiB
Rust

//! Research Edition `/api/0/export` sanitizer.
//!
//! Copied into `aw-server` only by `scripts/patch_research_edition_export.py`
//! when `AW_RESEARCH_EDITION=true`. Standard builds never see this module.
//!
//! Two jobs:
//! 1. Fail closed if currentwindow events still carry raw titles or any event
//! carries a URL. Application names are approved study variables.
//! 2. Rewrite each exported bucket's map key, embedded `id`, and `hostname`
//! so the real machine name never leaves the device. Colliding sanitized
//! IDs fail the export rather than silently merging two machines.
use std::collections::HashMap;
use aw_models::{Bucket, BucketsExport, TryVec};
use serde_json::Value;
pub const SANITIZED_HOSTNAME: &str = "research-participant";
/// Study categories plus the two "Excluded" spellings the watcher writes.
/// Keep in sync with `scripts/patch_research_edition_config.py`.
const STUDY_CATEGORIES: &[&str] = &[
"Sensitive / Excluded",
"Music & Audio",
"Video Streaming",
"Games",
"Travel & Mobility",
"Search & Navigation",
"News & Current Affairs",
"Social Networking",
"Messaging",
"Email",
"AI Chatbots & Assistants",
"Work & Productivity",
"Education & Learning",
"Shopping - Goods",
"Shopping - Groceries & Food",
"Banking & Finance",
"Public Services",
"Excluded",
"excluded",
];
pub fn sanitize_buckets_export(export: BucketsExport) -> Result<BucketsExport, String> {
let mut checked = HashMap::new();
for (key, mut bucket) in export.buckets {
reject_unfiltered(&mut bucket)?;
checked.insert(key, bucket);
}
rewrite_identities(checked)
}
fn reject_unfiltered(bucket: &mut Bucket) -> Result<(), String> {
let Some(events) = bucket.events.take() else {
return Ok(());
};
let inner = events.take_inner();
for event in &inner {
if let Some(reason) = unfiltered_reason(&bucket._type, &event.data) {
bucket.events = Some(TryVec::new(inner));
return Err(format!(
"Research Edition export refused: bucket type '{}' client '{}' contains unfiltered event data ({reason}). \
Research filtering only applies to newly captured events. Uninstall ActivityWatch, \
delete the existing database, and install the Research Edition on a clean profile.",
bucket._type, bucket.client
));
}
}
bucket.events = Some(TryVec::new(inner));
Ok(())
}
fn unfiltered_reason(
bucket_type: &str,
data: &serde_json::Map<String, Value>,
) -> Option<&'static str> {
if data.contains_key("url") {
return Some("url field");
}
if bucket_type != "currentwindow" {
return None;
}
if let Some(title) = data.get("title").and_then(Value::as_str) {
if !is_study_category(title) {
return Some("non-category window title");
}
}
None
}
fn is_study_category(value: &str) -> bool {
let trimmed = value.trim();
STUDY_CATEGORIES
.iter()
.any(|category| category.eq_ignore_ascii_case(trimmed))
}
fn rewrite_identities(buckets: HashMap<String, Bucket>) -> Result<BucketsExport, String> {
let mut out: HashMap<String, Bucket> = HashMap::new();
for (key, mut bucket) in buckets {
let original_id = if bucket.id.is_empty() {
key.clone()
} else {
bucket.id.clone()
};
let sanitized_id = sanitize_id(&original_id, &bucket.hostname);
let sanitized_key = sanitize_id(&key, &bucket.hostname);
if sanitized_id != sanitized_key {
return Err(
"Research Edition export refused: bucket key and embedded id sanitize to different identities. \
Start from a clean ActivityWatch profile."
.to_string(),
);
}
if out.contains_key(&sanitized_key) {
return Err(format!(
"Research Edition export refused: two buckets map to the same sanitized identity '{sanitized_key}'. \
This usually means data from more than one machine is in the same database. Start from a clean ActivityWatch profile."
));
}
bucket.id = sanitized_key.clone();
bucket.hostname = SANITIZED_HOSTNAME.to_string();
out.insert(sanitized_key, bucket);
}
Ok(BucketsExport { buckets: out })
}
pub(crate) fn sanitize_id(id: &str, hostname: &str) -> String {
if hostname.is_empty() {
return id.to_string();
}
if id == hostname {
return SANITIZED_HOSTNAME.to_string();
}
let suffix = format!("_{hostname}");
if let Some(prefix) = id.strip_suffix(suffix.as_str()) {
return format!("{prefix}_{SANITIZED_HOSTNAME}");
}
if id.contains(hostname) {
return id.replace(hostname, SANITIZED_HOSTNAME);
}
id.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use aw_models::{BucketMetadata, Event, TryVec};
use chrono::{Duration, TimeZone, Utc};
use serde_json::{json, Map};
fn ts() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 24, 12, 0, 0).unwrap()
}
fn event(data: serde_json::Value) -> Event {
Event {
id: None,
timestamp: ts(),
duration: Duration::seconds(1),
data: data.as_object().cloned().unwrap_or_else(Map::new),
}
}
fn bucket(
id: &str,
hostname: &str,
bucket_type: &str,
client: &str,
events: Vec<Event>,
) -> Bucket {
Bucket {
bid: None,
id: id.to_string(),
_type: bucket_type.to_string(),
client: client.to_string(),
hostname: hostname.to_string(),
created: None,
data: Map::new(),
metadata: BucketMetadata::default(),
events: Some(TryVec::new(events)),
last_updated: None,
}
}
fn export_of(buckets: Vec<Bucket>) -> BucketsExport {
BucketsExport {
buckets: buckets.into_iter().map(|b| (b.id.clone(), b)).collect(),
}
}
#[test]
fn rewrites_key_id_and_hostname_together() {
let host = "Participant-Alice-MacBook";
let original = export_of(vec![
bucket(
&format!("aw-watcher-window_{host}"),
host,
"currentwindow",
"aw-watcher-window",
vec![event(json!({"app": "Excluded"}))],
),
bucket(
&format!("aw-watcher-afk_{host}"),
host,
"afkstatus",
"aw-watcher-afk",
vec![event(json!({"status": "afk"}))],
),
]);
let sanitized = sanitize_buckets_export(original).unwrap();
let window_id = format!("aw-watcher-window_{SANITIZED_HOSTNAME}");
let afk_id = format!("aw-watcher-afk_{SANITIZED_HOSTNAME}");
assert_eq!(sanitized.buckets.len(), 2);
let window = sanitized.buckets.get(&window_id).unwrap();
assert_eq!(window.id, window_id);
assert_eq!(window.hostname, SANITIZED_HOSTNAME);
assert_eq!(
window.events.as_ref().unwrap().clone().take_inner()[0].data,
json!({"app": "Excluded"}).as_object().unwrap().clone()
);
let afk = sanitized.buckets.get(&afk_id).unwrap();
assert_eq!(afk.id, afk_id);
assert_eq!(afk.hostname, SANITIZED_HOSTNAME);
assert_eq!(
afk.events.as_ref().unwrap().clone().take_inner()[0].data,
json!({"status": "afk"}).as_object().unwrap().clone()
);
let dump = serde_json::to_string(&sanitized).unwrap();
assert!(
!dump.contains(host),
"real hostname must not appear in export JSON"
);
assert_eq!(dump.matches(SANITIZED_HOSTNAME).count(), 6); // key + id + hostname, twice
}
#[test]
fn collision_of_two_hostnames_fails_closed() {
let original = export_of(vec![
bucket(
"aw-watcher-window_host-a",
"host-a",
"currentwindow",
"aw-watcher-window",
vec![event(json!({"app": "Excluded"}))],
),
bucket(
"aw-watcher-window_host-b",
"host-b",
"currentwindow",
"aw-watcher-window",
vec![event(json!({"app": "Excluded"}))],
),
]);
let err = match sanitize_buckets_export(original) {
Err(err) => err,
Ok(_) => panic!("expected hostname collision to fail closed"),
};
assert!(err.contains("same sanitized identity"));
assert!(!err.contains("host-a"));
assert!(!err.contains("host-b"));
}
#[test]
fn unfiltered_title_fails_closed() {
let original = export_of(vec![bucket(
"aw-watcher-window_host",
"host",
"currentwindow",
"aw-watcher-window",
vec![event(json!({"app": "Code", "title": "secret.rs"}))],
)]);
let err = match sanitize_buckets_export(original) {
Err(err) => err,
Ok(_) => panic!("expected unfiltered title to fail closed"),
};
assert!(err.contains("unfiltered"));
assert!(err.contains("clean profile"));
assert!(!err.contains("secret.rs"));
assert!(!err.contains("Code"));
}
#[test]
fn unfiltered_url_fails_closed_even_outside_window_buckets() {
let original = export_of(vec![bucket(
"aw-watcher-web_host",
"host",
"web.tab.current",
"aw-watcher-web",
vec![event(
json!({"url": "https://mail.example/inbox", "title": "Inbox"}),
)],
)]);
let err = match sanitize_buckets_export(original) {
Err(err) => err,
Ok(_) => panic!("expected url field to fail closed"),
};
assert!(err.contains("url field"));
assert!(!err.contains("mail.example"));
}
#[test]
fn retained_non_browser_app_name_is_allowed() {
let original = export_of(vec![bucket(
"aw-watcher-window_host",
"host",
"currentwindow",
"aw-watcher-window",
vec![event(json!({"app": "Microsoft Word"}))],
)]);
let sanitized = sanitize_buckets_export(original).unwrap();
let bucket = sanitized
.buckets
.get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}"))
.unwrap();
assert_eq!(
bucket.events.as_ref().unwrap().clone().take_inner()[0].data,
json!({"app": "Microsoft Word"})
.as_object()
.unwrap()
.clone()
);
}
#[test]
fn browser_event_with_classified_title_is_allowed() {
let original = export_of(vec![bucket(
"aw-watcher-window_host",
"host",
"currentwindow",
"aw-watcher-window",
vec![event(
json!({"app": "Firefox", "title": "Work & Productivity"}),
)],
)]);
let sanitized = sanitize_buckets_export(original).unwrap();
let bucket = sanitized
.buckets
.get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}"))
.unwrap();
assert_eq!(
bucket.events.as_ref().unwrap().clone().take_inner()[0].data,
json!({"app": "Firefox", "title": "Work & Productivity"})
.as_object()
.unwrap()
.clone()
);
}
#[test]
fn sanitize_id_replaces_suffix_and_embedded_hostname() {
assert_eq!(
sanitize_id(
"aw-watcher-window_Participant-Alice-MacBook",
"Participant-Alice-MacBook"
),
format!("aw-watcher-window_{SANITIZED_HOSTNAME}")
);
assert_eq!(
sanitize_id("Participant-Alice-MacBook", "Participant-Alice-MacBook"),
SANITIZED_HOSTNAME
);
assert_eq!(sanitize_id("id1", "hostname"), "id1");
assert_eq!(sanitize_id("keep_me", ""), "keep_me");
}
}