Add Phase 5 enhancements: security, i18n, analysis, backup, notifications
This commit is contained in:
479
src/core/footprint.rs
Normal file
479
src/core/footprint.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::database::Database;
|
||||
|
||||
/// A discovered data/config/cache path for an AppImage.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredPath {
|
||||
pub path: PathBuf,
|
||||
pub path_type: PathType,
|
||||
pub discovery_method: DiscoveryMethod,
|
||||
pub confidence: Confidence,
|
||||
pub size_bytes: u64,
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PathType {
|
||||
Config,
|
||||
Data,
|
||||
Cache,
|
||||
State,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl PathType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PathType::Config => "config",
|
||||
PathType::Data => "data",
|
||||
PathType::Cache => "cache",
|
||||
PathType::State => "state",
|
||||
PathType::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
PathType::Config => "Configuration",
|
||||
PathType::Data => "Data",
|
||||
PathType::Cache => "Cache",
|
||||
PathType::State => "State",
|
||||
PathType::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon_name(&self) -> &'static str {
|
||||
match self {
|
||||
PathType::Config => "preferences-system-symbolic",
|
||||
PathType::Data => "folder-documents-symbolic",
|
||||
PathType::Cache => "user-trash-symbolic",
|
||||
PathType::State => "document-properties-symbolic",
|
||||
PathType::Other => "folder-symbolic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DiscoveryMethod {
|
||||
/// Matched by desktop entry ID or WM class
|
||||
DesktopId,
|
||||
/// Matched by app name in XDG directory
|
||||
NameMatch,
|
||||
/// Matched by executable name
|
||||
ExecMatch,
|
||||
/// Matched by binary name extracted from AppImage
|
||||
BinaryMatch,
|
||||
}
|
||||
|
||||
impl DiscoveryMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DiscoveryMethod::DesktopId => "desktop_id",
|
||||
DiscoveryMethod::NameMatch => "name_match",
|
||||
DiscoveryMethod::ExecMatch => "exec_match",
|
||||
DiscoveryMethod::BinaryMatch => "binary_match",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Confidence {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl Confidence {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Confidence::High => "high",
|
||||
Confidence::Medium => "medium",
|
||||
Confidence::Low => "low",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Confidence::High => "success",
|
||||
Confidence::Medium => "warning",
|
||||
Confidence::Low => "neutral",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of an AppImage's disk footprint.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FootprintSummary {
|
||||
pub appimage_size: u64,
|
||||
pub config_size: u64,
|
||||
pub data_size: u64,
|
||||
pub cache_size: u64,
|
||||
pub state_size: u64,
|
||||
pub other_size: u64,
|
||||
pub paths: Vec<DiscoveredPath>,
|
||||
}
|
||||
|
||||
impl FootprintSummary {
|
||||
pub fn total_size(&self) -> u64 {
|
||||
self.appimage_size + self.config_size + self.data_size
|
||||
+ self.cache_size + self.state_size + self.other_size
|
||||
}
|
||||
|
||||
pub fn data_total(&self) -> u64 {
|
||||
self.config_size + self.data_size + self.cache_size
|
||||
+ self.state_size + self.other_size
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover config/data/cache paths for an AppImage by searching XDG directories
|
||||
/// for name variations.
|
||||
pub fn discover_app_paths(
|
||||
app_name: Option<&str>,
|
||||
filename: &str,
|
||||
desktop_entry_content: Option<&str>,
|
||||
) -> Vec<DiscoveredPath> {
|
||||
let mut results = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// Build search terms from available identity information
|
||||
let mut search_terms: Vec<(String, DiscoveryMethod, Confidence)> = Vec::new();
|
||||
|
||||
// From desktop entry: extract desktop file ID and WM class
|
||||
if let Some(content) = desktop_entry_content {
|
||||
if let Some(wm_class) = extract_desktop_key(content, "StartupWMClass") {
|
||||
let lower = wm_class.to_lowercase();
|
||||
search_terms.push((lower.clone(), DiscoveryMethod::DesktopId, Confidence::High));
|
||||
search_terms.push((wm_class.clone(), DiscoveryMethod::DesktopId, Confidence::High));
|
||||
}
|
||||
if let Some(exec) = extract_desktop_key(content, "Exec") {
|
||||
// Extract just the binary name from the Exec line
|
||||
let binary = exec.split_whitespace().next().unwrap_or(&exec);
|
||||
let binary_name = Path::new(binary)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(binary);
|
||||
if !binary_name.is_empty() && binary_name != "AppRun" {
|
||||
let lower = binary_name.to_lowercase();
|
||||
search_terms.push((lower, DiscoveryMethod::ExecMatch, Confidence::Medium));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From app name
|
||||
if let Some(name) = app_name {
|
||||
let lower = name.to_lowercase();
|
||||
// Remove spaces and special chars for directory matching
|
||||
let sanitized = lower.replace(' ', "").replace('-', "");
|
||||
search_terms.push((lower.clone(), DiscoveryMethod::NameMatch, Confidence::Medium));
|
||||
if sanitized != lower {
|
||||
search_terms.push((sanitized, DiscoveryMethod::NameMatch, Confidence::Low));
|
||||
}
|
||||
// Also try with hyphens
|
||||
let hyphenated = lower.replace(' ', "-");
|
||||
if hyphenated != lower {
|
||||
search_terms.push((hyphenated, DiscoveryMethod::NameMatch, Confidence::Medium));
|
||||
}
|
||||
}
|
||||
|
||||
// From filename (strip .AppImage extension and version suffixes)
|
||||
let stem = filename
|
||||
.strip_suffix(".AppImage")
|
||||
.or_else(|| filename.strip_suffix(".appimage"))
|
||||
.unwrap_or(filename);
|
||||
// Strip version suffix like -1.2.3 or _v1.2
|
||||
let base = strip_version_suffix(stem);
|
||||
let lower = base.to_lowercase();
|
||||
search_terms.push((lower, DiscoveryMethod::BinaryMatch, Confidence::Low));
|
||||
|
||||
// XDG base directories
|
||||
let home = match std::env::var("HOME") {
|
||||
Ok(h) => PathBuf::from(h),
|
||||
Err(_) => return results,
|
||||
};
|
||||
|
||||
let xdg_config = std::env::var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home.join(".config"));
|
||||
let xdg_data = std::env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home.join(".local/share"));
|
||||
let xdg_cache = std::env::var("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home.join(".cache"));
|
||||
let xdg_state = std::env::var("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home.join(".local/state"));
|
||||
|
||||
let search_dirs = [
|
||||
(&xdg_config, PathType::Config),
|
||||
(&xdg_data, PathType::Data),
|
||||
(&xdg_cache, PathType::Cache),
|
||||
(&xdg_state, PathType::State),
|
||||
];
|
||||
|
||||
// Also search legacy dotfiles in $HOME
|
||||
for (term, method, confidence) in &search_terms {
|
||||
// Search XDG directories
|
||||
for (base_dir, path_type) in &search_dirs {
|
||||
if !base_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try exact match and case-insensitive match
|
||||
let entries = match std::fs::read_dir(base_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let entry_name = entry.file_name();
|
||||
let entry_str = entry_name.to_string_lossy();
|
||||
let entry_lower = entry_str.to_lowercase();
|
||||
|
||||
if entry_lower == *term || entry_lower.starts_with(&format!("{}.", term))
|
||||
|| entry_lower.starts_with(&format!("{}-", term))
|
||||
{
|
||||
let full_path = entry.path();
|
||||
if seen.contains(&full_path) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(full_path.clone());
|
||||
|
||||
let size = dir_size(&full_path);
|
||||
results.push(DiscoveredPath {
|
||||
path: full_path,
|
||||
path_type: *path_type,
|
||||
discovery_method: *method,
|
||||
confidence: *confidence,
|
||||
size_bytes: size,
|
||||
exists: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search for legacy dotfiles/dotdirs in $HOME (e.g., ~/.appname)
|
||||
let dotdir = home.join(format!(".{}", term));
|
||||
if dotdir.exists() && !seen.contains(&dotdir) {
|
||||
seen.insert(dotdir.clone());
|
||||
let size = dir_size(&dotdir);
|
||||
results.push(DiscoveredPath {
|
||||
path: dotdir,
|
||||
path_type: PathType::Config,
|
||||
discovery_method: *method,
|
||||
confidence: *confidence,
|
||||
size_bytes: size,
|
||||
exists: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: high confidence first, then by path type
|
||||
results.sort_by(|a, b| {
|
||||
let conf_ord = confidence_rank(&a.confidence).cmp(&confidence_rank(&b.confidence));
|
||||
if conf_ord != std::cmp::Ordering::Equal {
|
||||
return conf_ord;
|
||||
}
|
||||
a.path_type.as_str().cmp(b.path_type.as_str())
|
||||
});
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Discover paths and store them in the database.
|
||||
pub fn discover_and_store(db: &Database, appimage_id: i64, record: &crate::core::database::AppImageRecord) {
|
||||
let paths = discover_app_paths(
|
||||
record.app_name.as_deref(),
|
||||
&record.filename,
|
||||
record.desktop_entry_content.as_deref(),
|
||||
);
|
||||
|
||||
if let Err(e) = db.clear_app_data_paths(appimage_id) {
|
||||
log::warn!("Failed to clear app data paths for id {}: {}", appimage_id, e);
|
||||
}
|
||||
|
||||
for dp in &paths {
|
||||
if let Err(e) = db.insert_app_data_path(
|
||||
appimage_id,
|
||||
&dp.path.to_string_lossy(),
|
||||
dp.path_type.as_str(),
|
||||
dp.discovery_method.as_str(),
|
||||
dp.confidence.as_str(),
|
||||
dp.size_bytes as i64,
|
||||
) {
|
||||
log::warn!("Failed to insert app data path '{}' for id {}: {}", dp.path.display(), appimage_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a complete footprint summary for an AppImage.
|
||||
pub fn get_footprint(db: &Database, appimage_id: i64, appimage_size: u64) -> FootprintSummary {
|
||||
let stored = db.get_app_data_paths(appimage_id).unwrap_or_default();
|
||||
|
||||
let mut summary = FootprintSummary {
|
||||
appimage_size,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for record in &stored {
|
||||
let dp = DiscoveredPath {
|
||||
path: PathBuf::from(&record.path),
|
||||
path_type: match record.path_type.as_str() {
|
||||
"config" => PathType::Config,
|
||||
"data" => PathType::Data,
|
||||
"cache" => PathType::Cache,
|
||||
"state" => PathType::State,
|
||||
_ => PathType::Other,
|
||||
},
|
||||
discovery_method: match record.discovery_method.as_str() {
|
||||
"desktop_id" => DiscoveryMethod::DesktopId,
|
||||
"name_match" => DiscoveryMethod::NameMatch,
|
||||
"exec_match" => DiscoveryMethod::ExecMatch,
|
||||
_ => DiscoveryMethod::BinaryMatch,
|
||||
},
|
||||
confidence: match record.confidence.as_str() {
|
||||
"high" => Confidence::High,
|
||||
"medium" => Confidence::Medium,
|
||||
_ => Confidence::Low,
|
||||
},
|
||||
size_bytes: record.size_bytes as u64,
|
||||
exists: Path::new(&record.path).exists(),
|
||||
};
|
||||
|
||||
match dp.path_type {
|
||||
PathType::Config => summary.config_size += dp.size_bytes,
|
||||
PathType::Data => summary.data_size += dp.size_bytes,
|
||||
PathType::Cache => summary.cache_size += dp.size_bytes,
|
||||
PathType::State => summary.state_size += dp.size_bytes,
|
||||
PathType::Other => summary.other_size += dp.size_bytes,
|
||||
}
|
||||
summary.paths.push(dp);
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn extract_desktop_key<'a>(content: &'a str, key: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') && trimmed != "[Desktop Entry]" {
|
||||
break; // Only look in [Desktop Entry] section
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix(key) {
|
||||
let rest = rest.trim_start();
|
||||
if let Some(value) = rest.strip_prefix('=') {
|
||||
return Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn strip_version_suffix(name: &str) -> &str {
|
||||
// Strip trailing version patterns like -1.2.3, _v2.0, -x86_64
|
||||
// Check for known arch suffixes first (may contain underscores)
|
||||
for suffix in &["-x86_64", "-aarch64", "-arm64", "-x86", "_x86_64", "_aarch64"] {
|
||||
if let Some(stripped) = name.strip_suffix(suffix) {
|
||||
return strip_version_suffix(stripped);
|
||||
}
|
||||
}
|
||||
// Find last hyphen or underscore followed by a digit or 'v'
|
||||
if let Some(pos) = name.rfind(|c: char| c == '-' || c == '_') {
|
||||
let after = &name[pos + 1..];
|
||||
if after.starts_with(|c: char| c.is_ascii_digit() || c == 'v') {
|
||||
return &name[..pos];
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Calculate the total size of a file or directory recursively.
|
||||
pub fn dir_size_pub(path: &Path) -> u64 {
|
||||
dir_size(path)
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
if path.is_file() {
|
||||
return path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
}
|
||||
let mut total = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_file() {
|
||||
total += entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
} else if ft.is_dir() {
|
||||
total += dir_size(&entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn confidence_rank(c: &Confidence) -> u8 {
|
||||
match c {
|
||||
Confidence::High => 0,
|
||||
Confidence::Medium => 1,
|
||||
Confidence::Low => 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_strip_version_suffix() {
|
||||
assert_eq!(strip_version_suffix("MyApp-1.2.3"), "MyApp");
|
||||
assert_eq!(strip_version_suffix("MyApp_v2.0"), "MyApp");
|
||||
assert_eq!(strip_version_suffix("MyApp-x86_64"), "MyApp");
|
||||
assert_eq!(strip_version_suffix("MyApp"), "MyApp");
|
||||
assert_eq!(strip_version_suffix("My-App"), "My-App");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_desktop_key() {
|
||||
let content = "[Desktop Entry]\nName=Test App\nExec=/usr/bin/test --flag\nStartupWMClass=testapp\n\n[Actions]\nNew=new";
|
||||
assert_eq!(extract_desktop_key(content, "Name"), Some("Test App".into()));
|
||||
assert_eq!(extract_desktop_key(content, "Exec"), Some("/usr/bin/test --flag".into()));
|
||||
assert_eq!(extract_desktop_key(content, "StartupWMClass"), Some("testapp".into()));
|
||||
// Should not find keys in other sections
|
||||
assert_eq!(extract_desktop_key(content, "New"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_type_labels() {
|
||||
assert_eq!(PathType::Config.as_str(), "config");
|
||||
assert_eq!(PathType::Data.as_str(), "data");
|
||||
assert_eq!(PathType::Cache.as_str(), "cache");
|
||||
assert_eq!(PathType::Cache.label(), "Cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confidence_badge() {
|
||||
assert_eq!(Confidence::High.badge_class(), "success");
|
||||
assert_eq!(Confidence::Medium.badge_class(), "warning");
|
||||
assert_eq!(Confidence::Low.badge_class(), "neutral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_footprint_summary_totals() {
|
||||
let summary = FootprintSummary {
|
||||
appimage_size: 100,
|
||||
config_size: 10,
|
||||
data_size: 20,
|
||||
cache_size: 30,
|
||||
state_size: 5,
|
||||
other_size: 0,
|
||||
paths: Vec::new(),
|
||||
};
|
||||
assert_eq!(summary.total_size(), 165);
|
||||
assert_eq!(summary.data_total(), 65);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user