Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<img alt="License" src="https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg">
<img alt="Rust" src="https://img.shields.io/badge/Rust-000000?logo=rust&logoColor=white">
<img alt="Version" src="https://img.shields.io/github/v/release/xscriptor/xpm?include_prereleases&label=version">
<img alt="CI" src="https://img.shields.io/github/actions/workflow/status/xscriptor/xpm/ci.yml?branch=main&label=CI&logo=github">
<img alt="Status" src="https://img.shields.io/badge/Status-Active%20Development-2ea44f">
</p>

Expand All @@ -31,6 +32,7 @@
<li><a href="#package-format">Package Format</a></li>
<li><a href="#security">Security</a></li>
<li><a href="#repository-hosting">Repository Hosting</a></li>
<li><a href="#relationship-with-xpkg">Relationship with xpkg</a></li>
<li><a href="#roadmap">Roadmap</a></li>
<li><a href="#license">License</a></li>
<li><a href="#command-cheatsheet">Command Cheatsheet</a></li>
Expand All @@ -39,7 +41,7 @@

<h2 align="center" id="overview">Overview</h2>

<p><code>xpm</code> is a native Rust replacement for <code>pacman</code> and <code>libalpm</code>, designed for the X distribution. It uses the <code>.xp</code> package format (X Package) natively and maintains compatibility with Arch Linux <code>.pkg.tar.zst</code> packages.</p>
<p><code>xpm</code> is a native Rust replacement for <code>pacman</code> and <code>libalpm</code>, designed for the X distribution. It uses the <code>.xp</code> package format (X Package) natively and maintains compatibility with Arch Linux <code>.pkg.tar.zst</code> packages. Packages are built with <a href="https://github.com/xscriptor/xpkg"><code>xpkg</code></a>, the companion builder tool.</p>

<h3 align="center" id="key-features">Key Features</h3>

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

<h3 align="center" id="security">Security</h3>
Expand All @@ -404,6 +406,36 @@ sudo xpm install xpkg

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

<h2 align="center" id="relationship-with-xpkg">Relationship with xpkg</h2>

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

<table align="center">
<thead>
<tr>
<th>Tool</th>
<th>Role</th>
<th>Analogy</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>xpm</strong></td>
<td>Package manager — install, remove, upgrade, resolve deps</td>
<td><code>pacman</code></td>
</tr>
<tr>
<td><strong>xpkg</strong></td>
<td>Package builder — compile, package, lint, manage repos</td>
<td><code>makepkg</code> + <code>repo-add</code> + <code>namcap</code></td>
</tr>
</tbody>
</table>

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

<p>See the <a href="docs/INTEGRATION.md">Integration Guide</a> for detailed information about how the tools work together.</p>

<h2 align="center" id="roadmap">Roadmap</h2>

<p>See <a href="ROADMAP.md">ROADMAP.md</a> for the full development roadmap.</p>
Expand Down
61 changes: 47 additions & 14 deletions crates/xpm-core/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<()> {
Expand All @@ -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() {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions crates/xpm-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
61 changes: 27 additions & 34 deletions crates/xpm-core/src/repo_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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");
Expand All @@ -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",
Expand Down
Loading
Loading