622 lines
18 KiB
Rust
622 lines
18 KiB
Rust
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::Parser;
|
|
use serde::Serialize;
|
|
|
|
const DEFAULT_DATA_DIR: &str = "/var/lib/activitywatch";
|
|
const TMP_ARCHIVE_PATTERNS: &[NamePattern] = &[
|
|
NamePattern::PrefixSuffix("activitywatch-", ".zip"),
|
|
NamePattern::PrefixSuffix("hayabusa-", ".zip"),
|
|
NamePattern::Exact("aw-hayabusa-profiles.txt"),
|
|
];
|
|
const TMP_WEBUI_PATTERNS: &[NamePattern] = &[
|
|
NamePattern::Exact("aw-worktime-ui-bridge.py"),
|
|
NamePattern::Exact("views-default.json"),
|
|
NamePattern::Exact("apply_webui_ru_patch.out"),
|
|
];
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(about = "Safely prune ActivityWatch local app state and temporary artifacts")]
|
|
struct Cli {
|
|
#[arg(long)]
|
|
data_dir: Option<PathBuf>,
|
|
|
|
#[arg(long)]
|
|
backup_dir: Option<PathBuf>,
|
|
|
|
#[arg(long)]
|
|
browser_smoke_dir: Option<PathBuf>,
|
|
|
|
#[arg(long)]
|
|
tmp_dir: Option<PathBuf>,
|
|
|
|
#[arg(long, default_value_t = 7)]
|
|
backup_retention_days: u64,
|
|
|
|
#[arg(long, default_value_t = 2)]
|
|
backup_keep_last_db: usize,
|
|
|
|
#[arg(long, default_value_t = 2)]
|
|
backup_keep_last_json: usize,
|
|
|
|
#[arg(long, default_value_t = 24)]
|
|
browser_smoke_keep_runs: usize,
|
|
|
|
#[arg(long, default_value_t = 1)]
|
|
browser_smoke_retention_days: u64,
|
|
|
|
#[arg(long, default_value_t = 1)]
|
|
tmp_archive_retention_days: u64,
|
|
|
|
#[arg(long, default_value_t = 2)]
|
|
tmp_webui_retention_days: u64,
|
|
|
|
#[arg(long, default_value_t = false)]
|
|
apply: bool,
|
|
|
|
#[arg(long, default_value_t = false)]
|
|
json: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Config {
|
|
data_dir: PathBuf,
|
|
backup_dir: PathBuf,
|
|
browser_smoke_dir: PathBuf,
|
|
tmp_dir: PathBuf,
|
|
backup_retention_days: u64,
|
|
backup_keep_last_db: usize,
|
|
backup_keep_last_json: usize,
|
|
browser_smoke_keep_runs: usize,
|
|
browser_smoke_retention_days: u64,
|
|
tmp_archive_retention_days: u64,
|
|
tmp_webui_retention_days: u64,
|
|
apply: bool,
|
|
json: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
enum ItemKind {
|
|
File,
|
|
Directory,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct PruneItem {
|
|
path: PathBuf,
|
|
kind: ItemKind,
|
|
reason: String,
|
|
age_days: Option<u64>,
|
|
size_bytes: u64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct Summary {
|
|
apply: bool,
|
|
planned: usize,
|
|
deleted: usize,
|
|
failed: usize,
|
|
bytes: u64,
|
|
items: Vec<PruneItem>,
|
|
errors: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum NamePattern {
|
|
Exact(&'static str),
|
|
PrefixSuffix(&'static str, &'static str),
|
|
}
|
|
|
|
impl NamePattern {
|
|
fn matches(self, name: &str) -> bool {
|
|
match self {
|
|
Self::Exact(expected) => name == expected,
|
|
Self::PrefixSuffix(prefix, suffix) => {
|
|
name.starts_with(prefix) && name.ends_with(suffix)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let code = match run() {
|
|
Ok(code) => code,
|
|
Err(err) => {
|
|
eprintln!("{err:#}");
|
|
1
|
|
}
|
|
};
|
|
std::process::exit(code);
|
|
}
|
|
|
|
fn run() -> Result<i32> {
|
|
let cfg = Config::from_cli(Cli::parse());
|
|
let mut items = Vec::new();
|
|
fs::create_dir_all(&cfg.backup_dir)
|
|
.with_context(|| format!("create backup dir {}", cfg.backup_dir.display()))?;
|
|
|
|
plan_backup_group(
|
|
&cfg.backup_dir.join("db"),
|
|
cfg.backup_keep_last_db,
|
|
cfg.backup_retention_days,
|
|
"backup_db",
|
|
&mut items,
|
|
)?;
|
|
plan_backup_group(
|
|
&cfg.backup_dir,
|
|
cfg.backup_keep_last_json,
|
|
cfg.backup_retention_days,
|
|
"backup_root",
|
|
&mut items,
|
|
)?;
|
|
plan_browser_smoke(&cfg, &mut items)?;
|
|
plan_tmp(
|
|
&cfg.tmp_dir,
|
|
TMP_ARCHIVE_PATTERNS,
|
|
cfg.tmp_archive_retention_days,
|
|
"tmp_archive",
|
|
&mut items,
|
|
)?;
|
|
plan_tmp(
|
|
&cfg.tmp_dir,
|
|
TMP_WEBUI_PATTERNS,
|
|
cfg.tmp_webui_retention_days,
|
|
"tmp_webui",
|
|
&mut items,
|
|
)?;
|
|
|
|
validate_plan(&cfg, &items)?;
|
|
let mut summary = Summary {
|
|
apply: cfg.apply,
|
|
planned: items.len(),
|
|
deleted: 0,
|
|
failed: 0,
|
|
bytes: items.iter().map(|item| item.size_bytes).sum(),
|
|
items,
|
|
errors: Vec::new(),
|
|
};
|
|
if cfg.apply {
|
|
apply_plan(&mut summary);
|
|
}
|
|
print_summary(&summary, cfg.json)?;
|
|
if summary.failed == 0 { Ok(0) } else { Ok(1) }
|
|
}
|
|
|
|
impl Config {
|
|
fn from_cli(cli: Cli) -> Self {
|
|
let data_dir = cli
|
|
.data_dir
|
|
.or_else(|| env_path("AW_DATA_DIR"))
|
|
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_DIR));
|
|
let backup_dir = cli
|
|
.backup_dir
|
|
.or_else(|| env_path("AW_BACKUP_DIR"))
|
|
.unwrap_or_else(|| data_dir.join("backups"));
|
|
let browser_smoke_dir = cli
|
|
.browser_smoke_dir
|
|
.or_else(|| env_path("AW_BROWSER_SMOKE_OUTPUT_DIR"))
|
|
.unwrap_or_else(|| data_dir.join("browser-smoke"));
|
|
let tmp_dir = cli
|
|
.tmp_dir
|
|
.or_else(|| env_path("AW_TMP_DIR"))
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
|
Self {
|
|
data_dir,
|
|
backup_dir,
|
|
browser_smoke_dir,
|
|
tmp_dir,
|
|
backup_retention_days: env_u64("AW_BACKUP_RETENTION_DAYS", cli.backup_retention_days),
|
|
backup_keep_last_db: env_usize("AW_BACKUP_KEEP_LAST_DB", cli.backup_keep_last_db),
|
|
backup_keep_last_json: env_usize("AW_BACKUP_KEEP_LAST_JSON", cli.backup_keep_last_json),
|
|
browser_smoke_keep_runs: env_usize(
|
|
"AW_BROWSER_SMOKE_KEEP_RUNS",
|
|
cli.browser_smoke_keep_runs,
|
|
),
|
|
browser_smoke_retention_days: env_u64(
|
|
"AW_BROWSER_SMOKE_RETENTION_DAYS",
|
|
cli.browser_smoke_retention_days,
|
|
),
|
|
tmp_archive_retention_days: env_u64(
|
|
"AW_TMP_ARCHIVE_RETENTION_DAYS",
|
|
cli.tmp_archive_retention_days,
|
|
),
|
|
tmp_webui_retention_days: env_u64(
|
|
"AW_TMP_WEBUI_RETENTION_DAYS",
|
|
cli.tmp_webui_retention_days,
|
|
),
|
|
apply: cli.apply,
|
|
json: cli.json,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn plan_backup_group(
|
|
dir: &Path,
|
|
keep_last: usize,
|
|
keep_days: u64,
|
|
reason: &str,
|
|
items: &mut Vec<PruneItem>,
|
|
) -> Result<()> {
|
|
let mut files = list_files(dir)?;
|
|
files.sort_by(|a, b| b.modified.cmp(&a.modified));
|
|
let cutoff = cutoff(keep_days);
|
|
for (idx, candidate) in files.into_iter().enumerate() {
|
|
if idx < keep_last || candidate.modified >= cutoff || is_rollback_critical(&candidate.path)
|
|
{
|
|
continue;
|
|
}
|
|
items.push(candidate.into_item(reason));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn plan_browser_smoke(cfg: &Config, items: &mut Vec<PruneItem>) -> Result<()> {
|
|
let mut dirs = list_run_dirs(&cfg.browser_smoke_dir)?;
|
|
dirs.sort_by(|a, b| b.modified.cmp(&a.modified));
|
|
let cutoff = cutoff(cfg.browser_smoke_retention_days);
|
|
for (idx, candidate) in dirs.into_iter().enumerate() {
|
|
if idx < cfg.browser_smoke_keep_runs || candidate.modified >= cutoff {
|
|
continue;
|
|
}
|
|
items.push(candidate.into_item("browser_smoke_run"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn plan_tmp(
|
|
dir: &Path,
|
|
patterns: &[NamePattern],
|
|
keep_days: u64,
|
|
reason: &str,
|
|
items: &mut Vec<PruneItem>,
|
|
) -> Result<()> {
|
|
if !dir.exists() {
|
|
return Ok(());
|
|
}
|
|
let cutoff = cutoff(keep_days);
|
|
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
|
let entry = entry?;
|
|
let file_type = entry.file_type()?;
|
|
if !file_type.is_file() && !file_type.is_symlink() {
|
|
continue;
|
|
}
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
if !patterns.iter().any(|pattern| pattern.matches(&name)) {
|
|
continue;
|
|
}
|
|
let meta = entry.metadata()?;
|
|
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
|
if modified >= cutoff {
|
|
continue;
|
|
}
|
|
items.push(
|
|
Candidate {
|
|
path: entry.path(),
|
|
kind: ItemKind::File,
|
|
modified,
|
|
size_bytes: meta.len(),
|
|
}
|
|
.into_item(reason),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Candidate {
|
|
path: PathBuf,
|
|
kind: ItemKind,
|
|
modified: SystemTime,
|
|
size_bytes: u64,
|
|
}
|
|
|
|
impl Candidate {
|
|
fn into_item(self, reason: &str) -> PruneItem {
|
|
PruneItem {
|
|
path: self.path,
|
|
kind: self.kind,
|
|
reason: reason.to_string(),
|
|
age_days: age_days(self.modified),
|
|
size_bytes: self.size_bytes,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn list_files(dir: &Path) -> Result<Vec<Candidate>> {
|
|
if !dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut files = Vec::new();
|
|
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
|
let entry = entry?;
|
|
if !entry.file_type()?.is_file() {
|
|
continue;
|
|
}
|
|
let meta = entry.metadata()?;
|
|
files.push(Candidate {
|
|
path: entry.path(),
|
|
kind: ItemKind::File,
|
|
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
|
size_bytes: meta.len(),
|
|
});
|
|
}
|
|
Ok(files)
|
|
}
|
|
|
|
fn list_run_dirs(dir: &Path) -> Result<Vec<Candidate>> {
|
|
if !dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut dirs = Vec::new();
|
|
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
|
let entry = entry?;
|
|
if !entry.file_type()?.is_dir() {
|
|
continue;
|
|
}
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
if !looks_like_browser_smoke_run(&name) {
|
|
continue;
|
|
}
|
|
let meta = entry.metadata()?;
|
|
dirs.push(Candidate {
|
|
path: entry.path(),
|
|
kind: ItemKind::Directory,
|
|
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
|
size_bytes: dir_size(&entry.path()).unwrap_or(0),
|
|
});
|
|
}
|
|
Ok(dirs)
|
|
}
|
|
|
|
fn looks_like_browser_smoke_run(name: &str) -> bool {
|
|
name.len() >= 20
|
|
&& name.starts_with("20")
|
|
&& name.contains('T')
|
|
&& name
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
|
}
|
|
|
|
fn validate_plan(cfg: &Config, items: &[PruneItem]) -> Result<()> {
|
|
for item in items {
|
|
if is_rollback_critical(&item.path) {
|
|
bail!(
|
|
"refusing to delete rollback-critical path {}",
|
|
item.path.display()
|
|
);
|
|
}
|
|
let allowed = match item.reason.as_str() {
|
|
"backup_db" => is_under_or_equal(&item.path, &cfg.backup_dir.join("db")),
|
|
"backup_root" => is_under_or_equal(&item.path, &cfg.backup_dir),
|
|
"browser_smoke_run" => is_under_or_equal(&item.path, &cfg.browser_smoke_dir),
|
|
"tmp_archive" | "tmp_webui" => is_under_or_equal(&item.path, &cfg.tmp_dir),
|
|
_ => false,
|
|
};
|
|
if !allowed {
|
|
bail!(
|
|
"refusing to delete path outside allowlist {}",
|
|
item.path.display()
|
|
);
|
|
}
|
|
if item.reason == "backup_root" && item.path.parent() != Some(cfg.backup_dir.as_path()) {
|
|
bail!("refusing nested backup_root delete {}", item.path.display());
|
|
}
|
|
if item.path == cfg.data_dir
|
|
|| item.path == cfg.backup_dir
|
|
|| item.path == cfg.browser_smoke_dir
|
|
{
|
|
bail!("refusing to delete root directory {}", item.path.display());
|
|
}
|
|
let name = item
|
|
.path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("");
|
|
if is_sqlite_db_name(name) && item.reason != "backup_db" {
|
|
bail!(
|
|
"refusing to delete SQLite DB outside backup_db {}",
|
|
item.path.display()
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_plan(summary: &mut Summary) {
|
|
for item in summary.items.clone() {
|
|
let result = match item.kind {
|
|
ItemKind::File => fs::remove_file(&item.path),
|
|
ItemKind::Directory => fs::remove_dir_all(&item.path),
|
|
};
|
|
match result {
|
|
Ok(()) => summary.deleted += 1,
|
|
Err(err) if !item.path.exists() => {
|
|
summary.deleted += 1;
|
|
summary
|
|
.errors
|
|
.push(format!("already gone: {} ({err})", item.path.display()));
|
|
}
|
|
Err(err) => {
|
|
summary.failed += 1;
|
|
summary
|
|
.errors
|
|
.push(format!("{}: {err}", item.path.display()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_summary(summary: &Summary, json: bool) -> Result<()> {
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(summary)?);
|
|
return Ok(());
|
|
}
|
|
let mode = if summary.apply { "apply" } else { "dry-run" };
|
|
println!(
|
|
"aw-prune-local-state: mode={mode} planned={} deleted={} failed={} bytes={}",
|
|
summary.planned, summary.deleted, summary.failed, summary.bytes
|
|
);
|
|
for item in &summary.items {
|
|
let verb = if summary.apply {
|
|
"DELETE"
|
|
} else {
|
|
"WOULD_DELETE"
|
|
};
|
|
println!(
|
|
"{verb} {:?} {} reason={} age_days={} bytes={}",
|
|
item.kind,
|
|
item.path.display(),
|
|
item.reason,
|
|
item.age_days
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string()),
|
|
item.size_bytes
|
|
);
|
|
}
|
|
for err in &summary.errors {
|
|
eprintln!("WARN {err}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cutoff(days: u64) -> SystemTime {
|
|
SystemTime::now()
|
|
.checked_sub(Duration::from_secs(days.saturating_mul(86_400)))
|
|
.unwrap_or(SystemTime::UNIX_EPOCH)
|
|
}
|
|
|
|
fn age_days(modified: SystemTime) -> Option<u64> {
|
|
SystemTime::now()
|
|
.duration_since(modified)
|
|
.ok()
|
|
.map(|age| age.as_secs() / 86_400)
|
|
}
|
|
|
|
fn dir_size(path: &Path) -> Result<u64> {
|
|
let mut total = 0;
|
|
for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? {
|
|
let entry = entry?;
|
|
let file_type = entry.file_type()?;
|
|
let child = entry.path();
|
|
if file_type.is_dir() {
|
|
total += dir_size(&child).unwrap_or(0);
|
|
} else if file_type.is_file() || file_type.is_symlink() {
|
|
total += entry.metadata().map(|meta| meta.len()).unwrap_or(0);
|
|
}
|
|
}
|
|
Ok(total)
|
|
}
|
|
|
|
fn is_under_or_equal(path: &Path, root: &Path) -> bool {
|
|
path == root || path.starts_with(root)
|
|
}
|
|
|
|
fn is_rollback_critical(path: &Path) -> bool {
|
|
let text = path.to_string_lossy().to_ascii_lowercase();
|
|
text.contains("switch-backups")
|
|
|| text.contains("before-rust")
|
|
|| text.contains("rollback")
|
|
|| text.contains("pre-switch")
|
|
}
|
|
|
|
fn is_sqlite_db_name(name: &str) -> bool {
|
|
name.ends_with(".sqlite")
|
|
|| name.ends_with(".sqlite3")
|
|
|| name.ends_with(".db")
|
|
|| name.ends_with(".db-shm")
|
|
|| name.ends_with(".db-wal")
|
|
}
|
|
|
|
fn env_path(name: &str) -> Option<PathBuf> {
|
|
std::env::var(name)
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
fn env_u64(name: &str, default: u64) -> u64 {
|
|
std::env::var(name)
|
|
.ok()
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
fn env_usize(name: &str, default: usize) -> usize {
|
|
std::env::var(name)
|
|
.ok()
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs::File;
|
|
|
|
#[test]
|
|
fn browser_smoke_run_name_is_narrow() {
|
|
assert!(looks_like_browser_smoke_run("2026-06-02T03-02-19-530Z"));
|
|
assert!(!looks_like_browser_smoke_run(".cache"));
|
|
assert!(!looks_like_browser_smoke_run("latest-result.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn rollback_critical_paths_are_protected() {
|
|
assert!(is_rollback_critical(Path::new(
|
|
"/var/lib/activitywatch/health/switch-backups/file"
|
|
)));
|
|
assert!(is_rollback_critical(Path::new(
|
|
"/var/lib/activitywatch/backups/db/before-rust.sqlite"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn backup_group_keeps_newest_even_with_zero_retention() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let old = tmp.path().join("old.json");
|
|
let new = tmp.path().join("new.json");
|
|
File::create(&old).expect("old");
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
File::create(&new).expect("new");
|
|
let mut items = Vec::new();
|
|
plan_backup_group(tmp.path(), 1, 0, "backup_root", &mut items).expect("plan");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].path, old);
|
|
}
|
|
|
|
#[test]
|
|
fn validation_rejects_sqlite_outside_backup_db() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let cfg = Config {
|
|
data_dir: tmp.path().to_path_buf(),
|
|
backup_dir: tmp.path().join("backups"),
|
|
browser_smoke_dir: tmp.path().join("browser-smoke"),
|
|
tmp_dir: tmp.path().join("tmp"),
|
|
backup_retention_days: 1,
|
|
backup_keep_last_db: 1,
|
|
backup_keep_last_json: 1,
|
|
browser_smoke_keep_runs: 1,
|
|
browser_smoke_retention_days: 1,
|
|
tmp_archive_retention_days: 1,
|
|
tmp_webui_retention_days: 1,
|
|
apply: false,
|
|
json: false,
|
|
};
|
|
let item = PruneItem {
|
|
path: cfg.browser_smoke_dir.join("state.db"),
|
|
kind: ItemKind::File,
|
|
reason: "browser_smoke_run".to_string(),
|
|
age_days: Some(2),
|
|
size_bytes: 1,
|
|
};
|
|
assert!(validate_plan(&cfg, &[item]).is_err());
|
|
}
|
|
}
|