192cdc1af2
- Introduced a new `groups` field in the package specification to categorize packages. - Updated various functions to handle package groups, including parsing, registration, and querying. - Enhanced database schema to store package group information. - Modified the interactive package creation process to allow users to specify package groups. - Implemented tests to verify the correct handling of package groups in both source and binary repositories. - Updated the output formatting to include package group information where applicable.
1441 lines
48 KiB
Rust
Executable File
1441 lines
48 KiB
Rust
Executable File
//! SQLite-based package database
|
|
|
|
pub mod repo;
|
|
|
|
use crate::package::PackageSpec;
|
|
use crate::staging;
|
|
use anyhow::{Context, Result};
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use walkdir::WalkDir;
|
|
|
|
fn format_licenses(licenses: &[String]) -> String {
|
|
licenses.join(", ")
|
|
}
|
|
|
|
fn verbose_remove_output() -> bool {
|
|
std::env::var_os("DEPOT_VERBOSE_REMOVE").is_some()
|
|
}
|
|
|
|
/// Installed package row from the local package database.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InstalledPackageRecord {
|
|
/// Installed package name.
|
|
pub name: String,
|
|
/// Stable package stream name used for renamed updates.
|
|
pub real_name: Option<String>,
|
|
/// Installed package version.
|
|
pub version: String,
|
|
/// Installed package revision.
|
|
pub revision: u32,
|
|
/// Whether renamed updates should retain versioned shared libraries.
|
|
pub abi_breaking: bool,
|
|
/// Package completion timestamp if known.
|
|
pub completed_at: Option<i64>,
|
|
}
|
|
|
|
impl InstalledPackageRecord {
|
|
/// Return the stable package stream name, defaulting to the package name.
|
|
pub fn effective_real_name(&self) -> &str {
|
|
self.real_name.as_deref().unwrap_or(&self.name)
|
|
}
|
|
}
|
|
|
|
/// Rename-aware replacement metadata applied while registering a new package.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PackageReplacement {
|
|
/// Installed package name being replaced.
|
|
pub old_name: String,
|
|
/// Subset of old-package files to keep installed and owned by the old package.
|
|
pub retained_files: Vec<String>,
|
|
/// Subset of old-package directories to keep owned by the old package.
|
|
pub retained_directories: Vec<String>,
|
|
}
|
|
|
|
impl PackageReplacement {
|
|
/// Return true when the replaced package remains installed after registration.
|
|
pub fn retains_old_package(&self) -> bool {
|
|
!self.retained_files.is_empty() || !self.retained_directories.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Initialize database and register a package
|
|
pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> Result<()> {
|
|
register_package_with_replacement(db_path, spec, destdir, None)
|
|
}
|
|
|
|
/// Initialize database and register a package, optionally replacing a renamed predecessor.
|
|
pub fn register_package_with_replacement(
|
|
db_path: &Path,
|
|
spec: &PackageSpec,
|
|
destdir: &Path,
|
|
replacement: Option<&PackageReplacement>,
|
|
) -> 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()?;
|
|
let completed_at = package_completed_at(destdir)?;
|
|
|
|
// Generate manifest with files and directories
|
|
let manifest = staging::generate_manifest_with_dirs(destdir)?;
|
|
|
|
if let Some(replacement) = replacement {
|
|
if replacement.old_name == spec.package.name {
|
|
anyhow::bail!(
|
|
"Replacement package '{}' cannot match new package name",
|
|
spec.package.name
|
|
);
|
|
}
|
|
|
|
let old_pkg_id: i64 = tx
|
|
.query_row(
|
|
"SELECT id FROM packages WHERE name = ?1",
|
|
params![replacement.old_name],
|
|
|row| row.get(0),
|
|
)
|
|
.with_context(|| format!("Package '{}' not found", replacement.old_name))?;
|
|
|
|
if replacement.retains_old_package() {
|
|
tx.execute(
|
|
"DELETE FROM files WHERE package_id = ?1",
|
|
params![old_pkg_id],
|
|
)?;
|
|
tx.execute(
|
|
"DELETE FROM directories WHERE package_id = ?1",
|
|
params![old_pkg_id],
|
|
)?;
|
|
for file in &replacement.retained_files {
|
|
tx.execute(
|
|
"INSERT INTO files (package_id, path) VALUES (?1, ?2)",
|
|
params![old_pkg_id, file],
|
|
)?;
|
|
}
|
|
for directory in &replacement.retained_directories {
|
|
tx.execute(
|
|
"INSERT INTO directories (package_id, path) VALUES (?1, ?2)",
|
|
params![old_pkg_id, directory],
|
|
)?;
|
|
}
|
|
} else {
|
|
delete_package_rows_tx(&tx, old_pkg_id)?;
|
|
}
|
|
}
|
|
|
|
// Insert/update package without changing its primary key (UPSERT keeps the existing row).
|
|
tx.execute(
|
|
"INSERT INTO packages (name, real_name, version, revision, description, homepage, license, abi_breaking, completed_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
real_name=excluded.real_name,
|
|
version=excluded.version,
|
|
revision=excluded.revision,
|
|
description=excluded.description,
|
|
homepage=excluded.homepage,
|
|
license=excluded.license,
|
|
abi_breaking=excluded.abi_breaking,
|
|
completed_at=excluded.completed_at",
|
|
params![
|
|
spec.package.name,
|
|
spec.package.real_name,
|
|
spec.package.version,
|
|
spec.package.revision,
|
|
spec.package.description,
|
|
spec.package.homepage,
|
|
format_licenses(&spec.package.license),
|
|
spec.package.abi_breaking,
|
|
completed_at,
|
|
],
|
|
)?;
|
|
|
|
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 replaces 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],
|
|
)?;
|
|
tx.execute(
|
|
"DELETE FROM package_groups 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],
|
|
)?;
|
|
}
|
|
|
|
for replaces in &spec.alternatives.replaces {
|
|
tx.execute(
|
|
"INSERT OR IGNORE INTO replaces (package_id, replaces_name) VALUES (?1, ?2)",
|
|
params![pkg_id, replaces],
|
|
)?;
|
|
}
|
|
for group in &spec.dependencies.groups {
|
|
tx.execute(
|
|
"INSERT OR IGNORE INTO package_groups (package_id, group_name) VALUES (?1, ?2)",
|
|
params![pkg_id, group],
|
|
)?;
|
|
}
|
|
|
|
// 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<String> = 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<Vec<String>> {
|
|
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<i64> = 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<String> = stmt
|
|
.query_map(params![pkg_id], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
/// Return the list of directories owned by an installed package.
|
|
pub fn get_package_directories(db_path: &Path, name: &str) -> Result<Vec<String>> {
|
|
if !db_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let pkg_id_res: rusqlite::Result<i64> = 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 directories WHERE package_id = ?1")?;
|
|
let mut directories: Vec<String> = stmt
|
|
.query_map(params![pkg_id], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
directories.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count()));
|
|
Ok(directories)
|
|
}
|
|
|
|
/// 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<String> = 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<String> = 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<String> = Vec::new();
|
|
for file in &files {
|
|
let path = rootfs.join(file);
|
|
match fs::remove_file(&path) {
|
|
Ok(()) => {
|
|
if verbose_remove_output() {
|
|
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)
|
|
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 {
|
|
continue;
|
|
}
|
|
|
|
// Try to remove (will fail if not empty, which is fine)
|
|
match fs::remove_dir(&path) {
|
|
Ok(()) => {
|
|
if verbose_remove_output() {
|
|
crate::log_info!(" Removed directory: {}", dir);
|
|
}
|
|
}
|
|
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
|
|
if verbose_remove_output() {
|
|
crate::log_info!(" Keeping directory (not empty): {}", dir);
|
|
}
|
|
}
|
|
Err(_) => {
|
|
// Other errors (permission, etc.) - just skip silently
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove from database
|
|
delete_package_rows(&conn, pkg_id)?;
|
|
|
|
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);
|
|
|
|
let groups = get_package_groups(db_path, name)?;
|
|
if !groups.is_empty() {
|
|
crate::log_info!("Groups: {}", groups.join(", "));
|
|
}
|
|
|
|
// 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
|
|
p.name,
|
|
p.version,
|
|
GROUP_CONCAT(pg.group_name, ',')
|
|
FROM packages p
|
|
LEFT JOIN package_groups pg ON pg.package_id = p.id
|
|
GROUP BY p.id
|
|
ORDER BY p.name",
|
|
)?;
|
|
let packages = stmt.query_map([], |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, Option<String>>(2)?,
|
|
))
|
|
})?;
|
|
|
|
crate::log_info!("{:<30} VERSION", "PACKAGE");
|
|
crate::log_info!("{}", "-".repeat(50));
|
|
|
|
for pkg in packages {
|
|
let (name, version, groups_csv) = pkg?;
|
|
let groups = groups_csv
|
|
.map(|csv| {
|
|
let mut values: Vec<String> = csv
|
|
.split(',')
|
|
.filter(|value| !value.is_empty())
|
|
.map(String::from)
|
|
.collect();
|
|
values.sort();
|
|
values.dedup();
|
|
values
|
|
})
|
|
.unwrap_or_default();
|
|
if groups.is_empty() {
|
|
crate::log_info!("{:<30} {}", name, version);
|
|
} else {
|
|
crate::log_info!("{:<30} {} [groups: {}]", name, version, groups.join(", "));
|
|
}
|
|
}
|
|
|
|
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,
|
|
real_name TEXT,
|
|
version TEXT NOT NULL,
|
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
description TEXT,
|
|
homepage TEXT,
|
|
license TEXT,
|
|
abi_breaking INTEGER NOT NULL DEFAULT 0,
|
|
completed_at INTEGER
|
|
);
|
|
|
|
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 replaces (
|
|
id INTEGER PRIMARY KEY,
|
|
package_id INTEGER NOT NULL,
|
|
replaces_name TEXT NOT NULL,
|
|
FOREIGN KEY (package_id) REFERENCES packages(id),
|
|
UNIQUE(package_id, replaces_name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS package_groups (
|
|
id INTEGER PRIMARY KEY,
|
|
package_id INTEGER NOT NULL,
|
|
group_name TEXT NOT NULL,
|
|
FOREIGN KEY (package_id) REFERENCES packages(id),
|
|
UNIQUE(package_id, group_name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS installed_groups (
|
|
group_name TEXT PRIMARY KEY NOT NULL
|
|
);
|
|
|
|
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_replaces_name ON replaces(replaces_name);
|
|
CREATE INDEX IF NOT EXISTS idx_package_groups_name ON package_groups(group_name);
|
|
CREATE INDEX IF NOT EXISTS idx_directories_package ON directories(package_id);
|
|
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
|
|
",
|
|
)?;
|
|
ensure_packages_completed_at_column(conn)?;
|
|
ensure_packages_real_name_column(conn)?;
|
|
ensure_packages_abi_breaking_column(conn)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_packages_completed_at_column(conn: &Connection) -> Result<()> {
|
|
let has_completed_at: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'",
|
|
[],
|
|
|row| {
|
|
let count: i64 = row.get(0)?;
|
|
Ok(count > 0)
|
|
},
|
|
)
|
|
.context("Failed to inspect installed package DB schema")?;
|
|
if !has_completed_at {
|
|
conn.execute("ALTER TABLE packages ADD COLUMN completed_at INTEGER", [])
|
|
.context("Failed to add completed_at column to installed package DB")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_packages_real_name_column(conn: &Connection) -> Result<()> {
|
|
let has_real_name: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'real_name'",
|
|
[],
|
|
|row| {
|
|
let count: i64 = row.get(0)?;
|
|
Ok(count > 0)
|
|
},
|
|
)
|
|
.context("Failed to inspect installed package DB schema")?;
|
|
if !has_real_name {
|
|
conn.execute("ALTER TABLE packages ADD COLUMN real_name TEXT", [])
|
|
.context("Failed to add real_name column to installed package DB")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_packages_abi_breaking_column(conn: &Connection) -> Result<()> {
|
|
let has_abi_breaking: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'abi_breaking'",
|
|
[],
|
|
|row| {
|
|
let count: i64 = row.get(0)?;
|
|
Ok(count > 0)
|
|
},
|
|
)
|
|
.context("Failed to inspect installed package DB schema")?;
|
|
if !has_abi_breaking {
|
|
conn.execute(
|
|
"ALTER TABLE packages ADD COLUMN abi_breaking INTEGER NOT NULL DEFAULT 0",
|
|
[],
|
|
)
|
|
.context("Failed to add abi_breaking column to installed package DB")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn delete_package_rows(conn: &Connection, pkg_id: i64) -> Result<()> {
|
|
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 replaces WHERE package_id = ?1",
|
|
params![pkg_id],
|
|
)?;
|
|
conn.execute(
|
|
"DELETE FROM package_groups WHERE package_id = ?1",
|
|
params![pkg_id],
|
|
)?;
|
|
conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn delete_package_rows_tx(tx: &rusqlite::Transaction<'_>, pkg_id: i64) -> Result<()> {
|
|
tx.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?;
|
|
tx.execute(
|
|
"DELETE FROM directories WHERE package_id = ?1",
|
|
params![pkg_id],
|
|
)?;
|
|
tx.execute(
|
|
"DELETE FROM provides WHERE package_id = ?1",
|
|
params![pkg_id],
|
|
)?;
|
|
tx.execute(
|
|
"DELETE FROM replaces WHERE package_id = ?1",
|
|
params![pkg_id],
|
|
)?;
|
|
tx.execute(
|
|
"DELETE FROM package_groups WHERE package_id = ?1",
|
|
params![pkg_id],
|
|
)?;
|
|
tx.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn package_completed_at(destdir: &Path) -> Result<Option<i64>> {
|
|
let metadata_path = destdir.join(".metadata.toml");
|
|
if let Some(completed_at) =
|
|
crate::metadata_time::read_completed_at_from_metadata_path(&metadata_path)?
|
|
{
|
|
return Ok(Some(completed_at));
|
|
}
|
|
latest_tree_mtime(destdir)
|
|
}
|
|
|
|
fn latest_tree_mtime(path: &Path) -> Result<Option<i64>> {
|
|
let mut latest = None;
|
|
|
|
for entry in WalkDir::new(path)
|
|
.into_iter()
|
|
.filter_map(|entry| entry.ok())
|
|
{
|
|
let modified = entry
|
|
.metadata()
|
|
.with_context(|| format!("Failed to read metadata for {}", entry.path().display()))?
|
|
.modified()
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to read modification time for {}",
|
|
entry.path().display()
|
|
)
|
|
})?;
|
|
let modified = crate::metadata_time::system_time_to_unix(modified)?;
|
|
latest = Some(latest.map_or(modified, |current: i64| current.max(modified)));
|
|
}
|
|
|
|
Ok(latest)
|
|
}
|
|
|
|
/// 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 `<rootfs>/var/lib/depot/packages.db`, return
|
|
/// the `<rootfs>` portion. Otherwise return None.
|
|
fn detect_rootfs_from_db_path(db_path: &Path) -> Option<std::path::PathBuf> {
|
|
// 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_manifest: &staging::Manifest,
|
|
) -> Result<Vec<String>> {
|
|
let old_files = get_package_files(db_path, name)?;
|
|
let old_directories = get_package_directories(db_path, name)?;
|
|
let new_files: std::collections::HashSet<_> = new_manifest.files.iter().cloned().collect();
|
|
let new_directories: std::collections::HashSet<_> =
|
|
new_manifest.directories.iter().cloned().collect();
|
|
|
|
let mut remove_paths: Vec<String> = old_files
|
|
.into_iter()
|
|
.filter(|p| !new_files.contains(p))
|
|
.collect();
|
|
remove_paths.extend(
|
|
old_directories
|
|
.into_iter()
|
|
.filter(|p| !new_directories.contains(p)),
|
|
);
|
|
remove_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count()));
|
|
|
|
Ok(remove_paths)
|
|
}
|
|
|
|
/// Get set of all installed package names
|
|
pub fn get_installed_packages(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
|
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<String> = stmt
|
|
.query_map([], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
Ok(names)
|
|
}
|
|
|
|
/// List installed packages with version and revision metadata.
|
|
pub fn list_installed_package_records(db_path: &Path) -> Result<Vec<InstalledPackageRecord>> {
|
|
if !db_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT name, real_name, version, revision, abi_breaking, completed_at
|
|
FROM packages
|
|
ORDER BY name",
|
|
)?;
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok(InstalledPackageRecord {
|
|
name: row.get(0)?,
|
|
real_name: row.get(1)?,
|
|
version: row.get(2)?,
|
|
revision: row.get::<_, i64>(3)? as u32,
|
|
abi_breaking: row.get(4)?,
|
|
completed_at: row.get(5)?,
|
|
})
|
|
})?;
|
|
Ok(rows.filter_map(|row| row.ok()).collect())
|
|
}
|
|
|
|
/// Return the alternatives provided by an installed package.
|
|
pub fn get_package_provides(db_path: &Path, name: &str) -> Result<Vec<String>> {
|
|
if !db_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT pr.provides_name
|
|
FROM provides pr
|
|
JOIN packages p ON p.id = pr.package_id
|
|
WHERE p.name = ?1
|
|
ORDER BY pr.provides_name",
|
|
)?;
|
|
let rows = stmt.query_map(params![name], |row| row.get(0))?;
|
|
Ok(rows.filter_map(|row| row.ok()).collect())
|
|
}
|
|
|
|
/// Return the group memberships recorded for an installed package.
|
|
pub fn get_package_groups(db_path: &Path, name: &str) -> Result<Vec<String>> {
|
|
if !db_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT pg.group_name
|
|
FROM package_groups pg
|
|
JOIN packages p ON p.id = pg.package_id
|
|
WHERE p.name = ?1
|
|
ORDER BY pg.group_name",
|
|
)?;
|
|
let rows = stmt.query_map(params![name], |row| row.get(0))?;
|
|
Ok(rows.filter_map(|row| row.ok()).collect())
|
|
}
|
|
|
|
/// Return the installed package names that belong to an explicitly recorded group.
|
|
pub fn get_packages_in_installed_group(db_path: &Path, group: &str) -> Result<Vec<String>> {
|
|
if !db_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT p.name
|
|
FROM package_groups pg
|
|
JOIN packages p ON p.id = pg.package_id
|
|
WHERE lower(pg.group_name) = lower(?1)
|
|
ORDER BY p.name",
|
|
)?;
|
|
let rows = stmt.query_map(params![group], |row| row.get(0))?;
|
|
Ok(rows.filter_map(|row| row.ok()).collect())
|
|
}
|
|
|
|
/// Return true when the named group was explicitly installed by the user.
|
|
pub fn is_installed_group(db_path: &Path, group: &str) -> Result<bool> {
|
|
if !db_path.exists() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
let found = conn
|
|
.query_row(
|
|
"SELECT 1 FROM installed_groups WHERE lower(group_name) = lower(?1) LIMIT 1",
|
|
params![group],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.optional()?
|
|
.is_some();
|
|
Ok(found)
|
|
}
|
|
|
|
/// Record one or more explicit group installs.
|
|
pub fn record_installed_groups(db_path: &Path, groups: &[String]) -> Result<()> {
|
|
if groups.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
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()?;
|
|
for group in groups {
|
|
tx.execute(
|
|
"INSERT OR IGNORE INTO installed_groups (group_name) VALUES (?1)",
|
|
params![group],
|
|
)?;
|
|
}
|
|
tx.commit()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove an explicit installed-group marker.
|
|
pub fn remove_installed_group(db_path: &Path, group: &str) -> Result<()> {
|
|
if !db_path.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
conn.execute(
|
|
"DELETE FROM installed_groups WHERE lower(group_name) = lower(?1)",
|
|
params![group],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get set of all provided package names (alternatives)
|
|
pub fn get_all_provides(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
|
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<String> = stmt
|
|
.query_map([], |row| row.get(0))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
Ok(names)
|
|
}
|
|
|
|
/// Get set of all replacement names satisfied by installed packages.
|
|
pub fn get_all_replaces(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
|
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 replaces_name FROM replaces")?;
|
|
let names: HashSet<String> = 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<Option<String>> {
|
|
if !db_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
let version: Option<String> = 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<Option<String>> {
|
|
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(),
|
|
real_name: None,
|
|
version: version.into(),
|
|
revision: 1,
|
|
description: "d".into(),
|
|
homepage: "h".into(),
|
|
abi_breaking: false,
|
|
license: vec!["MIT".into()],
|
|
},
|
|
packages: Vec::new(),
|
|
alternatives: Alternatives {
|
|
provides: vec![format!("{}-virtual", name)],
|
|
conflicts: Vec::new(),
|
|
replaces: Vec::new(),
|
|
lib32: None,
|
|
},
|
|
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_uses_metadata_completed_at_when_present() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("packages.db");
|
|
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();
|
|
std::fs::write(
|
|
dest.join(".metadata.toml"),
|
|
"completed_at = \"2026-03-10T12:34:56Z\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
register_package(&db_path, &spec, &dest).unwrap();
|
|
|
|
let records = list_installed_package_records(&db_path).unwrap();
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0].completed_at, Some(1_773_146_096));
|
|
}
|
|
|
|
#[test]
|
|
fn register_package_falls_back_to_destdir_mtime_when_metadata_missing() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("packages.db");
|
|
let spec = mk_spec("foo", "1.0");
|
|
let dest = tmp.path().join("dest");
|
|
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
|
let file = dest.join("usr/bin/foo");
|
|
std::fs::write(&file, "bin").unwrap();
|
|
|
|
let ts = filetime::FileTime::from_unix_time(1_700_000_000, 0);
|
|
filetime::set_file_mtime(&file, ts).unwrap();
|
|
filetime::set_file_mtime(dest.join("usr"), ts).unwrap();
|
|
filetime::set_file_mtime(dest.join("usr/bin"), ts).unwrap();
|
|
filetime::set_file_mtime(&dest, ts).unwrap();
|
|
|
|
register_package(&db_path, &spec, &dest).unwrap();
|
|
|
|
let records = list_installed_package_records(&db_path).unwrap();
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0].completed_at, Some(1_700_000_000));
|
|
}
|
|
|
|
#[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 manifest = staging::Manifest {
|
|
files: vec!["usr/bin/foo".to_string()],
|
|
directories: Vec::new(),
|
|
};
|
|
|
|
let remove_paths = calculate_upgrade_paths(&db_path, "nonexistent", &manifest).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).unwrap();
|
|
|
|
assert_eq!(
|
|
remove_paths,
|
|
vec![
|
|
"usr/bin/shared_dir/old_file".to_string(),
|
|
"usr/bin/shared_dir".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()));
|
|
}
|
|
|
|
#[test]
|
|
fn register_package_persists_replacements() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("packages.db");
|
|
let dest = tmp.path().join("dest");
|
|
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
|
std::fs::write(dest.join("usr/bin/vx"), "vx").unwrap();
|
|
|
|
let mut spec = mk_spec("vx", "1.0");
|
|
spec.alternatives.replaces = vec!["grep".into(), "patch".into()];
|
|
|
|
register_package(&db_path, &spec, &dest).unwrap();
|
|
|
|
let replaces = get_all_replaces(&db_path).unwrap();
|
|
assert!(replaces.contains("grep"));
|
|
assert!(replaces.contains("patch"));
|
|
}
|
|
|
|
#[test]
|
|
fn register_package_persists_groups() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("packages.db");
|
|
let dest = tmp.path().join("dest");
|
|
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
|
std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap();
|
|
|
|
let mut spec = mk_spec("foo", "1.0");
|
|
spec.dependencies.groups = vec!["base".into(), "desktop".into()];
|
|
|
|
register_package(&db_path, &spec, &dest).unwrap();
|
|
|
|
assert_eq!(
|
|
get_package_groups(&db_path, "foo").unwrap(),
|
|
vec!["base".to_string(), "desktop".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_group_helpers_round_trip_membership() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("packages.db");
|
|
let dest = tmp.path().join("dest");
|
|
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
|
std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap();
|
|
|
|
let mut spec = mk_spec("foo", "1.0");
|
|
spec.dependencies.groups = vec!["base".into()];
|
|
register_package(&db_path, &spec, &dest).unwrap();
|
|
|
|
record_installed_groups(&db_path, &[String::from("base")]).unwrap();
|
|
assert!(is_installed_group(&db_path, "base").unwrap());
|
|
assert_eq!(
|
|
get_packages_in_installed_group(&db_path, "base").unwrap(),
|
|
vec!["foo".to_string()]
|
|
);
|
|
|
|
remove_installed_group(&db_path, "base").unwrap();
|
|
assert!(!is_installed_group(&db_path, "base").unwrap());
|
|
}
|
|
}
|