commit 7aaefb99572d8fcbfaf3271a450f004b62d4f435 Author: robojerk Date: Sat Sep 13 10:02:01 2025 -0700 Initial commit: apt-wrapper - Simple APT transaction wrapper - Core transaction API with add_package, resolve, commit, rollback - Version tracking for upgrades/downgrades - Simple package info with FFI conversion functions - Comprehensive error handling - Basic test suite - Clean, minimal implementation (~326 lines total) diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..61f9a2a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "apt-wrapper" +version = "0.1.0" +edition = "2021" +authors = ["apt-ostree team"] +description = "A simple DNF-like API wrapper around APT for apt-ostree" +license = "MIT" +repository = "https://github.com/apt-ostree/apt-wrapper" +keywords = ["apt", "package-management", "debian", "ubuntu"] +categories = ["os::linux-apis", "development-tools::build-utils"] + +# This is part of the apt-ostree workspace + +[dependencies] +anyhow = "1.0" +thiserror = "1.0" + +[dev-dependencies] +tempfile = "3.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..21325d3 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# APT Wrapper + +A simple DNF-like API wrapper around APT for porting rpm-ostree to apt-ostree. + +## Purpose + +This library provides a simple transaction interface that mimics DNF's imperative model, making it easier to adapt rpm-ostree code for Debian/Ubuntu systems. + +## Features + +- **Simple transaction interface**: `add_package()`, `resolve()`, `commit()`, `rollback()` +- **DNF-like API**: Easy to port from rpm-ostree +- **Version-based rollback**: Track versions and restore previous states +- **Minimal dependencies**: Only `anyhow` and `thiserror` +- **~250 lines total**: Focused and maintainable + +## Quick Start + +```rust +use apt_wrapper::{AptTransaction, init}; + +fn main() -> Result<(), Box> { + // Initialize + init()?; + + // Create transaction + let mut tx = AptTransaction::new()?; + + // Add packages + tx.add_package("vim")?; + tx.add_package("git")?; + + // Resolve dependencies + tx.resolve()?; + + // Commit transaction + tx.commit()?; + + // If something goes wrong, rollback + // tx.rollback()?; + + Ok(()) +} +``` + +## API + +### AptTransaction + +```rust +pub struct AptTransaction { + packages: Vec, +} + +impl AptTransaction { + pub fn new() -> Result; // Create new transaction + pub fn add_package(&mut self, name: &str) -> Result<()>; // Add package + pub fn resolve(&self) -> Result<()>; // Resolve dependencies + pub fn commit(&mut self) -> Result<()>; // Commit transaction + pub fn rollback(&self) -> Result<()>; // Rollback transaction + pub fn packages(&self) -> &[String]; // Get package list + pub fn changed_packages(&self) -> Vec; // Get changed packages + pub fn is_empty(&self) -> bool; // Check if empty +} +``` + +### Utility Functions + +```rust +pub fn init() -> Result<()>; // Initialize +pub fn search_packages(query: &str) -> Result>; // Search packages +pub fn is_package_installed(name: &str) -> Result; // Check installed +pub fn get_package_info(name: &str) -> Result; // Get package info +``` + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +apt-wrapper = "0.1.0" +``` + +## Usage + +### Basic Transaction + +```rust +use apt_wrapper::AptTransaction; + +let mut tx = AptTransaction::new()?; +tx.add_package("vim")?; +tx.add_package("git")?; +tx.resolve()?; +tx.commit()?; +``` + +### Search Packages + +```rust +use apt_wrapper::search_packages; + +let packages = search_packages("editor")?; +for package in packages { + println!("Found: {}", package); +} +``` + +### Check Installation + +```rust +use apt_wrapper::is_package_installed; + +if is_package_installed("vim")? { + println!("vim is installed"); +} +``` + +### Rollback Support + +```rust +use apt_wrapper::AptTransaction; + +let mut tx = AptTransaction::new()?; +tx.add_package("vim")?; +tx.add_package("git")?; +tx.resolve()?; + +// Commit the transaction +tx.commit()?; + +// If something goes wrong later, rollback +tx.rollback()?; + +// Check what was changed +println!("Changed packages: {:?}", tx.changed_packages()); +``` + +## Testing + +```bash +cargo test +``` + +## Examples + +```bash +cargo run --example simple_usage +``` + +## Design Philosophy + +This wrapper is designed to be: + +1. **Simple**: Minimal API surface, easy to understand +2. **Focused**: Only what's needed for apt-ostree porting +3. **DNF-like**: Familiar interface for rpm-ostree developers +4. **Minimal**: ~200 lines total, no complex abstractions + +## Differences from DNF + +- **APT is declarative**: Dependencies are resolved automatically +- **No complex repo management**: APT uses simple text files +- **Simpler error handling**: APT provides clear error messages +- **No transaction rollback**: APT doesn't have built-in rollback + +## OSTree Integration + +For atomic operations, use OSTree's native checkpoint/rollback: + +```rust +// 1. Create OSTree checkpoint +let checkpoint = ostree_create_checkpoint()?; + +// 2. Run APT transaction +let mut tx = AptTransaction::new()?; +tx.add_package("vim")?; +tx.commit()?; + +// 3. Commit or rollback based on result +if success { + ostree_commit_changes()?; +} else { + ostree_rollback_to_checkpoint(checkpoint)?; +} +``` + +## License + +MIT License - see [LICENSE](LICENSE) file for details. + +## Contributing + +This is a focused tool for apt-ostree. Contributions should maintain simplicity and focus on the core use case. diff --git a/examples/simple_usage.rs b/examples/simple_usage.rs new file mode 100644 index 0000000..01eed14 --- /dev/null +++ b/examples/simple_usage.rs @@ -0,0 +1,45 @@ +//! Simple usage example for APT wrapper + +use apt_wrapper::{AptTransaction, init, search_packages}; + +fn main() -> Result<(), Box> { + println!("=== Simple APT Wrapper Example ==="); + + // Initialize + init()?; + println!("✓ APT wrapper initialized"); + + // Search for packages + let packages = search_packages("vim")?; + println!("Found {} packages matching 'vim'", packages.len()); + + // Create transaction + let mut tx = AptTransaction::new()?; + println!("✓ Created transaction"); + + // Add packages + tx.add_package("apt")?; + tx.add_package("curl")?; + println!("✓ Added packages to transaction"); + + // Resolve dependencies + tx.resolve()?; + println!("✓ Dependencies resolved"); + + // Show what would be installed + println!("Packages in transaction: {:?}", tx.packages()); + + // Note: We don't actually commit in the example to avoid installing packages + // tx.commit()?; + // println!("✓ Transaction committed"); + + // If commit failed, you could rollback: + // tx.rollback()?; + // println!("✓ Transaction rolled back"); + // println!("Changed packages: {:?}", tx.changed_packages()); + + println!("✓ Transaction ready (not committed in example)"); + + println!("=== Example completed ==="); + Ok(()) +} diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..8358be1 --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,47 @@ +use cxx::bridge; + +#[bridge] +mod ffi { + /// C++ side representation of AptPackage + extern "C++" { + include!("apt-wrapper/bridge.h"); + + type AptPackage; + + /// Get package name + fn name(self: &AptPackage) -> &str; + + /// Get package version + fn version(self: &AptPackage) -> &str; + + /// Get package description + fn description(self: &AptPackage) -> &str; + + /// Check if package is installed + fn is_installed(self: &AptPackage) -> bool; + } + + /// Rust side AptPackage + extern "Rust" { + type AptPackage; + + /// Create AptPackage from name, version, description, and installed status + fn new_ffi(name: String, version: String, description: String, installed: bool) -> AptPackage; + + /// Get package name + fn name_ffi(self: &AptPackage) -> &str; + + /// Get package version + fn version_ffi(self: &AptPackage) -> &str; + + /// Get package description + fn description_ffi(self: &AptPackage) -> &str; + + /// Check if package is installed + fn is_installed_ffi(self: &AptPackage) -> bool; + } +} + +pub use ffi::AptPackage as FFIAptPackage; + + diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..258e6c2 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,25 @@ +//! Simple error types for APT wrapper + +use thiserror::Error; + +/// APT wrapper error types +#[derive(Error, Debug)] +pub enum AptError { + #[error("Package not found: {0}")] + PackageNotFound(String), + + #[error("APT command failed: {0}")] + AptCommandFailed(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("UTF-8 error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), + + #[error("Generic error: {0}")] + Other(String), +} + +/// Result type alias +pub type AptResult = Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4de02a1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,71 @@ +//! Simple APT wrapper for apt-ostree +//! +//! Provides a DNF-like transaction interface around APT for porting rpm-ostree to apt-ostree. + +use std::process::Command; +use anyhow::{Result, anyhow}; + +pub mod transaction; +pub mod package; +pub mod error; + +pub use transaction::AptTransaction; +pub use package::AptPackage; +pub use error::{AptError, AptResult}; + +/// Initialize the APT wrapper system +pub fn init() -> Result<()> { + // Simple initialization - just verify APT is available + let output = Command::new("apt") + .arg("--version") + .output() + .map_err(|_| anyhow!("APT not found in PATH"))?; + + if !output.status.success() { + return Err(anyhow!("APT not working properly")); + } + + Ok(()) +} + +/// Simple package search using APT +pub fn search_packages(query: &str) -> Result> { + let output = Command::new("apt") + .args(&["search", "--names-only", query]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Package search failed")); + } + + let packages: Vec = String::from_utf8(output.stdout)? + .lines() + .filter(|line| !line.is_empty() && !line.starts_with("Sorting")) + .map(|line| line.split('/').next().unwrap_or(line).to_string()) + .collect(); + + Ok(packages) +} + +/// Check if a package is installed +pub fn is_package_installed(name: &str) -> Result { + let output = Command::new("dpkg") + .args(&["-l", name]) + .output()?; + + Ok(output.status.success()) +} + +/// Get package information +pub fn get_package_info(name: &str) -> Result { + let output = Command::new("apt") + .args(&["show", name]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Package not found: {}", name)); + } + + let info = String::from_utf8(output.stdout)?; + AptPackage::from_apt_show(&info) +} \ No newline at end of file diff --git a/src/package.rs b/src/package.rs new file mode 100644 index 0000000..cb64335 --- /dev/null +++ b/src/package.rs @@ -0,0 +1,69 @@ +//! Simple package information structure + +use anyhow::{Result, anyhow}; + +/// Simple package information +#[derive(Debug, Clone)] +pub struct AptPackage { + pub name: String, + pub version: String, + pub description: String, + pub installed: bool, +} + +impl AptPackage { + /// Create from apt show output + pub fn from_apt_show(output: &str) -> Result { + let mut name = String::new(); + let mut version = String::new(); + let mut description = String::new(); + + for line in output.lines() { + if line.starts_with("Package: ") { + name = line.strip_prefix("Package: ").unwrap_or("").to_string(); + } else if line.starts_with("Version: ") { + version = line.strip_prefix("Version: ").unwrap_or("").to_string(); + } else if line.starts_with("Description: ") { + description = line.strip_prefix("Description: ").unwrap_or("").to_string(); + } + } + + if name.is_empty() { + return Err(anyhow!("Invalid package information")); + } + + Ok(Self { + name, + version, + description, + installed: false, // Will be set separately + }) + } + + /// Check if package is installed + pub fn is_installed(&self) -> bool { + self.installed + } + + /// Convert to C-compatible struct for FFI + /// This provides the data needed for cxx::bridge without complex trait implementations + pub fn to_ffi_data(&self) -> (String, String, String, bool) { + ( + self.name.clone(), + self.version.clone(), + self.description.clone(), + self.installed, + ) + } + + /// Create from C-compatible data + pub fn from_ffi_data(name: String, version: String, description: String, installed: bool) -> Self { + Self { + name, + version, + description, + installed, + } + } +} + diff --git a/src/transaction.rs b/src/transaction.rs new file mode 100644 index 0000000..2755c24 --- /dev/null +++ b/src/transaction.rs @@ -0,0 +1,184 @@ +//! Simple APT transaction implementation +//! +//! Provides a DNF-like transaction interface for APT operations. + +use std::process::Command; +use anyhow::{Result, anyhow}; +use std::collections::HashMap; + +/// Simple APT transaction that mimics DNF's imperative model +pub struct AptTransaction { + packages: Vec, + before_versions: HashMap, // package -> version before + after_versions: HashMap, // package -> version after +} + +impl AptTransaction { + /// Create a new transaction + pub fn new() -> Result { + Ok(Self { + packages: Vec::new(), + before_versions: HashMap::new(), + after_versions: HashMap::new(), + }) + } + + /// Add a package to the transaction + pub fn add_package(&mut self, name: &str) -> Result<()> { + // Verify package exists + let output = Command::new("apt") + .args(&["show", name]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Package not found: {}", name)); + } + + self.packages.push(name.to_string()); + Ok(()) + } + + /// Resolve dependencies (APT handles this automatically) + pub fn resolve(&self) -> Result<()> { + // APT handles dependency resolution automatically + // Just validate all packages are available + for package in &self.packages { + let output = Command::new("apt") + .args(&["show", package]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Package unavailable: {}", package)); + } + } + Ok(()) + } + + /// Commit the transaction + pub fn commit(&mut self) -> Result<()> { + if self.packages.is_empty() { + return Ok(()); + } + + // Get current versions before installation + self.before_versions = self.get_package_versions()?; + + // Run apt install with all packages + let output = Command::new("apt") + .args(&["install", "-y"]) + .args(&self.packages) + .output()?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("APT installation failed: {}", error)); + } + + // Get versions after installation + self.after_versions = self.get_package_versions()?; + + Ok(()) + } + + /// Rollback the transaction by restoring previous versions + pub fn rollback(&self) -> Result<()> { + if self.before_versions.is_empty() { + return Ok(()); + } + + // Build list of packages to restore to previous versions + let mut packages_to_restore = Vec::new(); + + for (package, before_version) in &self.before_versions { + if let Some(after_version) = self.after_versions.get(package) { + // Only rollback if version changed + if before_version != after_version { + packages_to_restore.push(format!("{}={}", package, before_version)); + } + } else { + // Package was newly installed, remove it + packages_to_restore.push(format!("{}", package)); + } + } + + if packages_to_restore.is_empty() { + return Ok(()); + } + + // Restore previous versions or remove newly installed packages + let output = Command::new("apt") + .args(&["install", "-y"]) + .args(&packages_to_restore) + .output()?; + + if !output.status.success() { + let error = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("APT rollback failed: {}", error)); + } + + Ok(()) + } + + /// Get current package versions + fn get_package_versions(&self) -> Result> { + let output = Command::new("dpkg") + .args(&["-l"]) + .output()?; + + if !output.status.success() { + return Err(anyhow!("Failed to get package versions")); + } + + let mut versions = HashMap::new(); + let output_str = String::from_utf8_lossy(&output.stdout); + + for line in output_str.lines() { + if line.starts_with("ii") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + let package_name = parts[1]; + let version = parts[2]; + versions.insert(package_name.to_string(), version.to_string()); + } + } + } + + Ok(versions) + } + + /// Get list of packages in transaction + pub fn packages(&self) -> &[String] { + &self.packages + } + + /// Check if transaction is empty + pub fn is_empty(&self) -> bool { + self.packages.is_empty() + } + + /// Get list of packages that were changed by this transaction + pub fn changed_packages(&self) -> Vec { + let mut changed = Vec::new(); + + for (package, before_version) in &self.before_versions { + if let Some(after_version) = self.after_versions.get(package) { + if before_version != after_version { + changed.push(format!("{}: {} -> {}", package, before_version, after_version)); + } + } else { + changed.push(format!("{}: removed", package)); + } + } + + // Add newly installed packages + for package in &self.packages { + if !self.before_versions.contains_key(package) { + if let Some(after_version) = self.after_versions.get(package) { + changed.push(format!("{}: installed {}", package, after_version)); + } + } + } + + changed + } +} diff --git a/tests/basic.rs b/tests/basic.rs new file mode 100644 index 0000000..25aa987 --- /dev/null +++ b/tests/basic.rs @@ -0,0 +1,56 @@ +//! Basic tests for APT wrapper + +use apt_wrapper::{AptTransaction, init, search_packages}; + +#[test] +fn test_transaction_creation() { + let tx = AptTransaction::new().unwrap(); + assert!(tx.is_empty()); + assert_eq!(tx.packages().len(), 0); +} + +#[test] +fn test_transaction_add_package() { + let mut tx = AptTransaction::new().unwrap(); + + // Try to add a common package (should exist) + let result = tx.add_package("apt"); + assert!(result.is_ok()); + assert_eq!(tx.packages().len(), 1); + assert_eq!(tx.packages()[0], "apt"); +} + +#[test] +fn test_transaction_add_nonexistent_package() { + let mut tx = AptTransaction::new().unwrap(); + + // Try to add a package that definitely doesn't exist + let result = tx.add_package("this-package-definitely-does-not-exist-12345"); + assert!(result.is_err()); + assert!(tx.is_empty()); +} + +#[test] +fn test_init() { + // This will fail if APT is not available + let result = init(); + // We can't assert success since APT might not be available in test environment + // Just ensure it doesn't panic + let _ = result; +} + +#[test] +fn test_transaction_rollback_tracking() { + let mut tx = AptTransaction::new().unwrap(); + + // Add a package + let result = tx.add_package("apt"); + assert!(result.is_ok()); + + // Check that changed_packages is initially empty + assert!(tx.changed_packages().is_empty()); + + // Note: We don't actually commit in tests to avoid installing packages + // In real usage, after commit(), changed_packages would contain + // the packages that were changed (installed/upgraded) +} diff --git a/tests/unit/test_basic.rs b/tests/unit/test_basic.rs new file mode 100644 index 0000000..8576f62 --- /dev/null +++ b/tests/unit/test_basic.rs @@ -0,0 +1,50 @@ +//! Unit tests for basic functionality + +use apt_wrapper::{AptTransaction, AptPackage, AptRepository, PackageDatabase}; + +#[test] +fn test_transaction_creation() { + let transaction = AptTransaction::new().unwrap(); + assert!(transaction.is_empty()); + assert_eq!(transaction.packages().len(), 0); +} + +#[test] +fn test_transaction_add_package() { + let mut transaction = AptTransaction::new().unwrap(); + transaction.add_package("vim").unwrap(); + assert!(!transaction.is_empty()); + assert_eq!(transaction.packages().len(), 1); + assert_eq!(transaction.packages()[0], "vim"); +} + +#[test] +fn test_transaction_resolve() { + let mut transaction = AptTransaction::new().unwrap(); + transaction.add_package("vim").unwrap(); + transaction.resolve().unwrap(); + // Should not panic +} + +#[test] +fn test_package_creation() { + let package = AptPackage::new("vim".to_string(), "2:8.2.2434-3+deb11u1".to_string()); + assert_eq!(package.name(), "vim"); + assert_eq!(package.version(), "2:8.2.2434-3+deb11u1"); +} + +#[test] +fn test_repository_creation() { + let repo = AptRepository::new( + "debian".to_string(), + "http://deb.debian.org/debian".to_string(), + ); + assert_eq!(repo.name(), "debian"); + assert_eq!(repo.url(), "http://deb.debian.org/debian"); +} + +#[test] +fn test_package_database_creation() { + let db = PackageDatabase::new().unwrap(); + assert!(!db.is_stale()); +}