//! SQLite-based package database pub mod repo; use crate::package::PackageSpec; use crate::staging; use anyhow::{Context, Result}; use rusqlite::{Connection, params}; use std::fs; use std::path::Path; fn format_licenses(licenses: &[String]) -> String { licenses.join(", ") } /// Initialize database and register a package pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> Result<()> { // Create parent directory (auto-create db dir if missing) if let Some(parent) = db_path.parent() { fs::create_dir_all(parent)?; } let mut conn = Connection::open(db_path)?; init_db(&conn)?; let tx = conn.transaction()?; // Generate manifest with files and directories let manifest = staging::generate_manifest_with_dirs(destdir)?; // Insert/update package without changing its primary key (UPSERT keeps the existing row). tx.execute( "INSERT INTO packages (name, version, revision, description, homepage, license) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(name) DO UPDATE SET version=excluded.version, revision=excluded.revision, description=excluded.description, homepage=excluded.homepage, license=excluded.license", params![ spec.package.name, spec.package.version, spec.package.revision, spec.package.description, spec.package.homepage, format_licenses(&spec.package.license), ], )?; let pkg_id: i64 = tx.query_row( "SELECT id FROM packages WHERE name = ?1", params![spec.package.name], |row| row.get(0), )?; // Replace provides + file + directory lists for this package. tx.execute( "DELETE FROM provides WHERE package_id = ?1", params![pkg_id], )?; tx.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?; tx.execute( "DELETE FROM directories WHERE package_id = ?1", params![pkg_id], )?; // Insert provides for provides in &spec.alternatives.provides { tx.execute( "INSERT OR IGNORE INTO provides (package_id, provides_name) VALUES (?1, ?2)", params![pkg_id, provides], )?; } // Detect ownership conflicts and separate into auto-removable vs fatal. let mut fatal_conflicts: Vec<(String, String)> = Vec::new(); let mut auto_conflicts: Vec<(String, String)> = Vec::new(); for file in &manifest.files { let owner_res: rusqlite::Result = tx.query_row( "SELECT p.name FROM files f JOIN packages p ON f.package_id = p.id WHERE f.path = ?1", params![file], |row| row.get(0), ); if let Ok(owner) = owner_res && owner != spec.package.name { if is_auto_removable_path(file) { auto_conflicts.push((file.clone(), owner)); } else { fatal_conflicts.push((file.clone(), owner)); } } } if !fatal_conflicts.is_empty() { let mut msg = String::from("File ownership conflict detected:\n"); for (f, owner) in &fatal_conflicts { msg.push_str(&format!(" {} -> owned by {}\n", f, owner)); } anyhow::bail!("{}", msg); } // For auto-removable conflicts, remove previous DB ownership entries and // attempt to delete the on-disk file if we can infer the rootfs. if !auto_conflicts.is_empty() { let rootfs_opt = detect_rootfs_from_db_path(db_path); for (f, owner) in &auto_conflicts { // Remove DB row(s) marking the previous owner for this path tx.execute( "DELETE FROM files WHERE path = ?1 AND package_id = (SELECT id FROM packages WHERE name = ?2)", params![f, owner], )?; if let Some(rootfs) = &rootfs_opt { let disk_path = rootfs.join(f); if disk_path.exists() { let _ = std::fs::remove_file(&disk_path); crate::log_info!( "Auto-removed conflicting path: {} (was owned by {})", f, owner ); } } else { crate::log_info!( "Auto-cleared DB ownership for path: {} (previously owned by {})", f, owner ); } } } // Insert files (no fatal conflicts remain) for file in &manifest.files { tx.execute( "INSERT INTO files (package_id, path) VALUES (?1, ?2)", params![pkg_id, file], )?; } // Insert directories (can be shared by multiple packages) for dir in &manifest.directories { tx.execute( "INSERT INTO directories (package_id, path) VALUES (?1, ?2)", params![pkg_id, dir], )?; } tx.commit()?; Ok(()) } /// Return the list of files owned by an installed package. pub fn get_package_files(db_path: &Path, name: &str) -> Result> { if !db_path.exists() { return Ok(Vec::new()); } let conn = Connection::open(db_path)?; init_db(&conn)?; // If the package is not present in the DB (fresh install), treat that as // "no previously installed files" rather than an error. let pkg_id_res: rusqlite::Result = conn.query_row( "SELECT id FROM packages WHERE name = ?1", params![name], |row| row.get(0), ); let pkg_id = match pkg_id_res { Ok(id) => id, Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(Vec::new()), Err(e) => return Err(e.into()), }; let mut stmt = conn.prepare("SELECT path FROM files WHERE package_id = ?1")?; let files: Vec = stmt .query_map(params![pkg_id], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); Ok(files) } /// Remove a package from the database and filesystem pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> { if !db_path.exists() { anyhow::bail!("Package database not found"); } let conn = Connection::open(db_path)?; init_db(&conn)?; // Get package ID let pkg_id: i64 = conn .query_row( "SELECT id FROM packages WHERE name = ?1", params![name], |row| row.get(0), ) .context(format!("Package '{}' not found", name))?; // Get file list let mut stmt = conn.prepare("SELECT path FROM files WHERE package_id = ?1")?; let files: Vec = stmt .query_map(params![pkg_id], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); // Get directory list (sorted deepest-first for proper removal order) let mut stmt = conn.prepare("SELECT path FROM directories WHERE package_id = ?1")?; let mut directories: Vec = stmt .query_map(params![pkg_id], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); directories.sort_by_key(|b| std::cmp::Reverse(b.matches('/').count())); // Remove files let mut removal_errors: Vec = Vec::new(); for file in &files { let path = rootfs.join(file); match fs::remove_file(&path) { Ok(()) => { crate::log_info!(" Removed file: {}", file); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Already gone, keep going. } Err(e) => { removal_errors.push(format!("{}: {}", file, e)); } } } // Remove directories (only if empty and not owned by another package) let mut dirs_removed = 0; for dir in &directories { let path = rootfs.join(dir); // Skip if outside rootfs if !path.starts_with(rootfs) { continue; } // Check if directory is owned by another package let other_owners: i64 = conn.query_row( "SELECT COUNT(*) FROM directories WHERE path = ?1 AND package_id != ?2", params![dir, pkg_id], |row| row.get(0), )?; if other_owners > 0 { crate::log_info!(" Keeping directory (owned by other package): {}", dir); continue; } // Try to remove (will fail if not empty, which is fine) match fs::remove_dir(&path) { Ok(()) => { crate::log_info!(" Removed directory: {}", dir); dirs_removed += 1; } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Already gone } Err(e) if e.raw_os_error() == Some(39) || e.raw_os_error() == Some(66) => { // ENOTEMPTY (39 on Linux, 66 on macOS) - directory not empty crate::log_info!(" Keeping directory (not empty): {}", dir); } Err(_) => { // Other errors (permission, etc.) - just skip silently } } } // Remove from database conn.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?; conn.execute( "DELETE FROM directories WHERE package_id = ?1", params![pkg_id], )?; conn.execute( "DELETE FROM provides WHERE package_id = ?1", params![pkg_id], )?; conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?; crate::log_info!( "Removed {} files and {} directories", files.len(), dirs_removed ); if !removal_errors.is_empty() { crate::log_warn!("Failed to remove some paths:"); for err in removal_errors { crate::log_warn!(" {}", err); } } Ok(()) } /// Show information about an installed package pub fn show_package_info(db_path: &Path, name: &str) -> Result<()> { if !db_path.exists() { anyhow::bail!("Package database not found"); } let conn = Connection::open(db_path)?; init_db(&conn)?; let (version, revision, description, homepage, license): (String, u32, String, String, String) = conn .query_row( "SELECT version, revision, description, homepage, license FROM packages WHERE name = ?1", params![name], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), ) .context(format!("Package '{}' not found", name))?; crate::log_info!("Package: {} v{}-{}", name, version, revision); crate::log_info!("Description: {}", description); crate::log_info!("Homepage: {}", homepage); crate::log_info!("License: {}", license); // Count files let file_count: i64 = conn.query_row( "SELECT COUNT(*) FROM files f JOIN packages p ON f.package_id = p.id WHERE p.name = ?1", params![name], |row| row.get(0), )?; crate::log_info!("Files: {}", file_count); Ok(()) } /// List all installed packages pub fn list_packages(db_path: &Path) -> Result<()> { if !db_path.exists() { crate::log_info!("No packages installed."); return Ok(()); } let conn = Connection::open(db_path)?; init_db(&conn)?; let mut stmt = conn.prepare("SELECT name, version FROM packages ORDER BY name")?; let packages = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })?; crate::log_info!("{:<30} VERSION", "PACKAGE"); crate::log_info!("{}", "-".repeat(50)); for pkg in packages { let (name, version) = pkg?; crate::log_info!("{:<30} {}", name, version); } Ok(()) } fn init_db(conn: &Connection) -> Result<()> { conn.execute_batch( " CREATE TABLE IF NOT EXISTS packages ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, version TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, description TEXT, homepage TEXT, license TEXT ); CREATE TABLE IF NOT EXISTS provides ( id INTEGER PRIMARY KEY, package_id INTEGER NOT NULL, provides_name TEXT NOT NULL, FOREIGN KEY (package_id) REFERENCES packages(id), UNIQUE(package_id, provides_name) ); CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, package_id INTEGER NOT NULL, path TEXT NOT NULL, FOREIGN KEY (package_id) REFERENCES packages(id), UNIQUE(path) ); CREATE TABLE IF NOT EXISTS directories ( id INTEGER PRIMARY KEY, package_id INTEGER NOT NULL, path TEXT NOT NULL, FOREIGN KEY (package_id) REFERENCES packages(id) ); CREATE INDEX IF NOT EXISTS idx_files_package ON files(package_id); CREATE INDEX IF NOT EXISTS idx_provides_name ON provides(provides_name); CREATE INDEX IF NOT EXISTS idx_directories_package ON directories(package_id); CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path); ", )?; Ok(()) } /// Decide whether a conflicting path is safe to auto-remove ownership for. /// This covers common language-shared trees such as Perl site/vendor/lib /// directories. fn is_auto_removable_path(path: &str) -> bool { let p = path.trim_start_matches('/'); // Perl shared trees (common multi-package locations) if p.starts_with("usr/lib/perl") || p.starts_with("usr/share/perl") || p.starts_with("usr/lib/perl5") || p.starts_with("usr/share/perl5") { return true; } false } /// Attempt to infer the rootfs path from the database path. If the DB path /// follows the standard layout `/var/lib/depot/packages.db`, return /// the `` portion. Otherwise return None. fn detect_rootfs_from_db_path(db_path: &Path) -> Option { // Look for the suffix `var/lib/depot/packages.db` and return the ancestor 4 levels up if db_path.ends_with(std::path::Path::new("var/lib/depot/packages.db")) { return db_path.ancestors().nth(4).map(|p| p.to_path_buf()); } None } /// Calculate which files need to be removed during an upgrade. /// Returns paths that exist in the old version but NOT in the new version. pub fn calculate_upgrade_paths( db_path: &Path, name: &str, new_files: &[String], ) -> Result> { let old_files = get_package_files(db_path, name)?; let mut new_set = std::collections::HashSet::new(); for f in new_files { new_set.insert(f); } let remove_paths: Vec = old_files .into_iter() .filter(|p| !new_set.contains(p)) .collect(); Ok(remove_paths) } /// Get set of all installed package names pub fn get_installed_packages(db_path: &Path) -> Result> { use std::collections::HashSet; if !db_path.exists() { return Ok(HashSet::new()); } let conn = Connection::open(db_path)?; init_db(&conn)?; let mut stmt = conn.prepare("SELECT name FROM packages")?; let names: HashSet = stmt .query_map([], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); Ok(names) } /// Get set of all provided package names (alternatives) pub fn get_all_provides(db_path: &Path) -> Result> { use std::collections::HashSet; if !db_path.exists() { return Ok(HashSet::new()); } let conn = Connection::open(db_path)?; init_db(&conn)?; let mut stmt = conn.prepare("SELECT provides_name FROM provides")?; let names: HashSet = stmt .query_map([], |row| row.get(0))? .filter_map(|r| r.ok()) .collect(); Ok(names) } /// Get version of a specific installed package pub fn get_package_version(db_path: &Path, name: &str) -> Result> { if !db_path.exists() { return Ok(None); } let conn = Connection::open(db_path)?; init_db(&conn)?; let version: Option = conn .query_row( "SELECT version FROM packages WHERE name = ?1", params![name], |row| row.get(0), ) .ok(); Ok(version) } /// Find the installed package that owns a filesystem path from the local DB. pub fn owns_path(db_path: &Path, path: &Path) -> Result> { if !db_path.exists() { return Ok(None); } let normalized = path .to_string_lossy() .trim_start_matches('/') .trim_start_matches("./") .to_string(); if normalized.is_empty() { return Ok(None); } let conn = Connection::open(db_path)?; init_db(&conn)?; let owner = conn .query_row( "SELECT p.name FROM files f JOIN packages p ON p.id = f.package_id WHERE f.path = ?1 LIMIT 1", params![normalized], |row| row.get(0), ) .ok(); Ok(owner) } #[cfg(test)] mod tests { use super::*; use crate::package::{ Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, }; use std::path::PathBuf; fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo { name: name.into(), version: version.into(), revision: 1, description: "d".into(), homepage: "h".into(), license: vec!["MIT".into()], }, packages: Vec::new(), alternatives: Alternatives { provides: vec![format!("{}-virtual", name)], replaces: Vec::new(), }, manual_sources: Vec::new(), source: vec![Source { url: "https://example.com/foo.tar.gz".into(), sha256: "skip".into(), extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Custom, flags: BuildFlags::default(), }, dependencies: Dependencies::default(), package_alternatives: Default::default(), package_dependencies: Default::default(), spec_dir: PathBuf::from("."), } } #[test] fn register_package_updates_in_place_and_replaces_file_list() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); let spec_v1 = mk_spec("foo", "1.0"); let dest1 = tmp.path().join("dest1"); std::fs::create_dir_all(dest1.join("usr/bin")).unwrap(); std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap(); register_package(&db_path, &spec_v1, &dest1).unwrap(); // Capture package id let conn = Connection::open(&db_path).unwrap(); let id1: i64 = conn .query_row( "SELECT id FROM packages WHERE name = ?1", params!["foo"], |r| r.get(0), ) .unwrap(); // Update with different file set let spec_v2 = mk_spec("foo", "2.0"); let dest2 = tmp.path().join("dest2"); std::fs::create_dir_all(dest2.join("usr/bin")).unwrap(); std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap(); std::fs::write(dest2.join("usr/bin/new_only"), "x").unwrap(); register_package(&db_path, &spec_v2, &dest2).unwrap(); let id2: i64 = conn .query_row( "SELECT id FROM packages WHERE name = ?1", params!["foo"], |r| r.get(0), ) .unwrap(); assert_eq!(id1, id2); let files = get_package_files(&db_path, "foo").unwrap(); assert!(files.contains(&"usr/bin/foo".to_string())); assert!(files.contains(&"usr/bin/new_only".to_string())); let version = get_package_version(&db_path, "foo").unwrap(); assert_eq!(version.as_deref(), Some("2.0")); } #[test] fn register_package_detects_conflicting_files() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); // Install package 'alpha' owning usr/bin/shared let spec_a = mk_spec("alpha", "1.0"); let dest_a = tmp.path().join("dest_a"); std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); std::fs::write(dest_a.join("usr/bin/shared"), "a").unwrap(); register_package(&db_path, &spec_a, &dest_a).unwrap(); // Try to install package 'beta' that also includes the same path -> should fail let spec_b = mk_spec("beta", "1.0"); let dest_b = tmp.path().join("dest_b"); std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); std::fs::write(dest_b.join("usr/bin/shared"), "b").unwrap(); let res = register_package(&db_path, &spec_b, &dest_b); assert!(res.is_err()); let err = format!("{}", res.err().unwrap()); assert!(err.contains("File ownership conflict detected")); assert!(err.contains("usr/bin/shared")); assert!(err.contains("alpha")); } #[test] fn register_package_auto_clears_safe_conflicts() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); // Install package 'alpha' owning a known shared Perl path let spec_a = mk_spec("alpha", "1.0"); let dest_a = tmp.path().join("dest_a"); std::fs::create_dir_all(dest_a.join("usr/share/perl5")).unwrap(); std::fs::write(dest_a.join("usr/share/perl5/shared.pm"), "package A;").unwrap(); register_package(&db_path, &spec_a, &dest_a).unwrap(); // Now install package 'beta' that also provides the same shared path -> should auto-clear let spec_b = mk_spec("beta", "1.0"); let dest_b = tmp.path().join("dest_b"); std::fs::create_dir_all(dest_b.join("usr/share/perl5")).unwrap(); std::fs::write(dest_b.join("usr/share/perl5/shared.pm"), "package B;").unwrap(); // This should succeed and transfer ownership of the shared path to beta register_package(&db_path, &spec_b, &dest_b).unwrap(); // Verify DB: alpha should no longer own the path, beta should let files_a = get_package_files(&db_path, "alpha").unwrap(); assert!(!files_a.contains(&"usr/share/perl5/shared.pm".to_string())); let files_b = get_package_files(&db_path, "beta").unwrap(); assert!(files_b.contains(&"usr/share/perl5/shared.pm".to_string())); } #[test] fn get_package_files_missing_package_returns_empty() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); // Create an empty database file with schema but no packages let conn = Connection::open(&db_path).unwrap(); init_db(&conn).unwrap(); drop(conn); // Querying files for a package that doesn't exist should return an empty list let files = get_package_files(&db_path, "nonexistent").unwrap(); assert!(files.is_empty()); } #[test] fn get_package_version_missing_db_returns_none_without_creating_db() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); let version = get_package_version(&db_path, "nonexistent").unwrap(); assert!(version.is_none()); assert!(!db_path.exists()); } #[test] fn calculate_upgrade_paths_handles_existing_db_file_without_schema() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); std::fs::File::create(&db_path).unwrap(); let remove_paths = calculate_upgrade_paths(&db_path, "nonexistent", &["usr/bin/foo".to_string()]).unwrap(); assert!(remove_paths.is_empty()); } #[test] fn remove_package_tolerates_missing_files_and_cleans_db() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); let rootfs = tmp.path().join("root"); std::fs::create_dir_all(&rootfs).unwrap(); let spec = mk_spec("foo", "1.0"); let dest = tmp.path().join("dest"); std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap(); register_package(&db_path, &spec, &dest).unwrap(); // Create the installed file in rootfs (one real) std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); std::fs::write(rootfs.join("usr/bin/foo"), "bin").unwrap(); // Inject an extra missing file into DB to ensure we tolerate it. let conn = Connection::open(&db_path).unwrap(); let pkg_id: i64 = conn .query_row( "SELECT id FROM packages WHERE name = ?1", params!["foo"], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT OR IGNORE INTO files (package_id, path) VALUES (?1, ?2)", params![pkg_id, "usr/bin/does_not_exist"], ) .unwrap(); remove_package(&db_path, "foo", &rootfs).unwrap(); assert!(get_package_version(&db_path, "foo").unwrap().is_none()); } #[test] fn test_package_upgrade_removes_orphaned_files() { let tmp = tempfile::tempdir().unwrap(); let db_path = tmp.path().join("packages.db"); let rootfs = tmp.path().join("root"); let tx_base = tmp.path().join("tx"); std::fs::create_dir_all(&rootfs).unwrap(); // 1. Install v1: usr/bin/foo, usr/bin/shared_dir/old_file let spec_v1 = mk_spec("foo", "1.0"); let dest1 = tmp.path().join("dest1"); std::fs::create_dir_all(dest1.join("usr/bin/shared_dir")).unwrap(); std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap(); std::fs::write(dest1.join("usr/bin/shared_dir/old_file"), "old").unwrap(); register_package(&db_path, &spec_v1, &dest1).unwrap(); let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[], &[]).unwrap(); assert!(rootfs.join("usr/bin/foo").exists()); assert!(rootfs.join("usr/bin/shared_dir/old_file").exists()); // 2. Prepare v2: usr/bin/foo (updated), usr/bin/new_file // (shared_dir/old_file is removed from spec) let spec_v2 = mk_spec("foo", "2.0"); let dest2 = tmp.path().join("dest2"); std::fs::create_dir_all(dest2.join("usr/bin")).unwrap(); std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap(); std::fs::write(dest2.join("usr/bin/new_file"), "new").unwrap(); let manifest2 = crate::staging::generate_manifest_with_dirs(&dest2).unwrap(); let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2.files).unwrap(); assert_eq!( remove_paths, vec!["usr/bin/shared_dir/old_file".to_string()] ); let tx = crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); register_package(&db_path, &spec_v2, &dest2).unwrap(); tx.commit().unwrap(); // 3. Verify filesystem assert_eq!( std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), "v2" ); assert!(rootfs.join("usr/bin/new_file").exists()); assert!(!rootfs.join("usr/bin/shared_dir/old_file").exists()); // Check DB let files = get_package_files(&db_path, "foo").unwrap(); assert!(files.contains(&"usr/bin/foo".to_string())); assert!(files.contains(&"usr/bin/new_file".to_string())); assert!(!files.contains(&"usr/bin/shared_dir/old_file".to_string())); } }