From 1f2228ef6bbb1e64ec67acd975ec43345a720330 Mon Sep 17 00:00:00 2001 From: xscriptor Date: Sun, 21 Jun 2026 15:31:14 +0000 Subject: [PATCH] update core & minimal update on main --- .github/workflows/ci.yml | 50 ++++++++++++ README.md | 36 ++++++++- crates/xpm-core/src/hooks.rs | 61 +++++++++++---- crates/xpm-core/src/lib.rs | 4 +- crates/xpm-core/src/repo_db.rs | 61 +++++++-------- crates/xpm-core/src/repo_sync.rs | 44 +++++------ crates/xpm-core/src/signing.rs | 11 +-- crates/xpm-core/src/transaction.rs | 56 ++++++-------- crates/xpm/src/main.rs | 104 +++++++++++++------------ docs/INTEGRATION.md | 119 +++++++++++++++++++++++++++++ 10 files changed, 381 insertions(+), 165 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/INTEGRATION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3de306a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --workspace --all-targets + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check diff --git a/README.md b/README.md index 5ff5a84..dc9ff3d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ License Rust Version + CI Status

@@ -31,6 +32,7 @@
  • Package Format
  • Security
  • Repository Hosting
  • +
  • Relationship with xpkg
  • Roadmap
  • License
  • Command Cheatsheet
  • @@ -39,7 +41,7 @@

    Overview

    -

    xpm is a native Rust replacement for pacman and libalpm, designed for the X distribution. It uses the .xp package format (X Package) natively and maintains compatibility with Arch Linux .pkg.tar.zst packages.

    +

    xpm is a native Rust replacement for pacman and libalpm, designed for the X distribution. It uses the .xp package format (X Package) natively and maintains compatibility with Arch Linux .pkg.tar.zst packages. Packages are built with xpkg, the companion builder tool.

    Key Features

    @@ -388,7 +390,7 @@ sudo xpm install xpkg
  • .PKGINFO - package name, version, dependencies
  • .BUILDINFO - reproducible build environment
  • .MTREE - file integrity hashes
  • -
  • .INSTALL - optional pre/post install scripts
  • +
  • .INSTALL - optional pre/post install, upgrade, and remove scripts (executed by xpm during transactions)
  • Security

    @@ -404,6 +406,36 @@ sudo xpm install xpkg

    The default package repository is hosted on GitHub Pages at xscriptor.github.io/x-repo. This will migrate to the xscriptor organization for consistency as the project grows. xpm supports any HTTP-based static file server, making future migration to a VPS transparent.

    +

    Relationship with xpkg

    + +

    xpm and xpkg are complementary tools in the X ecosystem. They share the same package format and metadata structures but are independent binaries.

    + + + + + + + + + + + + + + + + + + + + + +
    ToolRoleAnalogy
    xpmPackage manager — install, remove, upgrade, resolve depspacman
    xpkgPackage builder — compile, package, lint, manage reposmakepkg + repo-add + namcap
    + +

    xpkg produces .xp packages that xpm installs. During installation, xpm executes any .INSTALL scriptlets generated by xpkg (post_install, pre_upgrade, etc.) and verifies signatures created during the build process.

    + +

    See the Integration Guide for detailed information about how the tools work together.

    +

    Roadmap

    See ROADMAP.md for the full development roadmap.

    diff --git a/crates/xpm-core/src/hooks.rs b/crates/xpm-core/src/hooks.rs index ed9a928..7970cb1 100644 --- a/crates/xpm-core/src/hooks.rs +++ b/crates/xpm-core/src/hooks.rs @@ -78,9 +78,10 @@ impl Hook for FileExtractionHook { // Check magic bytes if n >= 4 && magic[..4] == [0x28, 0xB5, 0x2F, 0xFD] { // Zstd - Box::new(Decoder::new(buf).map_err(|e| { - XpmError::Package(format!("failed to decode zstd: {}", e)) - })?) + Box::new( + Decoder::new(buf) + .map_err(|e| XpmError::Package(format!("failed to decode zstd: {}", e)))?, + ) } else if n >= 2 && magic[..2] == [0x1F, 0x8B] { // Gzip Box::new(GzDecoder::new(buf)) @@ -166,12 +167,43 @@ impl Hook for FileExtractionHook { } } -/// Execute native package scriptlets from `.INSTALL` files. -pub struct ScriptletHook; +/// Execute pre-operation package scriptlets from `.INSTALL` files. +/// +/// Runs before file changes: `pre_install` (install), `pre_upgrade` (upgrade), `pre_remove` (remove). +pub struct PreScriptletHook; + +impl Hook for PreScriptletHook { + fn name(&self) -> &str { + "pre-scriptlet" + } + + fn run(&self, context: &HookContext) -> XpmResult<()> { + let Some(script_data) = load_install_script(context)? else { + return Ok(()); + }; + + let functions: &[&str] = match context.operation_type { + OperationType::Install => &["pre_install"], + OperationType::Upgrade => &["pre_upgrade"], + OperationType::Remove => &["pre_remove"], + }; + + if functions.is_empty() { + return Ok(()); + } + + run_scriptlet_functions(context, &script_data, functions) + } +} + +/// Execute post-operation package scriptlets from `.INSTALL` files. +/// +/// Runs after file changes: `post_install` (install), `post_upgrade` (upgrade), `post_remove` (remove). +pub struct PostScriptletHook; -impl Hook for ScriptletHook { +impl Hook for PostScriptletHook { fn name(&self) -> &str { - "scriptlet" + "post-scriptlet" } fn run(&self, context: &HookContext) -> XpmResult<()> { @@ -181,8 +213,8 @@ impl Hook for ScriptletHook { let functions: &[&str] = match context.operation_type { OperationType::Install => &["post_install"], - OperationType::Upgrade => &["post_upgrade", "post_install"], - OperationType::Remove => &[], + OperationType::Upgrade => &["post_upgrade"], + OperationType::Remove => &["post_remove"], }; if functions.is_empty() { @@ -553,11 +585,12 @@ impl HookChain { impl Default for HookChain { fn default() -> Self { let mut chain = HookChain::new(); - // Add default hooks in order + // Hook order: pre-scriptlet → file extraction → file removal → post-scriptlet → local db chain.add_hook(Box::new(MetadataLoadHook)); + chain.add_hook(Box::new(PreScriptletHook)); chain.add_hook(Box::new(FileExtractionHook)); - chain.add_hook(Box::new(ScriptletHook)); chain.add_hook(Box::new(FileRemovalHook)); + chain.add_hook(Box::new(PostScriptletHook)); chain.add_hook(Box::new(LocalDbHook)); chain } @@ -589,13 +622,13 @@ mod tests { fn hook_chain_default_has_hooks() { let chain = HookChain::default(); assert!(!chain.hooks().is_empty()); - assert_eq!(chain.hooks().len(), 5); // metadata, extraction, scriptlet, removal, localdb + assert_eq!(chain.hooks().len(), 6); // metadata, pre-scriptlet, extraction, removal, post-scriptlet, localdb } #[test] - fn scriptlet_hook_runs_post_install() { + fn post_scriptlet_hook_runs_post_install() { let (root, db_tmp, mut ctx) = test_context(OperationType::Install); - let hook = ScriptletHook; + let hook = PostScriptletHook; ctx.pkg_file = None; let pkg_dir = db_tmp.path().join(&ctx.pkg_name); diff --git a/crates/xpm-core/src/lib.rs b/crates/xpm-core/src/lib.rs index 568a4cf..8a7b490 100644 --- a/crates/xpm-core/src/lib.rs +++ b/crates/xpm-core/src/lib.rs @@ -17,5 +17,5 @@ pub mod transaction; // Re-export key types for convenience. pub use config::XpmConfig; pub use error::{XpmError, XpmResult}; -pub use transaction::{Transaction, TransactionOp, TransactionState, FileLock}; -pub use hooks::{Hook, HookChain, HookContext, OperationType}; +pub use hooks::{Hook, HookChain, HookContext, OperationType, PostScriptletHook, PreScriptletHook}; +pub use transaction::{FileLock, Transaction, TransactionOp, TransactionState}; diff --git a/crates/xpm-core/src/repo_db.rs b/crates/xpm-core/src/repo_db.rs index 891ee5a..7868c4a 100644 --- a/crates/xpm-core/src/repo_db.rs +++ b/crates/xpm-core/src/repo_db.rs @@ -294,10 +294,7 @@ mod tests { assert_eq!(hello.version, "1.0-1"); assert_eq!(hello.description.as_deref(), Some("hello package")); assert_eq!(hello.arch.as_deref(), Some("x86_64")); - assert_eq!( - hello.filename.as_deref(), - Some("hello-1.0-1-x86_64.xp") - ); + assert_eq!(hello.filename.as_deref(), Some("hello-1.0-1-x86_64.xp")); assert_eq!(hello.sha256sum.as_deref(), Some("abc123")); assert_eq!( hello.url.as_deref(), @@ -382,7 +379,10 @@ mod tests { let linux = &db.entries[0]; assert_eq!(linux.name, "linux"); assert_eq!(linux.version, "6.1.0-1"); - assert_eq!(linux.description.as_deref(), Some("The Linux kernel and modules")); + assert_eq!( + linux.description.as_deref(), + Some("The Linux kernel and modules") + ); assert_eq!(linux.arch.as_deref(), Some("x86_64")); // These fields should be None for standard Arch .db assert!(linux.filename.is_none()); @@ -417,10 +417,7 @@ mod tests { assert_eq!(xpkg.name, "xpkg"); assert_eq!(xpkg.version, "0.1.0-1"); // Extended fields should be present - assert_eq!( - xpkg.filename.as_deref(), - Some("xpkg-0.1.0-1-x86_64.xp") - ); + assert_eq!(xpkg.filename.as_deref(), Some("xpkg-0.1.0-1-x86_64.xp")); assert_eq!( xpkg.sha256sum.as_deref(), Some("deadbeefcafebabe0000000000000000deadbeefcafebabe0000000000000000") @@ -465,10 +462,7 @@ mod tests { fn parse_minimal_valid_db() { // Minimum required fields: NAME, VERSION. // This validates parser robustness for minimal entries. - let bytes = make_gzip_tar(&[( - "tiny-1.0-1/desc", - "%NAME%\ntiny\n\n%VERSION%\n1.0-1\n", - )]); + let bytes = make_gzip_tar(&[("tiny-1.0-1/desc", "%NAME%\ntiny\n\n%VERSION%\n1.0-1\n")]); let db = parse_sync_db_bytes(&bytes, "test").expect("parse minimal db"); assert_eq!(db.entries.len(), 1); @@ -497,7 +491,10 @@ mod tests { entries.push((format!("{}/desc", version), desc)); } - let entry_refs: Vec<_> = entries.iter().map(|(p, c)| (p.as_str(), c.as_str())).collect(); + let entry_refs: Vec<_> = entries + .iter() + .map(|(p, c)| (p.as_str(), c.as_str())) + .collect(); let bytes = make_gzip_tar(&entry_refs); let db = parse_sync_db_bytes(&bytes, "large").expect("parse large repo"); @@ -507,16 +504,13 @@ mod tests { assert_eq!(names.len(), 50, "All package names should be unique"); } - #[test] fn parse_db_with_optional_arch_field() { // Some packages may omit ARCH; should default or be None. - let bytes = make_gzip_tar(&[ - ( - "noarch-1.0-1/desc", - "%NAME%\nnoarch\n\n%VERSION%\n1.0-1\n\n%DESC%\nNo architecture specified\n", - ), - ]); + let bytes = make_gzip_tar(&[( + "noarch-1.0-1/desc", + "%NAME%\nnoarch\n\n%VERSION%\n1.0-1\n\n%DESC%\nNo architecture specified\n", + )]); let db = parse_sync_db_bytes(&bytes, "extra").expect("parse db without arch"); assert_eq!(db.entries[0].arch, None); // Our parser doesn't force a default @@ -537,10 +531,7 @@ mod tests { ]); let files_bytes = make_gzip_tar(&[ - ( - "bin-1.0-1/files", - "%FILES%\nusr/bin/\nusr/bin/tool\n", - ), + ("bin-1.0-1/files", "%FILES%\nusr/bin/\nusr/bin/tool\n"), ( "lib-1.0-1/files", "%FILES%\nusr/lib/\nusr/lib64/\nusr/lib/libfoo.so\n", @@ -561,20 +552,25 @@ mod tests { #[test] fn real_db_file_from_disk() { // Test parsing a real .db file if it exists in the workspace. - let real_db_path = Path::new("/home/xscriptor/Documents/repos/xpkgrepos/x-repo/public/repo/x86_64/x.db.tar.gz"); - + let real_db_path = Path::new( + "/home/xscriptor/Documents/repos/xpkgrepos/x-repo/public/repo/x86_64/x.db.tar.gz", + ); + if real_db_path.exists() { let bytes = fs::read(real_db_path).expect("read real x.db"); let db = parse_sync_db_bytes(&bytes, "x").expect("parse real x.db"); - + // Validate structure - assert!(!db.entries.is_empty(), "x.db should contain at least one entry"); + assert!( + !db.entries.is_empty(), + "x.db should contain at least one entry" + ); for entry in &db.entries { // Every entry must have name and version assert!(!entry.name.is_empty(), "entry name must not be empty"); assert!(!entry.version.is_empty(), "entry version must not be empty"); } - + // Check for expected xfetch entry if present if let Some(xfetch) = db.entries.iter().find(|e| e.name == "xfetch") { assert_eq!(xfetch.version, "0.1.0-1"); @@ -589,10 +585,7 @@ mod tests { fn parse_db_with_version_variants() { // Test parsing versions in different formats (semantic, pre-release, etc). let bytes = make_gzip_tar(&[ - ( - "pkg1-1.0.0-1/desc", - "%NAME%\npkg1\n\n%VERSION%\n1.0.0-1\n", - ), + ("pkg1-1.0.0-1/desc", "%NAME%\npkg1\n\n%VERSION%\n1.0.0-1\n"), ( "pkg2-2.5beta-2/desc", "%NAME%\npkg2\n\n%VERSION%\n2.5beta-2\n", diff --git a/crates/xpm-core/src/repo_sync.rs b/crates/xpm-core/src/repo_sync.rs index c652bff..811728a 100644 --- a/crates/xpm-core/src/repo_sync.rs +++ b/crates/xpm-core/src/repo_sync.rs @@ -196,7 +196,11 @@ fn signature_cache_path(artifact_path: &Path) -> PathBuf { /// 2) If `%URL%` is a direct `.xp` URL, use it as-is /// 3) If `%URL%` points to a GitHub repo, derive release URL: /// `/releases/download/-/` -pub fn package_download_candidates(repo: &Repository, arch: &str, entry: &RepoEntry) -> Vec { +pub fn package_download_candidates( + repo: &Repository, + arch: &str, + entry: &RepoEntry, +) -> Vec { let mut candidates = Vec::new(); let Some(filename) = entry.filename.as_deref() else { @@ -216,9 +220,7 @@ pub fn package_download_candidates(repo: &Repository, arch: &str, entry: &RepoEn } else if clean.starts_with("https://github.com/") { let repo_url = clean.trim_end_matches('/'); let tag = format!("{}-{}", entry.name, entry.version); - candidates.push(format!( - "{repo_url}/releases/download/{tag}/{filename}" - )); + candidates.push(format!("{repo_url}/releases/download/{tag}/{filename}")); } } @@ -236,9 +238,7 @@ pub fn download_first_available(urls: &[String], dest: &Path, retries: u32) -> X } } - Err(last_error.unwrap_or_else(|| { - XpmError::Database("no usable package URL found".to_string()) - })) + Err(last_error.unwrap_or_else(|| XpmError::Database("no usable package URL found".to_string()))) } /// Verify SHA-256 for a file if checksum metadata is available. @@ -323,12 +323,13 @@ fn download_once(url: &str, dest: &Path) -> XpmResult<()> { downloaded += n as u64; if let Some(total) = total { - if total > 0 { - let percent = downloaded.saturating_mul(100) / total; - if percent >= next_report { - tracing::debug!(url, downloaded, total, percent, "download progress"); - next_report = (next_report + 25).min(100); - } + let percent = total + .checked_div(1) + .map(|_| downloaded.saturating_mul(100) / total) + .unwrap_or(0); + if percent >= next_report { + tracing::debug!(url, downloaded, total, percent, "download progress"); + next_report = (next_report + 25).min(100); } } } @@ -399,15 +400,8 @@ mod tests { let keyring = temp.path().join("trustedkeys.gpg"); std::fs::write(&keyring, b"not-a-real-keyring").expect("write keyring placeholder"); - let result = sync_repo_databases( - &repo, - "x86_64", - &sync, - 2, - SigLevel::Never, - &keyring, - ) - .expect("sync repo"); + let result = sync_repo_databases(&repo, "x86_64", &sync, 2, SigLevel::Never, &keyring) + .expect("sync repo"); assert!(result.db_downloaded); assert!(result.files_downloaded); assert!(sync.join("core.db").exists()); @@ -479,7 +473,8 @@ mod tests { let keyring = temp.path().join("trustedkeys.gpg"); let mut keyring_file = fs::File::create(&keyring).expect("create keyring"); - cert.serialize(&mut keyring_file).expect("write keyring cert"); + cert.serialize(&mut keyring_file) + .expect("write keyring cert"); let repo = Repository { name: "core".to_string(), @@ -518,7 +513,8 @@ mod tests { let keyring = temp.path().join("trustedkeys.gpg"); let mut keyring_file = fs::File::create(&keyring).expect("create keyring"); - cert.serialize(&mut keyring_file).expect("write keyring cert"); + cert.serialize(&mut keyring_file) + .expect("write keyring cert"); let sig_url = format!("file://{}", sig_path.display()); let err = verify_remote_signature(&package_dest, &sig_url, SigLevel::Required, &keyring, 2) diff --git a/crates/xpm-core/src/signing.rs b/crates/xpm-core/src/signing.rs index 6ab71f9..c398dd0 100644 --- a/crates/xpm-core/src/signing.rs +++ b/crates/xpm-core/src/signing.rs @@ -21,15 +21,12 @@ pub fn load_keyring(path: &Path) -> XpmResult> { let data = std::fs::read(path) .map_err(|e| XpmError::SignatureError(format!("read keyring {}: {e}", path.display())))?; - let parser = CertParser::from_bytes(&data).map_err(|e| { - XpmError::SignatureError(format!("parse keyring {}: {e}", path.display())) - })?; + let parser = CertParser::from_bytes(&data) + .map_err(|e| XpmError::SignatureError(format!("parse keyring {}: {e}", path.display())))?; let mut certs = Vec::new(); for cert in parser { - certs.push( - cert.map_err(|e| XpmError::SignatureError(format!("keyring entry: {e}")))?, - ); + certs.push(cert.map_err(|e| XpmError::SignatureError(format!("keyring entry: {e}")))?); } Ok(certs) @@ -119,4 +116,4 @@ impl VerificationHelper for VHelper { None => Err(anyhow::anyhow!("no verification result")), } } -} \ No newline at end of file +} diff --git a/crates/xpm-core/src/transaction.rs b/crates/xpm-core/src/transaction.rs index b59bfa5..5c38d1e 100644 --- a/crates/xpm-core/src/transaction.rs +++ b/crates/xpm-core/src/transaction.rs @@ -57,7 +57,7 @@ pub enum TransactionOp { pkg_name: String, pkg_version: String, pkg_file: PathBuf, - metadata: Option, + metadata: Box>, }, /// Remove an installed package. Remove { pkg_name: String }, @@ -148,7 +148,7 @@ impl Transaction { pkg_name, pkg_version, pkg_file, - metadata: None, + metadata: Box::new(None), }); Ok(()) @@ -323,7 +323,12 @@ impl Transaction { } /// Upgrade a single package (internal - called by commit). - fn execute_upgrade(&self, pkg_name: &str, new_version: &str, new_pkg_file: &Path) -> XpmResult<()> { + fn execute_upgrade( + &self, + pkg_name: &str, + new_version: &str, + new_pkg_file: &Path, + ) -> XpmResult<()> { // Remove old version self.execute_remove(pkg_name)?; @@ -461,7 +466,10 @@ mod tests { assert!(log_path.exists(), "log file should exist"); let content = fs::read_to_string(&log_path).expect("read log"); - assert!(content.contains("test message"), "log should contain message"); + assert!( + content.contains("test message"), + "log should contain message" + ); } #[test] @@ -569,20 +577,15 @@ mod tests { let pkg_file = make_test_xp_package(&cache_dir); // Create transaction - let mut tx = Transaction::new(root.clone(), local_db.clone()) - .expect("create transaction"); + let mut tx = Transaction::new(root.clone(), local_db.clone()).expect("create transaction"); // Setup hooks let hooks = HookChain::default(); tx.set_hooks(hooks); // Add install operation - tx.add_install( - "test".to_string(), - "1.0.0-1".to_string(), - pkg_file.clone(), - ) - .expect("add install"); + tx.add_install("test".to_string(), "1.0.0-1".to_string(), pkg_file.clone()) + .expect("add install"); // Prepare transaction tx.prepare().expect("prepare"); @@ -616,17 +619,12 @@ mod tests { // First install a package let pkg_file = make_test_xp_package(&cache_dir); - let mut tx = Transaction::new(root.clone(), local_db.clone()) - .expect("create transaction"); + let mut tx = Transaction::new(root.clone(), local_db.clone()).expect("create transaction"); let hooks = HookChain::default(); tx.set_hooks(hooks); - tx.add_install( - "test".to_string(), - "1.0.0-1".to_string(), - pkg_file, - ) - .expect("add install"); + tx.add_install("test".to_string(), "1.0.0-1".to_string(), pkg_file) + .expect("add install"); tx.prepare().expect("prepare"); tx.commit().expect("commit"); @@ -636,12 +634,11 @@ mod tests { assert!(test_file.exists(), "file should exist after install"); // Now remove the package (without hooks for now) - let mut tx2 = Transaction::new(root.clone(), local_db.clone()) - .expect("create remove transaction"); + let mut tx2 = + Transaction::new(root.clone(), local_db.clone()).expect("create remove transaction"); // Don't set hooks to isolate removal logic - tx2.add_remove("test".to_string()) - .expect("add remove"); + tx2.add_remove("test".to_string()).expect("add remove"); tx2.prepare().expect("prepare"); tx2.commit().expect("commit"); @@ -665,17 +662,12 @@ mod tests { fs::copy(&pkg1, &pkg2_path).expect("copy package"); // Create transaction with multiple installs - let mut tx = Transaction::new(root.clone(), local_db.clone()) - .expect("create transaction"); + let mut tx = Transaction::new(root.clone(), local_db.clone()).expect("create transaction"); let hooks = HookChain::default(); tx.set_hooks(hooks); - tx.add_install( - "test".to_string(), - "1.0.0-1".to_string(), - pkg1, - ) - .expect("add install 1"); + tx.add_install("test".to_string(), "1.0.0-1".to_string(), pkg1) + .expect("add install 1"); // Prepare and commit first should succeed tx.prepare().expect("prepare"); diff --git a/crates/xpm/src/main.rs b/crates/xpm/src/main.rs index e30f4af..906d613 100644 --- a/crates/xpm/src/main.rs +++ b/crates/xpm/src/main.rs @@ -19,11 +19,11 @@ use cli::{Cli, Command}; use xpm_core::config::Repository; use xpm_core::repo::RepoManager; use xpm_core::repo_db::{merge_files_db, parse_sync_db}; -use xpm_core::resolver::Version; use xpm_core::repo_sync::{ download_first_available, package_download_candidates, sync_repo_databases, verify_remote_signature, verify_sha256, }; +use xpm_core::resolver::Version; use xpm_core::{HookChain, Transaction}; use xpm_core::{XpmConfig, XpmError}; @@ -182,7 +182,7 @@ fn cmd_sync(config: &XpmConfig, args: &cli::SyncArgs) -> Result<()> { Ok(()) } -fn display_rel_or_abs(path: &PathBuf) -> String { +fn display_rel_or_abs(path: &Path) -> String { std::env::current_dir() .ok() .and_then(|cwd| path.strip_prefix(cwd).ok().map(|p| p.display().to_string())) @@ -294,15 +294,13 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) -> .with_context(|| format!("failed to create cache dir {}", cache_dir.display()))?; // Create transaction - let mut tx = Transaction::new( - config.options.root_dir.clone(), - local_db_dir, - ).context("failed to create transaction")?; + let mut tx = Transaction::new(config.options.root_dir.clone(), local_db_dir) + .context("failed to create transaction")?; // Setup hooks chain let hooks = HookChain::default(); tx.set_hooks(hooks); - tx.set_shell_integration(config.options.root_dir != PathBuf::from("/")); + tx.set_shell_integration(config.options.root_dir != Path::new("/")); // Phase 1: Download and validate packages for pkg_name in &args.packages { @@ -349,12 +347,14 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) -> let sig_level = repo.sig_level.unwrap_or(config.options.sig_level); let keyring_path = config.options.gpg_dir.join("trustedkeys.gpg"); let sig_url = format!("{mirror}.sig"); - verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context(|| { - format!( - "signature verification failed for '{}' from repo '{}'", - pkg_name, repo.name - ) - })?; + verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context( + || { + format!( + "signature verification failed for '{}' from repo '{}'", + pkg_name, repo.name + ) + }, + )?; if let Some(sum) = entry.sha256sum.as_deref() { verify_sha256(&dest, sum)?; @@ -364,11 +364,8 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) -> println!(" source: {}", mirror); // Add to transaction - tx.add_install( - entry.name.clone(), - entry.version.clone(), - dest, - ).context("failed to add install to transaction")?; + tx.add_install(entry.name.clone(), entry.version.clone(), dest) + .context("failed to add install to transaction")?; } if args.download_only { @@ -382,15 +379,21 @@ fn cmd_install(config: &XpmConfig, args: &cli::InstallArgs, no_confirm: bool) -> )?; // Phase 2: Prepare transaction (pre-flight checks) - println!(":: Preparing transaction ({} operation(s))...", tx.operation_count()); + println!( + ":: Preparing transaction ({} operation(s))...", + tx.operation_count() + ); tx.prepare().context("transaction preparation failed")?; // Phase 3: Commit transaction (write changes) println!(":: Committing transaction..."); tx.commit().context("transaction commit failed")?; - println!(":: {} package(s) installed successfully.", args.packages.len()); - if config.options.root_dir != PathBuf::from("/") { + println!( + ":: {} package(s) installed successfully.", + args.packages.len() + ); + if config.options.root_dir != Path::new("/") { println!(":: Shell integration enabled via ~/.local/bin shims."); println!(":: If this shell does not find new commands yet, run: hash -r"); println!(":: For immediate PATH refresh, run: source ~/.zshrc or source ~/.bashrc"); @@ -406,26 +409,22 @@ fn cmd_remove(config: &XpmConfig, args: &cli::RemoveArgs, no_confirm: bool) -> R // Create transaction let local_db_dir = config.options.db_path.join("local"); - let mut tx = Transaction::new( - config.options.root_dir.clone(), - local_db_dir.clone(), - ).context("failed to create transaction")?; + let mut tx = Transaction::new(config.options.root_dir.clone(), local_db_dir.clone()) + .context("failed to create transaction")?; // Setup hooks chain let hooks = HookChain::default(); tx.set_hooks(hooks); - tx.set_shell_integration(config.options.root_dir != PathBuf::from("/")); + tx.set_shell_integration(config.options.root_dir != Path::new("/")); // Add remove operations for each package for pkg_name in &args.packages { // Verify package is installed let pkg_dir = local_db_dir.join(pkg_name); if !pkg_dir.exists() { - return Err(XpmError::Package(format!( - "package '{}' is not installed", - pkg_name - )) - .into()); + return Err( + XpmError::Package(format!("package '{}' is not installed", pkg_name)).into(), + ); } tx.add_remove(pkg_name.clone()) @@ -435,15 +434,21 @@ fn cmd_remove(config: &XpmConfig, args: &cli::RemoveArgs, no_confirm: bool) -> R confirm_action(":: Proceed with removal? [y/N] ", no_confirm)?; // Phase 2: Prepare transaction (pre-flight checks) - println!(":: Preparing transaction ({} operation(s))...", tx.operation_count()); + println!( + ":: Preparing transaction ({} operation(s))...", + tx.operation_count() + ); tx.prepare().context("transaction preparation failed")?; // Phase 3: Commit transaction (write changes) println!(":: Committing transaction..."); tx.commit().context("transaction commit failed")?; - println!(":: {} package(s) removed successfully.", args.packages.len()); - if config.options.root_dir != PathBuf::from("/") { + println!( + ":: {} package(s) removed successfully.", + args.packages.len() + ); + if config.options.root_dir != Path::new("/") { println!(":: If command lookup is stale in current shell, run: hash -r"); } Ok(()) @@ -490,11 +495,7 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) -> let ordering = Version::cmp_versions(&entry.version, &local_version); if ordering.is_gt() || (args.force && ordering.is_eq()) { - planned.push(( - repo.clone(), - entry.clone(), - local_version, - )); + planned.push((repo.clone(), entry.clone(), local_version)); } } @@ -514,7 +515,7 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) -> .context("failed to create transaction")?; let hooks = HookChain::default(); tx.set_hooks(hooks); - tx.set_shell_integration(config.options.root_dir != PathBuf::from("/")); + tx.set_shell_integration(config.options.root_dir != Path::new("/")); for (repo, entry, _) in planned { let filename = entry.filename.clone().ok_or_else(|| { @@ -536,12 +537,14 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) -> let sig_level = repo.sig_level.unwrap_or(config.options.sig_level); let keyring_path = config.options.gpg_dir.join("trustedkeys.gpg"); let sig_url = format!("{mirror}.sig"); - verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context(|| { - format!( - "signature verification failed for '{}' from repo '{}'", - entry.name, repo.name - ) - })?; + verify_remote_signature(&dest, &sig_url, sig_level, &keyring_path, 3).with_context( + || { + format!( + "signature verification failed for '{}' from repo '{}'", + entry.name, repo.name + ) + }, + )?; if let Some(sum) = entry.sha256sum.as_deref() { verify_sha256(&dest, sum)?; @@ -553,7 +556,10 @@ fn cmd_upgrade(config: &XpmConfig, args: &cli::UpgradeArgs, no_confirm: bool) -> .context("failed to add install op to transaction")?; } - println!(":: Preparing transaction ({} operation(s))...", tx.operation_count()); + println!( + ":: Preparing transaction ({} operation(s))...", + tx.operation_count() + ); tx.prepare().context("transaction preparation failed")?; println!(":: Committing transaction..."); @@ -629,9 +635,7 @@ fn read_latest_remote_entries( } for (name, entry) in best_by_name { - latest - .entry(name) - .or_insert_with(|| (repo.clone(), entry)); + latest.entry(name).or_insert_with(|| (repo.clone(), entry)); } } diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..763a121 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,119 @@ +# Integration Guide: xpm and xpkg + +This document describes how `xpm` and `xpkg` work together in the X ecosystem. + +## Tool Roles + +| Tool | Role | CLI binary | Config | +|------|------|------------|--------| +| **xpm** | Package manager | `/usr/bin/xpm` | `/etc/xpm.conf` | +| **xpkg** | Package builder | `/usr/bin/xpkg` | `~/.config/xpkg/xpkg.conf` | + +`xpm` installs, removes, and manages packages on the target system. `xpkg` builds those packages from source recipes (XBUILD or PKGBUILD), lints them for quality, signs them, and manages repository databases. + +## Package Lifecycle + +``` +Source code → XBUILD recipe → xpkg build → .xp package → xpm install → System +``` + +1. A developer writes an XBUILD (TOML) or PKGBUILD recipe describing the package. +2. `xpkg build` reads the recipe, downloads sources, compiles in an isolated environment (fakeroot), produces a `.xp` archive (tar.zst), and optionally signs it with OpenPGP. +3. `xpkg repo-add` adds the package to a repository database. +4. The repository (containing `.xp` + `.sig` + database files) is uploaded to a static HTTP server. +5. On the target system, `xpm sync` downloads repository metadata. +6. `xpm install ` or `xpm upgrade` downloads the `.xp` file, verifies the signature, extracts files, executes `.INSTALL` scriptlets, and registers the package in the local database. + +## Shared Package Format + +Both tools use the ALPM-compatible `.xp` format (`.tar.zst` archive): + +| Entry | Generated by xpkg | Used by xpm | Purpose | +|-------|-------------------|-------------|---------| +| `.PKGINFO` | Yes | Yes | Package name, version, dependencies, metadata | +| `.BUILDINFO` | Yes | Yes | Reproducible build environment record | +| `.MTREE` | Yes | Yes | File hierarchy with SHA-256 hashes for integrity | +| `.INSTALL` | Yes | Yes | Optional shell scriptlets executed during install/remove/upgrade | + +## Scriptlet Execution + +When `xpm` processes a package that contains an `.INSTALL` file, it sources the scriptlets as bash functions: + +| Operation | Hooks called | Timing | +|-----------|-------------|--------| +| Install | `pre_install` | Before file extraction | +| Install | `post_install` | After file extraction and database registration | +| Upgrade | `pre_upgrade` | Before old version removal | +| Upgrade | `post_upgrade` | After new version files are extracted | +| Remove | `pre_remove` | Before file removal | +| Remove | `post_remove` | After file removal and database cleanup | + +Scriptlets receive environment variables: + +- `XPM_ROOT_DIR` - the installation root directory +- `XPM_PKG_NAME` - the package name +- `XPM_PKG_VERSION` - the package version being processed + +## Signature Verification + +1. `xpkg sign` signs the `.xp` package and produces `.xp.sig` (detached OpenPGP signature). +2. The public signing key is distributed via `trustedkeys.gpg` in the repository. +3. `xpm` loads the keyring from `gpg_dir` (default `/etc/xpm/gnupg/`). +4. During `sync` and `install`, `xpm` verifies detached signatures against the keyring. +5. `sig_level` controls enforcement: `optional` (log warnings), `required` (fail on missing/invalid). + +## Repository Database + +`xpkg repo-add` creates and manages ALPM-compatible repository databases (`.db.tar.gz` + `.files.tar.gz`) with extended metadata: + +| Field | Standard ALPM | xpkg extension | +|-------|--------------|----------------| +| FILENAME | Yes | - | +| SHA256SUM | - | Yes | +| URL | - | Yes (source URL for rebuilds) | + +`xpm` parses both standard and extended fields, maintaining backward compatibility with Arch Linux repositories. + +## Dependency Resolution + +`xpkg` declares dependencies in the XBUILD recipe (`depends`, `makedepends`, `optdepends`, etc.) and writes them into `.PKGINFO`. + +`xpm` reads these declarations and uses its SAT-based resolver (`resolvo`) to compute a valid installation set, handling: + +- Version constraints (e.g. `glibc>=2.35`) +- Conflicts (`conflicts` field) +- Virtual providers (`provides` field) +- Optional dependencies (`optdepends`) +- Transitive dependencies + +## Examples + +### Build and install a package locally + +```bash +# Developer machine +xpkg build # produces hello-1.0.0-1-x86_64.xp +scp hello-1.0.0-1-x86_64.xp user@target:/tmp + +# Target machine +sudo xpm install /tmp/hello-1.0.0-1-x86_64.xp +``` + +### Build and publish to a repository + +```bash +# Developer machine +xpkg build +xpkg repo-add /repo/x/x86_64/x.db.tar.gz hello-1.0.0-1-x86_64.xp +# Upload /repo/ to HTTP server + +# Target machine +sudo xpm sync +sudo xpm install hello +``` + +## Version Alignment + +Both tools track the same package format and should be updated in lockstep when format changes occur. The format version is implicit in the `.PKGINFO` structure rather than an explicit version number. + +See each tool's ROADMAP for planned compatibility milestones.