use rusqlite::{params, Connection, Result as SqlResult}; use std::path::PathBuf; pub struct Database { conn: Connection, } #[derive(Debug, Clone)] pub struct AppImageRecord { pub id: i64, pub path: String, pub filename: String, pub app_name: Option, pub app_version: Option, pub appimage_type: Option, pub size_bytes: i64, pub sha256: Option, pub icon_path: Option, pub desktop_file: Option, pub integrated: bool, pub integrated_at: Option, pub is_executable: bool, pub desktop_entry_content: Option, pub categories: Option, pub description: Option, pub developer: Option, pub architecture: Option, pub first_seen: String, pub last_scanned: String, pub file_modified: Option, // Phase 2 fields pub fuse_status: Option, pub wayland_status: Option, pub update_info: Option, pub update_type: Option, pub latest_version: Option, pub update_checked: Option, pub update_url: Option, pub notes: Option, // Phase 3 fields pub sandbox_mode: Option, // Phase 5 fields pub runtime_wayland_status: Option, pub runtime_wayland_checked: Option, // Async analysis pipeline pub analysis_status: Option, // Custom launch arguments pub launch_args: Option, // Phase 6 fields pub tags: Option, pub pinned: bool, pub avg_startup_ms: Option, // Phase 9 fields - comprehensive metadata pub appstream_id: Option, pub appstream_description: Option, pub generic_name: Option, pub license: Option, pub homepage_url: Option, pub bugtracker_url: Option, pub donation_url: Option, pub help_url: Option, pub vcs_url: Option, pub keywords: Option, pub mime_types: Option, pub content_rating: Option, pub project_group: Option, pub release_history: Option, pub desktop_actions: Option, pub has_signature: bool, pub screenshot_urls: Option, // Phase 11 fields - system modification tracking pub previous_version_path: Option, pub source_url: Option, pub autostart: bool, pub startup_wm_class: Option, pub verification_status: Option, pub first_run_prompted: bool, pub system_wide: bool, pub is_portable: bool, pub mount_point: Option, } #[derive(Debug, Clone)] pub struct SystemModification { pub id: i64, pub mod_type: String, pub file_path: String, pub previous_value: Option, } #[derive(Debug, Clone)] pub struct CatalogApp { pub id: i64, pub name: String, pub description: Option, pub categories: Option, pub download_url: String, pub icon_url: Option, pub homepage: Option, pub license: Option, } #[derive(Debug, Clone)] pub struct CatalogSourceRecord { pub id: i64, pub name: String, pub url: String, pub source_type: String, pub enabled: bool, pub last_synced: Option, pub app_count: i32, } #[derive(Debug, Clone)] pub struct CatalogAppRecord { pub name: String, pub description: Option, pub categories: Option, pub latest_version: Option, pub download_url: String, pub icon_url: Option, pub homepage: Option, pub file_size: Option, pub architecture: Option, } #[derive(Debug, Clone)] pub struct OrphanedEntry { pub id: i64, pub desktop_file: String, pub original_path: Option, pub app_name: Option, pub detected_at: String, pub cleaned: bool, } #[derive(Debug, Clone)] pub struct LaunchEvent { pub id: i64, pub appimage_id: i64, pub launched_at: String, pub source: String, } #[derive(Debug, Clone)] pub struct BundledLibraryRecord { pub id: i64, pub appimage_id: i64, pub soname: String, pub detected_name: Option, pub detected_version: Option, pub file_path: Option, pub file_size: i64, } #[derive(Debug, Clone)] pub struct CveMatchRecord { pub id: i64, pub appimage_id: i64, pub library_id: i64, pub cve_id: String, pub severity: Option, pub cvss_score: Option, pub summary: Option, pub affected_versions: Option, pub fixed_version: Option, pub library_soname: String, pub library_name: Option, pub library_version: Option, } #[derive(Debug, Clone, Default)] pub struct CveSummary { pub critical: i64, pub high: i64, pub medium: i64, pub low: i64, } impl CveSummary { pub fn total(&self) -> i64 { self.critical + self.high + self.medium + self.low } pub fn max_severity(&self) -> &'static str { if self.critical > 0 { "CRITICAL" } else if self.high > 0 { "HIGH" } else if self.medium > 0 { "MEDIUM" } else if self.low > 0 { "LOW" } else { "NONE" } } pub fn badge_class(&self) -> &'static str { match self.max_severity() { "CRITICAL" => "error", "HIGH" => "error", "MEDIUM" => "warning", "LOW" => "neutral", _ => "success", } } } #[derive(Debug, Clone)] pub struct AppDataPathRecord { pub id: i64, pub appimage_id: i64, pub path: String, pub path_type: String, pub discovery_method: String, pub confidence: String, pub size_bytes: i64, } #[derive(Debug, Clone)] pub struct UpdateHistoryEntry { pub id: i64, pub appimage_id: i64, pub from_version: Option, pub to_version: Option, pub update_method: Option, pub download_size: Option, pub updated_at: String, pub success: bool, } #[derive(Debug, Clone)] pub struct ConfigBackupRecord { pub id: i64, pub appimage_id: i64, pub app_version: Option, pub archive_path: String, pub archive_size: Option, pub checksum: Option, pub created_at: String, pub path_count: Option, pub restored_count: i32, pub last_restored_at: Option, } #[derive(Debug, Clone)] pub struct SandboxProfileRecord { pub id: i64, pub app_name: String, pub profile_version: Option, pub author: Option, pub description: Option, pub content: String, pub source: String, pub registry_id: Option, pub created_at: Option, } fn db_path() -> PathBuf { let data_dir = crate::config::data_dir_fallback() .join("driftwood"); std::fs::create_dir_all(&data_dir).ok(); data_dir.join("driftwood.db") } impl Database { /// Return the path to the database file, or None if the data dir can't be resolved. pub fn db_path() -> Option { Some(db_path()) } pub fn open() -> SqlResult { let path = db_path(); let conn = Connection::open(&path)?; let db = Self { conn }; db.init_schema()?; Ok(db) } pub fn open_at(path: &std::path::Path) -> SqlResult { std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new("/"))).ok(); let conn = Connection::open(path)?; let db = Self { conn }; db.init_schema()?; Ok(db) } pub fn open_in_memory() -> SqlResult { let conn = Connection::open_in_memory()?; let db = Self { conn }; db.init_schema()?; Ok(db) } fn init_schema(&self) -> SqlResult<()> { // Phase 1 base tables self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS appimages ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, filename TEXT NOT NULL, app_name TEXT, app_version TEXT, appimage_type INTEGER, size_bytes INTEGER NOT NULL DEFAULT 0, sha256 TEXT, icon_path TEXT, desktop_file TEXT, integrated INTEGER NOT NULL DEFAULT 0, integrated_at TEXT, is_executable INTEGER NOT NULL DEFAULT 0, desktop_entry_content TEXT, categories TEXT, description TEXT, developer TEXT, architecture TEXT, first_seen TEXT NOT NULL DEFAULT (datetime('now')), last_scanned TEXT NOT NULL DEFAULT (datetime('now')), file_modified TEXT ); CREATE TABLE IF NOT EXISTS orphaned_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, desktop_file TEXT NOT NULL, original_path TEXT, app_name TEXT, detected_at TEXT NOT NULL DEFAULT (datetime('now')), cleaned INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS scan_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, scan_type TEXT NOT NULL, directories TEXT, found INTEGER NOT NULL DEFAULT 0, new_count INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, scanned_at TEXT NOT NULL DEFAULT (datetime('now')) );" )?; // Check current schema version and migrate let count: i32 = self.conn.query_row( "SELECT COUNT(*) FROM schema_version", [], |row| row.get(0), )?; let current_version = if count == 0 { self.conn.execute( "INSERT INTO schema_version (version) VALUES (?1)", params![1], )?; 1 } else { self.conn.query_row( "SELECT version FROM schema_version LIMIT 1", [], |row| row.get::<_, i32>(0), )? }; if current_version < 2 { self.migrate_to_v2()?; } if current_version < 3 { self.migrate_to_v3()?; } if current_version < 4 { self.migrate_to_v4()?; } if current_version < 5 { self.migrate_to_v5()?; } if current_version < 6 { self.migrate_to_v6()?; } if current_version < 7 { self.migrate_to_v7()?; } if current_version < 8 { self.migrate_to_v8()?; } if current_version < 9 { self.migrate_to_v9()?; } if current_version < 10 { self.migrate_to_v10()?; } if current_version < 11 { self.migrate_to_v11()?; } // Ensure all expected columns exist (repairs DBs where a migration // was updated after it had already run on this database) self.ensure_columns()?; Ok(()) } /// Add any missing columns that may have been missed by earlier migrations. fn ensure_columns(&self) -> SqlResult<()> { let repair_columns = [ "launch_args TEXT", "tags TEXT", "pinned INTEGER NOT NULL DEFAULT 0", "avg_startup_ms INTEGER", ]; for col_def in &repair_columns { self.conn.execute( &format!("ALTER TABLE appimages ADD COLUMN {}", col_def), [], ).ok(); // Silently ignore "duplicate column" errors } Ok(()) } fn migrate_to_v2(&self) -> SqlResult<()> { // Add Phase 2 columns to appimages table let phase2_columns = [ "fuse_status TEXT", "wayland_status TEXT", "update_info TEXT", "update_type TEXT", "latest_version TEXT", "update_checked TEXT", "update_url TEXT", "notes TEXT", ]; for col in &phase2_columns { let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col); self.conn.execute_batch(&sql).ok(); } // Phase 2 tables self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS launch_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, launched_at TEXT NOT NULL DEFAULT (datetime('now')), source TEXT NOT NULL DEFAULT 'desktop_entry' ); CREATE TABLE IF NOT EXISTS update_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, from_version TEXT, to_version TEXT, update_method TEXT, download_size INTEGER, updated_at TEXT NOT NULL DEFAULT (datetime('now')), success INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS duplicate_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, canonical_name TEXT NOT NULL, duplicate_type TEXT, detected_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS duplicate_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER REFERENCES duplicate_groups(id) ON DELETE CASCADE, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, is_recommended INTEGER NOT NULL DEFAULT 0 );" )?; self.conn.execute( "UPDATE schema_version SET version = ?1", params![2], )?; Ok(()) } fn migrate_to_v3(&self) -> SqlResult<()> { // Phase 3 tables: security scanning self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS bundled_libraries ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, soname TEXT NOT NULL, detected_name TEXT, detected_version TEXT, file_path TEXT, file_size INTEGER DEFAULT 0, scanned_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS cve_matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, library_id INTEGER REFERENCES bundled_libraries(id) ON DELETE CASCADE, cve_id TEXT NOT NULL, severity TEXT, cvss_score REAL, summary TEXT, affected_versions TEXT, fixed_version TEXT, matched_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS app_data_paths ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, path TEXT NOT NULL, path_type TEXT NOT NULL DEFAULT 'other', discovery_method TEXT NOT NULL DEFAULT 'heuristic', confidence TEXT NOT NULL DEFAULT 'low', size_bytes INTEGER DEFAULT 0, first_seen TEXT NOT NULL DEFAULT (datetime('now')), last_accessed TEXT ); CREATE INDEX IF NOT EXISTS idx_bundled_libs_appimage ON bundled_libraries(appimage_id); CREATE INDEX IF NOT EXISTS idx_cve_matches_appimage ON cve_matches(appimage_id); CREATE INDEX IF NOT EXISTS idx_cve_matches_severity ON cve_matches(severity); CREATE INDEX IF NOT EXISTS idx_app_data_paths_appimage ON app_data_paths(appimage_id);" )?; self.conn.execute( "UPDATE schema_version SET version = ?1", params![3], )?; Ok(()) } fn migrate_to_v4(&self) -> SqlResult<()> { self.conn.execute( "ALTER TABLE appimages ADD COLUMN sandbox_mode TEXT DEFAULT NULL", [], ).ok(); self.conn.execute( "UPDATE schema_version SET version = ?1", params![4], )?; Ok(()) } fn migrate_to_v5(&self) -> SqlResult<()> { self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS config_backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, app_version TEXT, archive_path TEXT NOT NULL, archive_size INTEGER, checksum TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), path_count INTEGER, restored_count INTEGER DEFAULT 0, last_restored_at TEXT ); CREATE TABLE IF NOT EXISTS backup_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, backup_id INTEGER REFERENCES config_backups(id) ON DELETE CASCADE, original_path TEXT NOT NULL, path_type TEXT NOT NULL, size_bytes INTEGER ); CREATE TABLE IF NOT EXISTS exported_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, format TEXT NOT NULL, file_path TEXT, generated_at TEXT NOT NULL DEFAULT (datetime('now')), app_count INTEGER, cve_count INTEGER ); CREATE TABLE IF NOT EXISTS cve_notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, cve_id TEXT NOT NULL, severity TEXT NOT NULL, notified_at TEXT NOT NULL DEFAULT (datetime('now')), user_action TEXT, acted_at TEXT, UNIQUE(appimage_id, cve_id) ); CREATE INDEX IF NOT EXISTS idx_config_backups_appimage ON config_backups(appimage_id); CREATE INDEX IF NOT EXISTS idx_cve_notifications_appimage ON cve_notifications(appimage_id);" )?; self.conn.execute_batch( "ALTER TABLE appimages ADD COLUMN runtime_wayland_status TEXT; ALTER TABLE appimages ADD COLUMN runtime_wayland_checked TEXT;" ).ok(); self.conn.execute( "UPDATE schema_version SET version = ?1", params![5], )?; Ok(()) } fn migrate_to_v6(&self) -> SqlResult<()> { self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS catalog_sources ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE, source_type TEXT NOT NULL, enabled INTEGER DEFAULT 1, last_synced TEXT, app_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS catalog_apps ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER REFERENCES catalog_sources(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, categories TEXT, latest_version TEXT, download_url TEXT NOT NULL, icon_url TEXT, homepage TEXT, file_size INTEGER, architecture TEXT, cached_at TEXT ); CREATE TABLE IF NOT EXISTS sandbox_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, app_name TEXT NOT NULL, profile_version TEXT, author TEXT, description TEXT, content TEXT NOT NULL, source TEXT NOT NULL, registry_id TEXT, created_at TEXT DEFAULT (datetime('now')), applied_to_appimage_id INTEGER REFERENCES appimages(id) ); CREATE TABLE IF NOT EXISTS sandbox_profile_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER REFERENCES sandbox_profiles(id) ON DELETE CASCADE, action TEXT NOT NULL, timestamp TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS runtime_updates ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, old_runtime TEXT, new_runtime TEXT, backup_path TEXT, updated_at TEXT DEFAULT (datetime('now')), success INTEGER ); CREATE INDEX IF NOT EXISTS idx_catalog_apps_source ON catalog_apps(source_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name ON catalog_apps(source_id, name); CREATE INDEX IF NOT EXISTS idx_sandbox_profiles_app ON sandbox_profiles(app_name); CREATE INDEX IF NOT EXISTS idx_runtime_updates_appimage ON runtime_updates(appimage_id);" )?; self.conn.execute( "UPDATE schema_version SET version = ?1", params![6], )?; Ok(()) } fn migrate_to_v7(&self) -> SqlResult<()> { // Async analysis pipeline and custom launch arguments self.conn.execute( "ALTER TABLE appimages ADD COLUMN analysis_status TEXT DEFAULT 'complete'", [], ).ok(); self.conn.execute( "ALTER TABLE appimages ADD COLUMN launch_args TEXT", [], ).ok(); self.conn.execute( "UPDATE schema_version SET version = ?1", params![7], )?; Ok(()) } fn migrate_to_v8(&self) -> SqlResult<()> { // Ensure launch_args exists (may have been missed if v7 migration // ran before that column was added to the v7 migration code) self.conn.execute( "ALTER TABLE appimages ADD COLUMN launch_args TEXT", [], ).ok(); self.conn.execute( "ALTER TABLE appimages ADD COLUMN tags TEXT", [], ).ok(); self.conn.execute( "ALTER TABLE appimages ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", [], ).ok(); self.conn.execute( "ALTER TABLE appimages ADD COLUMN avg_startup_ms INTEGER", [], ).ok(); self.conn.execute( "UPDATE schema_version SET version = ?1", params![8], )?; Ok(()) } fn migrate_to_v9(&self) -> SqlResult<()> { let new_columns = [ "appstream_id TEXT", "appstream_description TEXT", "generic_name TEXT", "license TEXT", "homepage_url TEXT", "bugtracker_url TEXT", "donation_url TEXT", "help_url TEXT", "vcs_url TEXT", "keywords TEXT", "mime_types TEXT", "content_rating TEXT", "project_group TEXT", "release_history TEXT", "desktop_actions TEXT", "has_signature INTEGER NOT NULL DEFAULT 0", ]; for col in &new_columns { let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col); match self.conn.execute(&sql, []) { Ok(_) => {} Err(e) => { let msg = e.to_string(); if !msg.contains("duplicate column") { return Err(e); } } } } self.conn.execute( "UPDATE schema_version SET version = ?1", params![9], )?; Ok(()) } fn migrate_to_v10(&self) -> SqlResult<()> { let sql = "ALTER TABLE appimages ADD COLUMN screenshot_urls TEXT"; match self.conn.execute(sql, []) { Ok(_) => {} Err(e) => { let msg = e.to_string(); if !msg.contains("duplicate column") { return Err(e); } } } // Force one-time re-analysis so the new AppStream parser (screenshots, // extended metadata) runs on existing apps self.conn.execute( "UPDATE appimages SET analysis_status = NULL WHERE analysis_status = 'complete'", [], )?; self.conn.execute( "UPDATE schema_version SET version = ?1", params![10], )?; Ok(()) } fn migrate_to_v11(&self) -> SqlResult<()> { self.conn.execute_batch( "CREATE TABLE IF NOT EXISTS system_modifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, mod_type TEXT NOT NULL, file_path TEXT NOT NULL, previous_value TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_system_mods_appimage ON system_modifications(appimage_id);" )?; let new_columns = [ "previous_version_path TEXT", "source_url TEXT", "autostart INTEGER NOT NULL DEFAULT 0", "startup_wm_class TEXT", "verification_status TEXT", "first_run_prompted INTEGER NOT NULL DEFAULT 0", "system_wide INTEGER NOT NULL DEFAULT 0", "is_portable INTEGER NOT NULL DEFAULT 0", "mount_point TEXT", ]; for col in &new_columns { let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col); self.conn.execute(&sql, []).ok(); } self.conn.execute( "UPDATE schema_version SET version = ?1", params![11], )?; Ok(()) } pub fn upsert_appimage( &self, path: &str, filename: &str, appimage_type: Option, size_bytes: i64, is_executable: bool, file_modified: Option<&str>, ) -> SqlResult { let id: i64 = self.conn.query_row( "INSERT INTO appimages (path, filename, appimage_type, size_bytes, is_executable, file_modified) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(path) DO UPDATE SET filename = excluded.filename, appimage_type = excluded.appimage_type, size_bytes = excluded.size_bytes, is_executable = excluded.is_executable, file_modified = excluded.file_modified, last_scanned = datetime('now') RETURNING id", params![path, filename, appimage_type, size_bytes, is_executable, file_modified], |row| row.get(0), )?; Ok(id) } pub fn update_metadata( &self, id: i64, app_name: Option<&str>, app_version: Option<&str>, description: Option<&str>, developer: Option<&str>, categories: Option<&str>, architecture: Option<&str>, icon_path: Option<&str>, desktop_entry_content: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET app_name = ?2, app_version = ?3, description = ?4, developer = ?5, categories = ?6, architecture = ?7, icon_path = ?8, desktop_entry_content = ?9 WHERE id = ?1", params![ id, app_name, app_version, description, developer, categories, architecture, icon_path, desktop_entry_content, ], )?; Ok(()) } pub fn update_appstream_metadata( &self, id: i64, appstream_id: Option<&str>, appstream_description: Option<&str>, generic_name: Option<&str>, license: Option<&str>, homepage_url: Option<&str>, bugtracker_url: Option<&str>, donation_url: Option<&str>, help_url: Option<&str>, vcs_url: Option<&str>, keywords: Option<&str>, mime_types: Option<&str>, content_rating: Option<&str>, project_group: Option<&str>, release_history: Option<&str>, desktop_actions: Option<&str>, has_signature: bool, screenshot_urls: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET appstream_id = ?2, appstream_description = ?3, generic_name = ?4, license = ?5, homepage_url = ?6, bugtracker_url = ?7, donation_url = ?8, help_url = ?9, vcs_url = ?10, keywords = ?11, mime_types = ?12, content_rating = ?13, project_group = ?14, release_history = ?15, desktop_actions = ?16, has_signature = ?17, screenshot_urls = ?18 WHERE id = ?1", params![ id, appstream_id, appstream_description, generic_name, license, homepage_url, bugtracker_url, donation_url, help_url, vcs_url, keywords, mime_types, content_rating, project_group, release_history, desktop_actions, has_signature, screenshot_urls, ], )?; Ok(()) } pub fn update_sha256(&self, id: i64, sha256: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET sha256 = ?2 WHERE id = ?1", params![id, sha256], )?; Ok(()) } pub fn set_integrated( &self, id: i64, integrated: bool, desktop_file: Option<&str>, ) -> SqlResult<()> { let integrated_at = if integrated { Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()) } else { None }; self.conn.execute( "UPDATE appimages SET integrated = ?2, desktop_file = ?3, integrated_at = ?4 WHERE id = ?1", params![id, integrated, desktop_file, integrated_at], )?; Ok(()) } const APPIMAGE_COLUMNS: &str = "id, path, filename, app_name, app_version, appimage_type, size_bytes, sha256, icon_path, desktop_file, integrated, integrated_at, is_executable, desktop_entry_content, categories, description, developer, architecture, first_seen, last_scanned, file_modified, fuse_status, wayland_status, update_info, update_type, latest_version, update_checked, update_url, notes, sandbox_mode, runtime_wayland_status, runtime_wayland_checked, analysis_status, launch_args, tags, pinned, avg_startup_ms, appstream_id, appstream_description, generic_name, license, homepage_url, bugtracker_url, donation_url, help_url, vcs_url, keywords, mime_types, content_rating, project_group, release_history, desktop_actions, has_signature, screenshot_urls, previous_version_path, source_url, autostart, startup_wm_class, verification_status, first_run_prompted, system_wide, is_portable, mount_point"; fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result { Ok(AppImageRecord { id: row.get(0)?, path: row.get(1)?, filename: row.get(2)?, app_name: row.get(3)?, app_version: row.get(4)?, appimage_type: row.get(5)?, size_bytes: row.get(6)?, sha256: row.get(7)?, icon_path: row.get(8)?, desktop_file: row.get(9)?, integrated: row.get(10)?, integrated_at: row.get(11)?, is_executable: row.get(12)?, desktop_entry_content: row.get(13)?, categories: row.get(14)?, description: row.get(15)?, developer: row.get(16)?, architecture: row.get(17)?, first_seen: row.get(18)?, last_scanned: row.get(19)?, file_modified: row.get(20)?, fuse_status: row.get(21)?, wayland_status: row.get(22)?, update_info: row.get(23)?, update_type: row.get(24)?, latest_version: row.get(25)?, update_checked: row.get(26)?, update_url: row.get(27)?, notes: row.get(28)?, sandbox_mode: row.get(29)?, runtime_wayland_status: row.get(30).unwrap_or(None), runtime_wayland_checked: row.get(31).unwrap_or(None), analysis_status: row.get(32).unwrap_or(None), launch_args: row.get(33).unwrap_or(None), tags: row.get(34).unwrap_or(None), pinned: row.get::<_, bool>(35).unwrap_or(false), avg_startup_ms: row.get(36).unwrap_or(None), appstream_id: row.get(37).unwrap_or(None), appstream_description: row.get(38).unwrap_or(None), generic_name: row.get(39).unwrap_or(None), license: row.get(40).unwrap_or(None), homepage_url: row.get(41).unwrap_or(None), bugtracker_url: row.get(42).unwrap_or(None), donation_url: row.get(43).unwrap_or(None), help_url: row.get(44).unwrap_or(None), vcs_url: row.get(45).unwrap_or(None), keywords: row.get(46).unwrap_or(None), mime_types: row.get(47).unwrap_or(None), content_rating: row.get(48).unwrap_or(None), project_group: row.get(49).unwrap_or(None), release_history: row.get(50).unwrap_or(None), desktop_actions: row.get(51).unwrap_or(None), has_signature: row.get::<_, bool>(52).unwrap_or(false), screenshot_urls: row.get(53).unwrap_or(None), previous_version_path: row.get(54).unwrap_or(None), source_url: row.get(55).unwrap_or(None), autostart: row.get::<_, bool>(56).unwrap_or(false), startup_wm_class: row.get(57).unwrap_or(None), verification_status: row.get(58).unwrap_or(None), first_run_prompted: row.get::<_, bool>(59).unwrap_or(false), system_wide: row.get::<_, bool>(60).unwrap_or(false), is_portable: row.get::<_, bool>(61).unwrap_or(false), mount_point: row.get(62).unwrap_or(None), }) } pub fn get_all_appimages(&self) -> SqlResult> { let sql = format!( "SELECT {} FROM appimages ORDER BY app_name COLLATE NOCASE, filename", Self::APPIMAGE_COLUMNS ); let mut stmt = self.conn.prepare(&sql)?; let rows = stmt.query_map([], Self::row_to_record)?; rows.collect() } pub fn get_appimage_by_id(&self, id: i64) -> SqlResult> { let sql = format!( "SELECT {} FROM appimages WHERE id = ?1", Self::APPIMAGE_COLUMNS ); let mut stmt = self.conn.prepare(&sql)?; let mut rows = stmt.query_map(params![id], Self::row_to_record)?; Ok(rows.next().transpose()?) } pub fn get_appimage_by_path(&self, path: &str) -> SqlResult> { let sql = format!( "SELECT {} FROM appimages WHERE path = ?1", Self::APPIMAGE_COLUMNS ); let mut stmt = self.conn.prepare(&sql)?; let mut rows = stmt.query_map(params![path], Self::row_to_record)?; Ok(rows.next().transpose()?) } pub fn remove_appimage(&self, id: i64) -> SqlResult<()> { self.conn.execute("DELETE FROM appimages WHERE id = ?1", params![id])?; Ok(()) } pub fn remove_missing_appimages(&self) -> SqlResult> { let all = self.get_all_appimages()?; let mut removed = Vec::new(); for record in all { if !std::path::Path::new(&record.path).exists() { self.remove_appimage(record.id)?; removed.push(record); } } Ok(removed) } pub fn add_orphaned_entry( &self, desktop_file: &str, original_path: Option<&str>, app_name: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "INSERT INTO orphaned_entries (desktop_file, original_path, app_name) VALUES (?1, ?2, ?3)", params![desktop_file, original_path, app_name], )?; Ok(()) } pub fn get_orphaned_entries(&self) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, desktop_file, original_path, app_name, detected_at, cleaned FROM orphaned_entries WHERE cleaned = 0" )?; let rows = stmt.query_map([], |row| { Ok(OrphanedEntry { id: row.get(0)?, desktop_file: row.get(1)?, original_path: row.get(2)?, app_name: row.get(3)?, detected_at: row.get(4)?, cleaned: row.get(5)?, }) })?; rows.collect() } pub fn mark_orphan_cleaned(&self, id: i64) -> SqlResult<()> { self.conn.execute( "UPDATE orphaned_entries SET cleaned = 1 WHERE id = ?1", params![id], )?; Ok(()) } pub fn log_scan( &self, scan_type: &str, directories: &[String], found: i32, new_count: i32, removed: i32, duration_ms: i64, ) -> SqlResult<()> { let dirs_joined = directories.join(";"); self.conn.execute( "INSERT INTO scan_log (scan_type, directories, found, new_count, removed, duration_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![scan_type, dirs_joined, found, new_count, removed, duration_ms], )?; Ok(()) } pub fn appimage_count(&self) -> SqlResult { self.conn.query_row("SELECT COUNT(*) FROM appimages", [], |row| row.get(0)) } // --- Phase 2: Status updates --- pub fn update_fuse_status(&self, id: i64, status: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET fuse_status = ?2 WHERE id = ?1", params![id, status], )?; Ok(()) } pub fn update_wayland_status(&self, id: i64, status: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET wayland_status = ?2 WHERE id = ?1", params![id, status], )?; Ok(()) } pub fn update_notes(&self, id: i64, notes: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET notes = ?2 WHERE id = ?1", params![id, notes], )?; Ok(()) } pub fn update_sandbox_mode(&self, id: i64, mode: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET sandbox_mode = ?2 WHERE id = ?1", params![id, mode], )?; Ok(()) } pub fn update_launch_args(&self, id: i64, args: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET launch_args = ?2 WHERE id = ?1", params![id, args], )?; Ok(()) } pub fn update_update_info( &self, id: i64, update_info: Option<&str>, update_type: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET update_info = ?2, update_type = ?3 WHERE id = ?1", params![id, update_info, update_type], )?; Ok(()) } pub fn set_update_available( &self, id: i64, latest_version: Option<&str>, update_url: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET latest_version = ?2, update_url = ?3, update_checked = datetime('now') WHERE id = ?1", params![id, latest_version, update_url], )?; Ok(()) } pub fn clear_update_available(&self, id: i64) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET latest_version = NULL, update_url = NULL, update_checked = datetime('now') WHERE id = ?1", params![id], )?; Ok(()) } pub fn get_appimages_with_updates(&self) -> SqlResult> { let sql = format!( "SELECT {} FROM appimages WHERE latest_version IS NOT NULL ORDER BY app_name COLLATE NOCASE, filename", Self::APPIMAGE_COLUMNS ); let mut stmt = self.conn.prepare(&sql)?; let rows = stmt.query_map([], Self::row_to_record)?; rows.collect() } // --- Phase 2: Launch tracking --- pub fn record_launch(&self, appimage_id: i64, source: &str) -> SqlResult<()> { self.conn.execute( "INSERT INTO launch_events (appimage_id, source) VALUES (?1, ?2)", params![appimage_id, source], )?; Ok(()) } pub fn get_launch_count(&self, appimage_id: i64) -> SqlResult { self.conn.query_row( "SELECT COUNT(*) FROM launch_events WHERE appimage_id = ?1", params![appimage_id], |row| row.get(0), ) } pub fn get_last_launched(&self, appimage_id: i64) -> SqlResult> { self.conn.query_row( "SELECT MAX(launched_at) FROM launch_events WHERE appimage_id = ?1", params![appimage_id], |row| row.get(0), ) } pub fn get_launch_events(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, appimage_id, launched_at, source FROM launch_events WHERE appimage_id = ?1 ORDER BY launched_at DESC" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(LaunchEvent { id: row.get(0)?, appimage_id: row.get(1)?, launched_at: row.get(2)?, source: row.get(3)?, }) })?; rows.collect() } // --- Phase 2: Update history --- pub fn record_update( &self, appimage_id: i64, from_version: Option<&str>, to_version: Option<&str>, update_method: Option<&str>, download_size: Option, success: bool, ) -> SqlResult<()> { self.conn.execute( "INSERT INTO update_history (appimage_id, from_version, to_version, update_method, download_size, success) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![appimage_id, from_version, to_version, update_method, download_size, success], )?; Ok(()) } // --- Phase 3: Security scanning --- pub fn clear_bundled_libraries(&self, appimage_id: i64) -> SqlResult<()> { self.conn.execute( "DELETE FROM bundled_libraries WHERE appimage_id = ?1", params![appimage_id], )?; Ok(()) } pub fn insert_bundled_library( &self, appimage_id: i64, soname: &str, detected_name: Option<&str>, detected_version: Option<&str>, file_path: Option<&str>, file_size: i64, ) -> SqlResult { self.conn.execute( "INSERT INTO bundled_libraries (appimage_id, soname, detected_name, detected_version, file_path, file_size) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![appimage_id, soname, detected_name, detected_version, file_path, file_size], )?; Ok(self.conn.last_insert_rowid()) } pub fn get_bundled_libraries(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, appimage_id, soname, detected_name, detected_version, file_path, file_size FROM bundled_libraries WHERE appimage_id = ?1 ORDER BY detected_name, soname" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(BundledLibraryRecord { id: row.get(0)?, appimage_id: row.get(1)?, soname: row.get(2)?, detected_name: row.get(3)?, detected_version: row.get(4)?, file_path: row.get(5)?, file_size: row.get(6)?, }) })?; rows.collect() } pub fn clear_cve_matches(&self, appimage_id: i64) -> SqlResult<()> { self.conn.execute( "DELETE FROM cve_matches WHERE appimage_id = ?1", params![appimage_id], )?; Ok(()) } pub fn insert_cve_match( &self, appimage_id: i64, library_id: i64, cve_id: &str, severity: Option<&str>, cvss_score: Option, summary: Option<&str>, affected_versions: Option<&str>, fixed_version: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "INSERT INTO cve_matches (appimage_id, library_id, cve_id, severity, cvss_score, summary, affected_versions, fixed_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![appimage_id, library_id, cve_id, severity, cvss_score, summary, affected_versions, fixed_version], )?; Ok(()) } pub fn get_cve_matches(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT cm.id, cm.appimage_id, cm.library_id, cm.cve_id, cm.severity, cm.cvss_score, cm.summary, cm.affected_versions, cm.fixed_version, bl.soname, bl.detected_name, bl.detected_version FROM cve_matches cm JOIN bundled_libraries bl ON bl.id = cm.library_id WHERE cm.appimage_id = ?1 ORDER BY cm.cvss_score DESC NULLS LAST" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(CveMatchRecord { id: row.get(0)?, appimage_id: row.get(1)?, library_id: row.get(2)?, cve_id: row.get(3)?, severity: row.get(4)?, cvss_score: row.get(5)?, summary: row.get(6)?, affected_versions: row.get(7)?, fixed_version: row.get(8)?, library_soname: row.get(9)?, library_name: row.get(10)?, library_version: row.get(11)?, }) })?; rows.collect() } pub fn get_cve_summary(&self, appimage_id: i64) -> SqlResult { let mut summary = CveSummary { critical: 0, high: 0, medium: 0, low: 0 }; let mut stmt = self.conn.prepare( "SELECT severity, COUNT(*) FROM cve_matches WHERE appimage_id = ?1 GROUP BY severity" )?; let rows = stmt.query_map(params![appimage_id], |row| { let severity: String = row.get::<_, Option>(0)? .unwrap_or_else(|| "MEDIUM".to_string()); Ok((severity, row.get::<_, i64>(1)?)) })?; for row in rows { let (severity, count) = row?; match severity.as_str() { "CRITICAL" => summary.critical = count, "HIGH" => summary.high = count, "MEDIUM" => summary.medium = count, "LOW" => summary.low = count, _ => {} } } Ok(summary) } pub fn get_all_cve_summary(&self) -> SqlResult { let mut summary = CveSummary { critical: 0, high: 0, medium: 0, low: 0 }; let mut stmt = self.conn.prepare( "SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity" )?; let rows = stmt.query_map([], |row| { let severity: String = row.get::<_, Option>(0)? .unwrap_or_else(|| "MEDIUM".to_string()); Ok((severity, row.get::<_, i64>(1)?)) })?; for row in rows { let (severity, count) = row?; match severity.as_str() { "CRITICAL" => summary.critical = count, "HIGH" => summary.high = count, "MEDIUM" => summary.medium = count, "LOW" => summary.low = count, _ => {} } } Ok(summary) } // --- Phase 3: App data paths --- pub fn insert_app_data_path( &self, appimage_id: i64, path: &str, path_type: &str, discovery_method: &str, confidence: &str, size_bytes: i64, ) -> SqlResult<()> { self.conn.execute( "INSERT OR IGNORE INTO app_data_paths (appimage_id, path, path_type, discovery_method, confidence, size_bytes) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![appimage_id, path, path_type, discovery_method, confidence, size_bytes], )?; Ok(()) } pub fn get_app_data_paths(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, appimage_id, path, path_type, discovery_method, confidence, size_bytes FROM app_data_paths WHERE appimage_id = ?1 ORDER BY path_type, path" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(AppDataPathRecord { id: row.get(0)?, appimage_id: row.get(1)?, path: row.get(2)?, path_type: row.get(3)?, discovery_method: row.get(4)?, confidence: row.get(5)?, size_bytes: row.get(6)?, }) })?; rows.collect() } pub fn clear_app_data_paths(&self, appimage_id: i64) -> SqlResult<()> { self.conn.execute( "DELETE FROM app_data_paths WHERE appimage_id = ?1", params![appimage_id], )?; Ok(()) } pub fn get_update_history(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, appimage_id, from_version, to_version, update_method, download_size, updated_at, success FROM update_history WHERE appimage_id = ?1 ORDER BY updated_at DESC" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(UpdateHistoryEntry { id: row.get(0)?, appimage_id: row.get(1)?, from_version: row.get(2)?, to_version: row.get(3)?, update_method: row.get(4)?, download_size: row.get(5)?, updated_at: row.get(6)?, success: row.get(7)?, }) })?; rows.collect() } // --- Async analysis pipeline --- pub fn update_analysis_status(&self, id: i64, status: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET analysis_status = ?2 WHERE id = ?1", params![id, status], )?; Ok(()) } // --- Phase 5: Runtime Wayland --- pub fn update_runtime_wayland_status(&self, id: i64, status: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET runtime_wayland_status = ?1, runtime_wayland_checked = datetime('now') WHERE id = ?2", params![status, id], )?; Ok(()) } // --- Phase 5: Config Backups --- pub fn insert_config_backup( &self, appimage_id: i64, app_version: Option<&str>, archive_path: &str, archive_size: i64, checksum: Option<&str>, path_count: i32, ) -> SqlResult { self.conn.execute( "INSERT INTO config_backups (appimage_id, app_version, archive_path, archive_size, checksum, path_count) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![appimage_id, app_version, archive_path, archive_size, checksum, path_count], )?; Ok(self.conn.last_insert_rowid()) } pub fn get_config_backups(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, appimage_id, app_version, archive_path, archive_size, checksum, created_at, path_count, restored_count, last_restored_at FROM config_backups WHERE appimage_id = ?1 ORDER BY created_at DESC" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(ConfigBackupRecord { id: row.get(0)?, appimage_id: row.get(1)?, app_version: row.get(2)?, archive_path: row.get(3)?, archive_size: row.get(4)?, checksum: row.get(5)?, created_at: row.get(6)?, path_count: row.get(7)?, restored_count: row.get(8)?, last_restored_at: row.get(9)?, }) })?; rows.collect() } pub fn get_all_config_backups(&self) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, appimage_id, app_version, archive_path, archive_size, checksum, created_at, path_count, restored_count, last_restored_at FROM config_backups ORDER BY created_at DESC" )?; let rows = stmt.query_map([], |row| { Ok(ConfigBackupRecord { id: row.get(0)?, appimage_id: row.get(1)?, app_version: row.get(2)?, archive_path: row.get(3)?, archive_size: row.get(4)?, checksum: row.get(5)?, created_at: row.get(6)?, path_count: row.get(7)?, restored_count: row.get(8)?, last_restored_at: row.get(9)?, }) })?; rows.collect() } pub fn delete_config_backup(&self, backup_id: i64) -> SqlResult<()> { self.conn.execute("DELETE FROM config_backups WHERE id = ?1", params![backup_id])?; Ok(()) } // --- Phase 5: CVE Notifications --- pub fn has_cve_been_notified(&self, appimage_id: i64, cve_id: &str) -> SqlResult { let count: i32 = self.conn.query_row( "SELECT COUNT(*) FROM cve_notifications WHERE appimage_id = ?1 AND cve_id = ?2", params![appimage_id, cve_id], |row| row.get(0), )?; Ok(count > 0) } pub fn mark_cve_notified( &self, appimage_id: i64, cve_id: &str, severity: &str, ) -> SqlResult<()> { self.conn.execute( "INSERT OR IGNORE INTO cve_notifications (appimage_id, cve_id, severity) VALUES (?1, ?2, ?3)", params![appimage_id, cve_id, severity], )?; Ok(()) } // --- Phase 5: Sandbox Profiles --- pub fn insert_sandbox_profile( &self, app_name: &str, profile_version: Option<&str>, author: Option<&str>, description: Option<&str>, content: &str, source: &str, registry_id: Option<&str>, ) -> SqlResult { self.conn.execute( "INSERT INTO sandbox_profiles (app_name, profile_version, author, description, content, source, registry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![app_name, profile_version, author, description, content, source, registry_id], )?; Ok(self.conn.last_insert_rowid()) } pub fn get_sandbox_profile_for_app(&self, app_name: &str) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, app_name, profile_version, author, description, content, source, registry_id, created_at FROM sandbox_profiles WHERE app_name = ?1 ORDER BY created_at DESC LIMIT 1" )?; let mut rows = stmt.query_map(params![app_name], |row| { Ok(SandboxProfileRecord { id: row.get(0)?, app_name: row.get(1)?, profile_version: row.get(2)?, author: row.get(3)?, description: row.get(4)?, content: row.get(5)?, source: row.get(6)?, registry_id: row.get(7)?, created_at: row.get(8)?, }) })?; Ok(rows.next().transpose()?) } // --- Phase 5: Runtime Updates --- // --- Phase 6: Tags, Pin, Startup Time --- pub fn update_tags(&self, id: i64, tags: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET tags = ?2 WHERE id = ?1", params![id, tags], )?; Ok(()) } pub fn set_pinned(&self, id: i64, pinned: bool) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET pinned = ?2 WHERE id = ?1", params![id, pinned], )?; Ok(()) } pub fn update_avg_startup_ms(&self, id: i64, ms: i64) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET avg_startup_ms = ?2 WHERE id = ?1", params![id, ms], )?; Ok(()) } pub fn get_launch_history_daily(&self, id: Option, days: i32) -> SqlResult> { let days_param = format!("-{} days", days); if let Some(app_id) = id { let mut stmt = self.conn.prepare( "SELECT date(launched_at) as day, COUNT(*) as cnt FROM launch_events WHERE appimage_id = ?1 AND launched_at >= datetime('now', ?2) GROUP BY day ORDER BY day" )?; let rows = stmt.query_map(params![app_id, days_param], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) })?; rows.collect() } else { let mut stmt = self.conn.prepare( "SELECT date(launched_at) as day, COUNT(*) as cnt FROM launch_events WHERE launched_at >= datetime('now', ?1) GROUP BY day ORDER BY day" )?; let rows = stmt.query_map(params![days_param], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) })?; rows.collect() } } // --- Phase 5: Runtime Updates --- pub fn record_runtime_update( &self, appimage_id: i64, old_runtime: Option<&str>, new_runtime: Option<&str>, backup_path: Option<&str>, success: bool, ) -> SqlResult { self.conn.execute( "INSERT INTO runtime_updates (appimage_id, old_runtime, new_runtime, backup_path, success) VALUES (?1, ?2, ?3, ?4, ?5)", params![appimage_id, old_runtime, new_runtime, backup_path, success as i32], )?; Ok(self.conn.last_insert_rowid()) } // --- Autostart --- pub fn set_autostart(&self, id: i64, enabled: bool) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET autostart = ?2 WHERE id = ?1", params![id, enabled as i32], )?; Ok(()) } pub fn set_startup_wm_class(&self, id: i64, wm_class: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET startup_wm_class = ?2 WHERE id = ?1", params![id, wm_class], )?; Ok(()) } pub fn set_verification_status(&self, id: i64, status: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET verification_status = ?2 WHERE id = ?1", params![id, status], )?; Ok(()) } pub fn set_first_run_prompted(&self, id: i64, prompted: bool) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET first_run_prompted = ?2 WHERE id = ?1", params![id, prompted as i32], )?; Ok(()) } pub fn set_system_wide(&self, id: i64, system_wide: bool) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET system_wide = ?2 WHERE id = ?1", params![id, system_wide as i32], )?; Ok(()) } pub fn set_portable(&self, id: i64, portable: bool, mount_point: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET is_portable = ?2, mount_point = ?3 WHERE id = ?1", params![id, portable as i32, mount_point], )?; Ok(()) } // --- Launch statistics --- pub fn get_top_launched(&self, limit: i32) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT a.app_name, COUNT(l.id) as cnt FROM launch_events l JOIN appimages a ON a.id = l.appimage_id GROUP BY l.appimage_id ORDER BY cnt DESC LIMIT ?1" )?; let rows = stmt.query_map(params![limit], |row| { Ok(( row.get::<_, Option>(0)?.unwrap_or_else(|| "Unknown".to_string()), row.get::<_, u64>(1)?, )) })?; rows.collect() } pub fn get_total_launch_count(&self) -> SqlResult { self.conn.query_row( "SELECT COUNT(*) FROM launch_events", [], |row| row.get(0), ) } pub fn get_last_launch(&self) -> SqlResult> { match self.conn.query_row( "SELECT a.app_name, l.launched_at FROM launch_events l JOIN appimages a ON a.id = l.appimage_id ORDER BY l.launched_at DESC LIMIT 1", [], |row| Ok(( row.get::<_, Option>(0)?.unwrap_or_else(|| "Unknown".to_string()), row.get::<_, String>(1)?, )), ) { Ok(pair) => Ok(Some(pair)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } // --- Source URL --- pub fn set_source_url(&self, id: i64, url: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET source_url = ?2 WHERE id = ?1", params![id, url], )?; Ok(()) } // --- Version rollback --- pub fn set_previous_version(&self, id: i64, path: Option<&str>) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET previous_version_path = ?2 WHERE id = ?1", params![id, path], )?; Ok(()) } pub fn get_previous_version(&self, id: i64) -> SqlResult> { self.conn.query_row( "SELECT previous_version_path FROM appimages WHERE id = ?1", params![id], |row| row.get(0), ) } // --- Similar apps --- /// Find AppImages from the user's library that share categories with the given app. pub fn find_similar_apps( &self, categories: &str, exclude_id: i64, limit: i32, ) -> SqlResult)>> { // Split categories and match any overlap let cats: Vec<&str> = categories.split(';').filter(|s| !s.is_empty()).collect(); if cats.is_empty() { return Ok(Vec::new()); } // Build LIKE conditions for each category let conditions: Vec = cats.iter() .map(|c| format!("categories LIKE '%{}%'", c.replace('\'', "''"))) .collect(); let where_clause = conditions.join(" OR "); let sql = format!( "SELECT id, COALESCE(app_name, filename) AS name, icon_path FROM appimages WHERE id != ?1 AND ({}) LIMIT ?2", where_clause ); let mut stmt = self.conn.prepare(&sql)?; let rows = stmt.query_map(params![exclude_id, limit], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, )) })?; let mut results = Vec::new(); for row in rows { results.push(row?); } Ok(results) } // --- System modification tracking --- pub fn register_modification( &self, appimage_id: i64, mod_type: &str, file_path: &str, previous_value: Option<&str>, ) -> SqlResult { self.conn.query_row( "INSERT INTO system_modifications (appimage_id, mod_type, file_path, previous_value) VALUES (?1, ?2, ?3, ?4) RETURNING id", params![appimage_id, mod_type, file_path, previous_value], |row| row.get(0), ) } pub fn get_modifications(&self, appimage_id: i64) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, mod_type, file_path, previous_value FROM system_modifications WHERE appimage_id = ?1 ORDER BY id DESC" )?; let rows = stmt.query_map(params![appimage_id], |row| { Ok(SystemModification { id: row.get(0)?, mod_type: row.get(1)?, file_path: row.get(2)?, previous_value: row.get(3)?, }) })?; rows.collect() } pub fn remove_modification(&self, id: i64) -> SqlResult<()> { self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?; Ok(()) } // --- Catalog methods --- pub fn upsert_catalog_source( &self, name: &str, url: &str, source_type: &str, ) -> SqlResult { self.conn.execute( "INSERT INTO catalog_sources (name, url, source_type, last_synced) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(url) DO UPDATE SET last_synced = datetime('now')", params![name, url, source_type], )?; self.conn.query_row( "SELECT id FROM catalog_sources WHERE url = ?1", params![url], |row| row.get(0), ) } pub fn upsert_catalog_app( &self, source_id: i64, name: &str, description: Option<&str>, categories: Option<&str>, download_url: &str, icon_url: Option<&str>, homepage: Option<&str>, license: Option<&str>, ) -> SqlResult { self.conn.execute( "INSERT INTO catalog_apps (source_id, name, description, categories, download_url, icon_url, homepage, cached_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now')) ON CONFLICT(source_id, name) DO UPDATE SET description = COALESCE(?3, description), categories = COALESCE(?4, categories), download_url = ?5, icon_url = COALESCE(?6, icon_url), homepage = COALESCE(?7, homepage), cached_at = datetime('now')", params![source_id, name, description, categories, download_url, icon_url, homepage], )?; // Store license as architecture field (reusing available column) // until a proper column is added if let Some(lic) = license { self.conn.execute( "UPDATE catalog_apps SET architecture = ?1 WHERE source_id = ?2 AND name = ?3", params![lic, source_id, name], )?; } self.conn.query_row( "SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2", params![source_id, name], |row| row.get(0), ) } pub fn search_catalog( &self, query: &str, category: Option<&str>, limit: i32, ) -> SqlResult> { let mut sql = String::from( "SELECT id, name, description, categories, download_url, icon_url, homepage, architecture FROM catalog_apps WHERE 1=1" ); let mut params_list: Vec> = Vec::new(); if !query.is_empty() { sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1)"); params_list.push(Box::new(format!("%{}%", query))); } if let Some(cat) = category { let idx = params_list.len() + 1; sql.push_str(&format!(" AND categories LIKE ?{}", idx)); params_list.push(Box::new(format!("%{}%", cat))); } sql.push_str(&format!(" ORDER BY name LIMIT {}", limit)); let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_list.iter().map(|p| p.as_ref()).collect(); let mut stmt = self.conn.prepare(&sql)?; let rows = stmt.query_map(params_refs.as_slice(), |row| { Ok(CatalogApp { id: row.get(0)?, name: row.get(1)?, description: row.get(2)?, categories: row.get(3)?, download_url: row.get(4)?, icon_url: row.get(5)?, homepage: row.get(6)?, license: row.get(7)?, }) })?; let mut results = Vec::new(); for row in rows { results.push(row?); } Ok(results) } pub fn get_catalog_app(&self, id: i64) -> SqlResult> { let result = self.conn.query_row( "SELECT id, name, description, categories, download_url, icon_url, homepage, architecture FROM catalog_apps WHERE id = ?1", params![id], |row| { Ok(CatalogApp { id: row.get(0)?, name: row.get(1)?, description: row.get(2)?, categories: row.get(3)?, download_url: row.get(4)?, icon_url: row.get(5)?, homepage: row.get(6)?, license: row.get(7)?, }) }, ); match result { Ok(app) => Ok(Some(app)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } pub fn get_catalog_categories(&self) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != ''" )?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; let mut counts = std::collections::HashMap::new(); for row in rows { if let Ok(cats_str) = row { for cat in cats_str.split(';').filter(|s| !s.is_empty()) { *counts.entry(cat.to_string()).or_insert(0u32) += 1; } } } let mut result: Vec<(String, u32)> = counts.into_iter().collect(); result.sort_by(|a, b| b.1.cmp(&a.1)); Ok(result) } pub fn catalog_app_count(&self) -> SqlResult { self.conn.query_row("SELECT COUNT(*) FROM catalog_apps", [], |row| row.get(0)) } pub fn insert_catalog_app( &self, source_id: i64, name: &str, description: Option<&str>, categories: Option<&str>, latest_version: Option<&str>, download_url: &str, icon_url: Option<&str>, homepage: Option<&str>, file_size: Option, architecture: Option<&str>, ) -> SqlResult<()> { self.conn.execute( "INSERT OR REPLACE INTO catalog_apps (source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, cached_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))", params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture], )?; Ok(()) } pub fn search_catalog_apps(&self, query: &str) -> SqlResult> { let pattern = format!("%{}%", query); let mut stmt = self.conn.prepare( "SELECT name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture FROM catalog_apps WHERE name LIKE ?1 OR description LIKE ?1 ORDER BY name LIMIT 50" )?; let rows = stmt.query_map(params![pattern], |row| { Ok(CatalogAppRecord { name: row.get(0)?, description: row.get(1)?, categories: row.get(2)?, latest_version: row.get(3)?, download_url: row.get(4)?, icon_url: row.get(5)?, homepage: row.get(6)?, file_size: row.get(7)?, architecture: row.get(8)?, }) })?; rows.collect() } pub fn update_catalog_source_sync(&self, source_id: i64, app_count: i32) -> SqlResult<()> { self.conn.execute( "UPDATE catalog_sources SET last_synced = datetime('now'), app_count = ?2 WHERE id = ?1", params![source_id, app_count], )?; Ok(()) } pub fn get_catalog_sources(&self) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, name, url, source_type, enabled, last_synced, app_count FROM catalog_sources ORDER BY name" )?; let rows = stmt.query_map([], |row| { Ok(CatalogSourceRecord { id: row.get(0)?, name: row.get(1)?, url: row.get(2)?, source_type: row.get(3)?, enabled: row.get::<_, i32>(4).unwrap_or(1) != 0, last_synced: row.get(5)?, app_count: row.get(6)?, }) })?; rows.collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_create_and_query() { let db = Database::open_in_memory().unwrap(); assert_eq!(db.appimage_count().unwrap(), 0); db.upsert_appimage( "/home/user/Apps/test.AppImage", "test.AppImage", Some(2), 1024000, true, None, ).unwrap(); assert_eq!(db.appimage_count().unwrap(), 1); let all = db.get_all_appimages().unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].filename, "test.AppImage"); assert_eq!(all[0].size_bytes, 1024000); assert!(all[0].is_executable); } #[test] fn test_upsert_updates_existing() { let db = Database::open_in_memory().unwrap(); db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap(); db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 2000, true, None).unwrap(); assert_eq!(db.appimage_count().unwrap(), 1); let record = db.get_appimage_by_path("/path/test.AppImage").unwrap().unwrap(); assert_eq!(record.size_bytes, 2000); } #[test] fn test_metadata_update() { let db = Database::open_in_memory().unwrap(); let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap(); db.update_metadata( id, Some("Test App"), Some("1.0.0"), Some("A test application"), Some("Test Dev"), Some("Utility;Development"), Some("x86_64"), Some("/path/to/icon.png"), Some("[Desktop Entry]\nName=Test App"), ).unwrap(); let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert_eq!(record.app_name.as_deref(), Some("Test App")); assert_eq!(record.app_version.as_deref(), Some("1.0.0")); assert_eq!(record.architecture.as_deref(), Some("x86_64")); } #[test] fn test_integration_toggle() { let db = Database::open_in_memory().unwrap(); let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap(); assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated); db.set_integrated(id, true, Some("/path/to/desktop")).unwrap(); let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert!(record.integrated); assert!(record.integrated_at.is_some()); db.set_integrated(id, false, None).unwrap(); assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated); } #[test] fn test_orphaned_entries() { let db = Database::open_in_memory().unwrap(); db.add_orphaned_entry("/path/to/desktop", Some("/path/app.AppImage"), Some("App")).unwrap(); let orphans = db.get_orphaned_entries().unwrap(); assert_eq!(orphans.len(), 1); assert_eq!(orphans[0].app_name.as_deref(), Some("App")); db.mark_orphan_cleaned(orphans[0].id).unwrap(); assert_eq!(db.get_orphaned_entries().unwrap().len(), 0); } #[test] fn test_scan_log() { let db = Database::open_in_memory().unwrap(); db.log_scan("manual", &["~/Applications".into()], 5, 3, 0, 250).unwrap(); } #[test] fn test_phase2_status_updates() { let db = Database::open_in_memory().unwrap(); let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap(); db.update_fuse_status(id, "fully_functional").unwrap(); db.update_wayland_status(id, "native").unwrap(); db.update_update_info(id, Some("gh-releases-zsync|user|repo|latest|*.zsync"), Some("github")).unwrap(); db.set_update_available(id, Some("2.0.0"), Some("https://example.com/app-2.0.AppImage")).unwrap(); let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert_eq!(record.fuse_status.as_deref(), Some("fully_functional")); assert_eq!(record.wayland_status.as_deref(), Some("native")); assert_eq!(record.update_type.as_deref(), Some("github")); assert_eq!(record.latest_version.as_deref(), Some("2.0.0")); assert!(record.update_checked.is_some()); let with_updates = db.get_appimages_with_updates().unwrap(); assert_eq!(with_updates.len(), 1); db.clear_update_available(id).unwrap(); let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert!(record.latest_version.is_none()); } #[test] fn test_launch_tracking() { let db = Database::open_in_memory().unwrap(); let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap(); assert_eq!(db.get_launch_count(id).unwrap(), 0); assert!(db.get_last_launched(id).unwrap().is_none()); db.record_launch(id, "desktop_entry").unwrap(); db.record_launch(id, "cli").unwrap(); assert_eq!(db.get_launch_count(id).unwrap(), 2); assert!(db.get_last_launched(id).unwrap().is_some()); let events = db.get_launch_events(id).unwrap(); assert_eq!(events.len(), 2); let sources: Vec<&str> = events.iter().map(|e| e.source.as_str()).collect(); assert!(sources.contains(&"desktop_entry")); assert!(sources.contains(&"cli")); } #[test] fn test_update_history() { let db = Database::open_in_memory().unwrap(); let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap(); db.record_update(id, Some("1.0"), Some("2.0"), Some("full_download"), Some(50_000_000), true).unwrap(); let history = db.get_update_history(id).unwrap(); assert_eq!(history.len(), 1); assert_eq!(history[0].from_version.as_deref(), Some("1.0")); assert_eq!(history[0].to_version.as_deref(), Some("2.0")); assert!(history[0].success); } // --- Migration tests --- #[test] fn test_fresh_database_creates_at_latest_version() { let db = Database::open_in_memory().unwrap(); // Verify schema_version is at the latest (9) let version: i32 = db.conn.query_row( "SELECT version FROM schema_version LIMIT 1", [], |row| row.get(0), ).unwrap(); assert_eq!(version, 11); // All tables that should exist after the full v1-v7 migration chain let expected_tables = [ "appimages", "orphaned_entries", "scan_log", "launch_events", "update_history", "duplicate_groups", "duplicate_members", "bundled_libraries", "cve_matches", "app_data_paths", "config_backups", "backup_entries", "exported_reports", "cve_notifications", "catalog_sources", "catalog_apps", "sandbox_profiles", "sandbox_profile_history", "runtime_updates", "system_modifications", ]; for table in &expected_tables { let count: i32 = db.conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", params![table], |row| row.get(0), ).unwrap(); assert_eq!(count, 1, "Expected table '{}' to exist", table); } } #[test] fn test_appimage_columns_include_analysis_status() { let db = Database::open_in_memory().unwrap(); // Insert a record via upsert_appimage let id = db.upsert_appimage( "/tmp/analysis_test.AppImage", "analysis_test.AppImage", Some(2), 5000, true, None, ).unwrap(); // Retrieve and verify analysis_status exists and defaults to 'complete' let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert_eq!( record.analysis_status.as_deref(), Some("complete"), "analysis_status should default to 'complete'" ); } #[test] fn test_update_analysis_status() { let db = Database::open_in_memory().unwrap(); let id = db.upsert_appimage( "/tmp/status_test.AppImage", "status_test.AppImage", Some(2), 3000, true, None, ).unwrap(); // Update to "analyzing" and verify db.update_analysis_status(id, "analyzing").unwrap(); let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert_eq!( record.analysis_status.as_deref(), Some("analyzing"), "analysis_status should be 'analyzing' after update" ); // Update back to "complete" and verify db.update_analysis_status(id, "complete").unwrap(); let record = db.get_appimage_by_id(id).unwrap().unwrap(); assert_eq!( record.analysis_status.as_deref(), Some("complete"), "analysis_status should be 'complete' after second update" ); } #[test] fn test_upsert_and_retrieve() { let db = Database::open_in_memory().unwrap(); let path = "/home/user/Apps/MyApp-3.2.1-x86_64.AppImage"; let filename = "MyApp-3.2.1-x86_64.AppImage"; let appimage_type = Some(2); let size_bytes: i64 = 48_000_000; let is_executable = true; let file_modified = Some("2026-01-15 10:30:00"); let id = db.upsert_appimage( path, filename, appimage_type, size_bytes, is_executable, file_modified, ).unwrap(); // Retrieve by path and verify all basic fields match let record = db.get_appimage_by_path(path).unwrap() .expect("record should exist after upsert"); assert_eq!(record.id, id); assert_eq!(record.path, path); assert_eq!(record.filename, filename); assert_eq!(record.appimage_type, appimage_type); assert_eq!(record.size_bytes, size_bytes); assert_eq!(record.is_executable, is_executable); assert_eq!(record.file_modified.as_deref(), file_modified); } #[test] fn test_remove_missing_cleans_nonexistent() { let db = Database::open_in_memory().unwrap(); // Insert a record with a path that definitely does not exist on disk let id = db.upsert_appimage( "/absolutely/nonexistent/path/fake.AppImage", "fake.AppImage", Some(2), 1234, true, None, ).unwrap(); assert!(id > 0); // Confirm it was inserted assert_eq!(db.appimage_count().unwrap(), 1); // remove_missing_appimages should remove it since the path does not exist let removed = db.remove_missing_appimages().unwrap(); assert_eq!(removed.len(), 1); assert_eq!(removed[0].path, "/absolutely/nonexistent/path/fake.AppImage"); // Verify the database is now empty assert_eq!(db.appimage_count().unwrap(), 0); let record = db.get_appimage_by_id(id).unwrap(); assert!(record.is_none(), "record should be gone after remove_missing_appimages"); } }