Files
AWatch-rus/adk-rust/crates/aw-prune-local-state/src/main.rs
T

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());
}
}