feat: initial implementation of Depot package manager
- Implement source-based package management with support for archives and Git - Add multi-system build support (Autotools, CMake, Meson, Rust/Cargo, Custom) - Implement atomic installation logic using transactions and fakeroot - Add dependency resolution for build-time and runtime requirements - Implement package indexing and local repository management - Add comprehensive configuration system with system/package overrides - Include Project guidelines (AGENTS.md) and README.md
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines mandatory development rules for contributors and automated agents working on the Depot package manager.
|
||||||
|
|
||||||
|
All code changes must comply with the policies below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. No Stubs
|
||||||
|
|
||||||
|
Stubs are not allowed.
|
||||||
|
|
||||||
|
Do **not**:
|
||||||
|
|
||||||
|
- Add placeholder functions
|
||||||
|
- Add `todo!()` or `unimplemented!()`
|
||||||
|
- Leave partially implemented logic
|
||||||
|
- Add empty modules “for later”
|
||||||
|
- Return dummy values to satisfy the compiler
|
||||||
|
- Bypass logic with `#[allow(unused)]` or similar attributes
|
||||||
|
|
||||||
|
All submitted code must:
|
||||||
|
|
||||||
|
- Be fully implemented
|
||||||
|
- Compile cleanly
|
||||||
|
- Function as described
|
||||||
|
- Pass relevant tests
|
||||||
|
|
||||||
|
If a feature is not ready, do not merge it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dependency Management
|
||||||
|
|
||||||
|
All Rust dependencies must be added using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo add <crate>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not**:
|
||||||
|
|
||||||
|
- Manually edit `Cargo.toml` to add dependencies
|
||||||
|
- Hardcode versions unless strictly required
|
||||||
|
- Add unused dependencies
|
||||||
|
|
||||||
|
When adding a crate:
|
||||||
|
|
||||||
|
- Prefer stable, well-maintained crates
|
||||||
|
- Avoid unnecessary heavy dependencies
|
||||||
|
- Justify large or complex dependencies in commit messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. No Dead Code
|
||||||
|
|
||||||
|
Do not introduce:
|
||||||
|
|
||||||
|
- Unused structs
|
||||||
|
- Unused enums
|
||||||
|
- Unused functions
|
||||||
|
- Unused modules
|
||||||
|
- Commented-out code
|
||||||
|
|
||||||
|
Remove obsolete code instead of commenting it out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Error Handling
|
||||||
|
|
||||||
|
All errors must:
|
||||||
|
|
||||||
|
- Use `anyhow::Result` at boundaries
|
||||||
|
- Provide meaningful context with `.with_context(...)`
|
||||||
|
- Avoid silent failure
|
||||||
|
|
||||||
|
Do not ignore `Result`s.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Deterministic Behavior
|
||||||
|
|
||||||
|
Depot is a package manager. Reproducibility matters.
|
||||||
|
|
||||||
|
All logic must:
|
||||||
|
|
||||||
|
- Avoid non-deterministic behavior
|
||||||
|
- Avoid reliance on environment state unless explicitly defined
|
||||||
|
- Avoid hidden global state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Formatting and Linting
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Warnings are treated as errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documentation
|
||||||
|
|
||||||
|
Public APIs must use Rustdoc comments.
|
||||||
|
|
||||||
|
Examples must compile when possible.
|
||||||
|
|
||||||
|
Do not leave undocumented public interfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Extraction and Fetching Safety
|
||||||
|
|
||||||
|
All archive extraction must:
|
||||||
|
|
||||||
|
- Prevent path traversal
|
||||||
|
- Reject unsafe paths
|
||||||
|
- Validate checksums before use
|
||||||
|
|
||||||
|
Security is not optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Architecture Discipline
|
||||||
|
|
||||||
|
Keep responsibilities separated:
|
||||||
|
|
||||||
|
- Spec parsing: `package/`
|
||||||
|
- Fetching: `source/`
|
||||||
|
- Extraction: `source/`
|
||||||
|
- Build execution: `builder/`
|
||||||
|
- Installation: `install/`
|
||||||
|
|
||||||
|
Do not mix layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing
|
||||||
|
|
||||||
|
New features must include:
|
||||||
|
|
||||||
|
- Parsing tests (if spec-related)
|
||||||
|
- Behavior tests (if build/install-related)
|
||||||
|
|
||||||
|
Avoid regression-prone changes without tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Depot is infrastructure.
|
||||||
|
|
||||||
|
Code must be:
|
||||||
|
|
||||||
|
- Complete
|
||||||
|
- Deterministic
|
||||||
|
- Secure
|
||||||
|
- Minimal
|
||||||
|
- Explicit
|
||||||
|
|
||||||
|
No shortcuts.
|
||||||
+2938
File diff suppressed because it is too large
Load Diff
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
[package]
|
||||||
|
name = "depot"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
warnings = "deny"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.101"
|
||||||
|
ar = "0.9.0"
|
||||||
|
bzip2 = "0.6.1"
|
||||||
|
clap = { version = "4.5.58", features = ["derive"] }
|
||||||
|
flate2 = "1.1.9"
|
||||||
|
git2 = "0.20.4"
|
||||||
|
indicatif = "0.18.4"
|
||||||
|
nix = { version = "0.31.1", features = ["user"] }
|
||||||
|
reqwest = { version = "0.13.2", features = ["blocking"] }
|
||||||
|
rusqlite = "0.38.0"
|
||||||
|
semver = "1.0.27"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
yaml-rust2 = "0.11.0"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
tar = "0.4.44"
|
||||||
|
tempfile = "3.25.0"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
toml = "0.9.8"
|
||||||
|
url = "2.5.8"
|
||||||
|
walkdir = "2.5.0"
|
||||||
|
xz2 = "0.1.7"
|
||||||
|
zip = "8.0.0"
|
||||||
|
zstd = { version = "0.13.3", features = ["zstdmt"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.25.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = "symbols"
|
||||||
|
debug = false
|
||||||
@@ -1,93 +1,77 @@
|
|||||||
# depot
|
# depot
|
||||||
|
|
||||||
|
Depot is a source-based package manager for Linux, designed for Linux. It focuses on reproducibility, atomic installations, and ease of cross-compilation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
## Getting started
|
- **Source-based**: Downloads, extracts, and builds packages from source.
|
||||||
|
- **Dependency Management**: Automatically handles build-time and runtime dependencies.
|
||||||
|
- **Atomic Installation**: Uses a transactional approach to ensure system consistency.
|
||||||
|
- **Multi-system Build Support**: Built-in support for Autotools, CMake, Meson, Rust (Cargo), and custom build scripts.
|
||||||
|
- **Cross-Compilation**: Easily build packages for different architectures using cross-toolchains.
|
||||||
|
- **Repository Management**: Create and manage local package repositories.
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
## Quick Start
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
### Building Depot
|
||||||
|
|
||||||
## Add your files
|
```bash
|
||||||
|
cargo build --release
|
||||||
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
|
|
||||||
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd existing_repo
|
|
||||||
git remote add origin https://gitlab.com/sfg-os/depot.git
|
|
||||||
git branch -M main
|
|
||||||
git push -uf origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrate with your tools
|
### Installing a Package
|
||||||
|
|
||||||
* [Set up project integrations](https://gitlab.com/sfg-os/depot/-/settings/integrations)
|
To install a package from a spec file:
|
||||||
|
|
||||||
## Collaborate with your team
|
```bash
|
||||||
|
depot install packages/zlib.toml
|
||||||
|
```
|
||||||
|
|
||||||
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
|
To install from a pre-built package archive:
|
||||||
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
|
|
||||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
|
|
||||||
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
|
|
||||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
|
||||||
|
|
||||||
## Test and Deploy
|
```bash
|
||||||
|
depot install zlib-1.2.11-1-x86_64.depot.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
## Command Reference
|
||||||
|
|
||||||
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
|
- `install <SPEC_OR_ARCHIVE>`: Build and install a package.
|
||||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
|
- `remove <PACKAGE>`: Remove an installed package.
|
||||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
|
- `build <SPEC>`: Build a package and create an archive without installing.
|
||||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
|
- `info <PACKAGE_OR_SPEC>`: Show information about a package.
|
||||||
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
|
- `list`: List all installed packages.
|
||||||
|
- `repo create [DIR]`: Create a repository database from a directory of packages.
|
||||||
|
- `config`: Show current configuration and overrides.
|
||||||
|
|
||||||
***
|
## Package Specification
|
||||||
|
|
||||||
# Editing this README
|
Packages are defined using TOML files. Here is a basic example:
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "example"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "An example package"
|
||||||
|
homepage = "https://example.com"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
## Suggestions for a good README
|
[[source]]
|
||||||
|
url = "https://example.com/example-$version.tar.gz"
|
||||||
|
sha256 = "..."
|
||||||
|
extract_dir = "example-$version"
|
||||||
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
[build]
|
||||||
|
type = "autotools"
|
||||||
|
flags = { configure = ["--enable-feature"] }
|
||||||
|
|
||||||
## Name
|
[dependencies]
|
||||||
Choose a self-explaining name for your project.
|
build = ["gcc", "make"]
|
||||||
|
runtime = ["libc"]
|
||||||
|
```
|
||||||
|
|
||||||
## Description
|
## Configuration
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
Depot can be configured via `/etc/depot.d/` (or relative to the rootfs).
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
|
||||||
|
|
||||||
## Visuals
|
- `/etc/depot.d/build.toml`: System-wide build overrides and flag appends.
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
- `/etc/depot.d/package.toml`: Package-specific overrides.
|
||||||
|
|
||||||
## Installation
|
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
|
||||||
|
|
||||||
## Authors and acknowledgment
|
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
|
||||||
## License
|
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
|
||||||
|
|||||||
Executable
+209
@@ -0,0 +1,209 @@
|
|||||||
|
//! GNU Autotools build system (configure && make && make install)
|
||||||
|
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
use crate::fakeroot;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use crate::source::hooks;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn build(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let flags = &spec.build.flags;
|
||||||
|
|
||||||
|
// Determine actual source directory (support source_subdir)
|
||||||
|
let actual_src = if !flags.source_subdir.is_empty() {
|
||||||
|
src_dir.join(&flags.source_subdir)
|
||||||
|
} else {
|
||||||
|
src_dir.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !actual_src.exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Source directory not found: {} (source_subdir: {})",
|
||||||
|
actual_src.display(),
|
||||||
|
flags.source_subdir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destdir
|
||||||
|
fs::create_dir_all(destdir)?;
|
||||||
|
|
||||||
|
// Build environment variables
|
||||||
|
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||||
|
|
||||||
|
// Use cross-compilation tools if configured
|
||||||
|
let (cc, ar) = if let Some(cc_cfg) = cross {
|
||||||
|
(cc_cfg.cc.clone(), cc_cfg.ar.clone())
|
||||||
|
} else {
|
||||||
|
(flags.cc.clone(), flags.ar.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
if !flags.cflags.is_empty() {
|
||||||
|
// Expand shell command substitutions like $($CC -print-resource-dir)
|
||||||
|
let cflags_str = flags.cflags.join(" ");
|
||||||
|
let expanded = expand_shell_commands(&cflags_str, &cc)?;
|
||||||
|
env_vars.push(("CFLAGS", expanded));
|
||||||
|
}
|
||||||
|
if !flags.ldflags.is_empty() {
|
||||||
|
env_vars.push(("LDFLAGS", flags.ldflags.join(" ")));
|
||||||
|
}
|
||||||
|
env_vars.push(("CC", cc.clone()));
|
||||||
|
env_vars.push(("AR", ar));
|
||||||
|
|
||||||
|
// Add cross-compilation environment
|
||||||
|
if let Some(cc_cfg) = cross {
|
||||||
|
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||||
|
env_vars.push(("RANLIB", cc_cfg.ranlib.clone()));
|
||||||
|
env_vars.push(("STRIP", cc_cfg.strip.clone()));
|
||||||
|
env_vars.push(("LD", cc_cfg.ld.clone()));
|
||||||
|
env_vars.push(("NM", cc_cfg.nm.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dynamic loader flag if specified
|
||||||
|
if !flags.libc.is_empty() {
|
||||||
|
let ldflags = if flags.ldflags.is_empty() {
|
||||||
|
format!("-Wl,--dynamic-linker={}", flags.libc)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} -Wl,--dynamic-linker={}",
|
||||||
|
flags.ldflags.join(" "),
|
||||||
|
flags.libc
|
||||||
|
)
|
||||||
|
};
|
||||||
|
env_vars.push(("LDFLAGS", ldflags));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run configure
|
||||||
|
println!("Running configure...");
|
||||||
|
let mut configure_cmd = Command::new("./configure");
|
||||||
|
configure_cmd.current_dir(&actual_src);
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut configure_cmd, &env_vars);
|
||||||
|
|
||||||
|
configure_cmd.arg(format!("--prefix={}", flags.prefix));
|
||||||
|
|
||||||
|
if !flags.chost.is_empty() {
|
||||||
|
configure_cmd.arg(format!("--host={}", flags.chost));
|
||||||
|
}
|
||||||
|
if !flags.cbuild.is_empty() {
|
||||||
|
configure_cmd.arg(format!("--build={}", flags.cbuild));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cross-compilation flags
|
||||||
|
if let Some(cc_cfg) = cross {
|
||||||
|
configure_cmd.arg(format!("--host={}", cc_cfg.host_triple()));
|
||||||
|
if let Ok(build) = CrossConfig::build_triple() {
|
||||||
|
configure_cmd.arg(format!("--build={}", build));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in &flags.configure {
|
||||||
|
configure_cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = configure_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run configure in {}", actual_src.display()))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("configure failed with status: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run make
|
||||||
|
println!("Running make...");
|
||||||
|
let mut make_cmd = Command::new("make");
|
||||||
|
make_cmd.current_dir(&actual_src);
|
||||||
|
make_cmd.arg("-j").arg(num_cpus().to_string());
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut make_cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = make_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run make in {}", actual_src.display()))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("make failed with status: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run post-compile hooks (after make, before make install)
|
||||||
|
hooks::run_post_compile_commands(spec, &actual_src, destdir)?;
|
||||||
|
|
||||||
|
// Run make install with fakeroot if not root
|
||||||
|
println!(
|
||||||
|
"Running make install{}...",
|
||||||
|
if fakeroot::is_root() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (with internal fakeroot for build)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut install_cmd = fakeroot::wrap_install_command("make", destdir);
|
||||||
|
install_cmd.current_dir(&actual_src);
|
||||||
|
install_cmd.arg("install");
|
||||||
|
|
||||||
|
let mut install_env = env_vars.clone();
|
||||||
|
install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||||
|
crate::builder::prepare_command(&mut install_cmd, &install_env);
|
||||||
|
|
||||||
|
let status = install_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run make install for {}", spec.package.name))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("make install failed with status: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run post-install hooks (after make install)
|
||||||
|
hooks::run_post_install_commands(spec, &actual_src, destdir)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand shell command substitutions like $($CC -print-resource-dir) in a string
|
||||||
|
fn expand_shell_commands(input: &str, cc: &str) -> Result<String> {
|
||||||
|
let mut result = input.to_string();
|
||||||
|
|
||||||
|
// Find and expand $(...) patterns
|
||||||
|
while let Some(start) = result.find("$(") {
|
||||||
|
let rest = &result[start + 2..];
|
||||||
|
if let Some(end) = rest.find(')') {
|
||||||
|
let cmd = &rest[..end];
|
||||||
|
// Replace $CC with actual compiler
|
||||||
|
let cmd = cmd.replace("$CC", cc);
|
||||||
|
|
||||||
|
// Execute the command via shell
|
||||||
|
let output = Command::new("sh").arg("-c").arg(&cmd).output();
|
||||||
|
|
||||||
|
let replacement = match output {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Silently skip failed commands (e.g., gcc doesn't support -print-resource-dir)
|
||||||
|
eprintln!("Warning: shell command '{}' failed, skipping", cmd);
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
|
||||||
|
} else {
|
||||||
|
break; // Malformed, no closing paren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! Binary package "build" system — used when package supplies a prebuilt binary installer
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs as unix_fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
|
||||||
|
/// For binary packages we simply copy the extracted files into DESTDIR (preserving
|
||||||
|
/// directory structure). This is useful for .deb packages where extract step
|
||||||
|
/// already unpacked the data payload into the source directory.
|
||||||
|
pub fn build(
|
||||||
|
_spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
_cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
println!("Binary install: copying files from {} to {} (pkg type={})", src_dir.display(), destdir.display(), _spec.build.flags.binary_type);
|
||||||
|
fs::create_dir_all(destdir).with_context(|| format!("Failed to create destdir: {}", destdir.display()))?;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(src_dir) {
|
||||||
|
let entry = entry?;
|
||||||
|
let rel = entry.path().strip_prefix(src_dir).unwrap();
|
||||||
|
let target = destdir.join(rel);
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target)?;
|
||||||
|
} else if entry.file_type().is_symlink() {
|
||||||
|
let link_target = fs::read_link(entry.path())?;
|
||||||
|
if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; }
|
||||||
|
// overwrite existing links/files
|
||||||
|
if target.exists() { let _ = fs::remove_file(&target); }
|
||||||
|
unix_fs::symlink(link_target, &target)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; }
|
||||||
|
fs::copy(entry.path(), &target)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Executable
+179
@@ -0,0 +1,179 @@
|
|||||||
|
//! CMake build system
|
||||||
|
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
use crate::fakeroot;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn build(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let flags = &spec.build.flags;
|
||||||
|
|
||||||
|
// Determine actual source directory (support source_subdir)
|
||||||
|
let actual_src = if !flags.source_subdir.is_empty() {
|
||||||
|
src_dir.join(&flags.source_subdir)
|
||||||
|
} else {
|
||||||
|
src_dir.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !actual_src.exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Source directory not found: {} (source_subdir: {})",
|
||||||
|
actual_src.display(),
|
||||||
|
flags.source_subdir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let build_dir = actual_src.join("build");
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
fs::create_dir_all(&build_dir)?;
|
||||||
|
fs::create_dir_all(destdir)?;
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||||
|
|
||||||
|
// Use cross-compilation tools if configured
|
||||||
|
let cc = if let Some(cc_cfg) = cross {
|
||||||
|
cc_cfg.cc.clone()
|
||||||
|
} else {
|
||||||
|
flags.cc.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !flags.cflags.is_empty() {
|
||||||
|
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
|
||||||
|
}
|
||||||
|
if !flags.chost.is_empty() {
|
||||||
|
env_vars.push(("CHOST", flags.chost.clone()));
|
||||||
|
}
|
||||||
|
if !flags.cbuild.is_empty() {
|
||||||
|
env_vars.push(("CBUILD", flags.cbuild.clone()));
|
||||||
|
}
|
||||||
|
if !flags.ldflags.is_empty() {
|
||||||
|
let ldflags = if flags.libc.is_empty() {
|
||||||
|
flags.ldflags.join(" ")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} -Wl,--dynamic-linker={}",
|
||||||
|
flags.ldflags.join(" "),
|
||||||
|
flags.libc
|
||||||
|
)
|
||||||
|
};
|
||||||
|
env_vars.push(("LDFLAGS", ldflags));
|
||||||
|
}
|
||||||
|
env_vars.push(("CC", cc));
|
||||||
|
|
||||||
|
// Add cross-compilation env
|
||||||
|
if let Some(cc_cfg) = cross {
|
||||||
|
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||||
|
env_vars.push(("AR", cc_cfg.ar.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract prefix from configure flags (cmake-style -DCMAKE_INSTALL_PREFIX=)
|
||||||
|
let prefix = flags
|
||||||
|
.configure
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.contains("CMAKE_INSTALL_PREFIX="))
|
||||||
|
.and_then(|s| s.split('=').nth(1))
|
||||||
|
.unwrap_or(&flags.prefix);
|
||||||
|
|
||||||
|
// Generate toolchain file if cross-compiling
|
||||||
|
let toolchain_file = if let Some(cc_cfg) = cross {
|
||||||
|
Some(cc_cfg.generate_cmake_toolchain(&build_dir)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run cmake configure
|
||||||
|
println!("Running cmake configure...");
|
||||||
|
let mut cmake_cmd = Command::new("cmake");
|
||||||
|
cmake_cmd.current_dir(&build_dir);
|
||||||
|
cmake_cmd.arg("-S").arg(&actual_src);
|
||||||
|
cmake_cmd.arg("-B").arg(&build_dir);
|
||||||
|
cmake_cmd.arg(format!("-DCMAKE_INSTALL_PREFIX={}", prefix));
|
||||||
|
cmake_cmd.arg("-DCMAKE_BUILD_TYPE=Release");
|
||||||
|
|
||||||
|
// Add toolchain file for cross-compilation
|
||||||
|
if let Some(ref tf) = toolchain_file {
|
||||||
|
cmake_cmd.arg(format!("-DCMAKE_TOOLCHAIN_FILE={}", tf.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom configure flags from spec (supports cross-compilation overrides)
|
||||||
|
for flag in &flags.configure {
|
||||||
|
// Expand environment variables in the flag
|
||||||
|
let expanded = expand_env_vars(flag);
|
||||||
|
cmake_cmd.arg(&expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut cmake_cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = cmake_cmd.status().context("Failed to run cmake")?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("cmake configure failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cmake build
|
||||||
|
println!("Running cmake build...");
|
||||||
|
let mut build_cmd = Command::new("cmake");
|
||||||
|
build_cmd.arg("--build").arg(&build_dir);
|
||||||
|
build_cmd.arg("-j").arg(num_cpus().to_string());
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut build_cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = build_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run cmake build for {}", spec.package.name))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("cmake build failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cmake install with fakeroot if not root
|
||||||
|
println!(
|
||||||
|
"Running cmake install{}...",
|
||||||
|
if fakeroot::is_root() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (with fakeroot)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut install_cmd = fakeroot::wrap_install_command("cmake", destdir);
|
||||||
|
install_cmd.arg("--install").arg(&build_dir);
|
||||||
|
|
||||||
|
let mut install_env = env_vars.clone();
|
||||||
|
install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||||
|
crate::builder::prepare_command(&mut install_cmd, &install_env);
|
||||||
|
|
||||||
|
let status = install_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run cmake install for {}", spec.package.name))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("cmake install failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand environment variables in a string (e.g., $DEPOT_SYSROOT)
|
||||||
|
fn expand_env_vars(input: &str) -> String {
|
||||||
|
let mut result = input.to_string();
|
||||||
|
// Simple expansion for $VAR and ${VAR} patterns
|
||||||
|
for (key, value) in std::env::vars() {
|
||||||
|
result = result.replace(&format!("${}", key), &value);
|
||||||
|
result = result.replace(&format!("${{{}}}", key), &value);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
//! Custom build scripts
|
||||||
|
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
use crate::fakeroot;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn build(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let flags = &spec.build.flags;
|
||||||
|
|
||||||
|
// Create destdir
|
||||||
|
fs::create_dir_all(destdir)?;
|
||||||
|
|
||||||
|
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||||
|
|
||||||
|
// For custom builds, look for a build.sh script in the source directory
|
||||||
|
let build_script = src_dir.join("build.sh");
|
||||||
|
|
||||||
|
if !build_script.exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Custom build type requires build.sh in source directory: {}",
|
||||||
|
src_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Running custom build script{}...",
|
||||||
|
if fakeroot::is_root() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (with fakeroot)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut cmd = fakeroot::wrap_install_command("bash", destdir);
|
||||||
|
cmd.current_dir(src_dir);
|
||||||
|
cmd.arg(&build_script);
|
||||||
|
|
||||||
|
if !flags.cflags.is_empty() {
|
||||||
|
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
|
||||||
|
}
|
||||||
|
if !flags.ldflags.is_empty() {
|
||||||
|
let ldflags = if flags.libc.is_empty() {
|
||||||
|
flags.ldflags.join(" ")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} -Wl,--dynamic-linker={}",
|
||||||
|
flags.ldflags.join(" "),
|
||||||
|
flags.libc
|
||||||
|
)
|
||||||
|
};
|
||||||
|
env_vars.push(("LDFLAGS", ldflags));
|
||||||
|
}
|
||||||
|
|
||||||
|
env_vars.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||||
|
env_vars.push(("PREFIX", spec.build.flags.prefix.clone()));
|
||||||
|
env_vars.push((
|
||||||
|
"NYAPM_SPECDIR",
|
||||||
|
spec.spec_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if !flags.chost.is_empty() {
|
||||||
|
env_vars.push(("CHOST", flags.chost.clone()));
|
||||||
|
}
|
||||||
|
if !flags.cbuild.is_empty() {
|
||||||
|
env_vars.push(("CBUILD", flags.cbuild.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cross-compilation tools if configured
|
||||||
|
if let Some(cc_cfg) = cross {
|
||||||
|
env_vars.push(("CC", cc_cfg.cc.clone()));
|
||||||
|
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||||
|
env_vars.push(("AR", cc_cfg.ar.clone()));
|
||||||
|
env_vars.push(("RANLIB", cc_cfg.ranlib.clone()));
|
||||||
|
env_vars.push(("STRIP", cc_cfg.strip.clone()));
|
||||||
|
env_vars.push(("LD", cc_cfg.ld.clone()));
|
||||||
|
env_vars.push(("NM", cc_cfg.nm.clone()));
|
||||||
|
env_vars.push(("CROSS_PREFIX", cc_cfg.prefix.clone()));
|
||||||
|
env_vars.push(("CROSS_COMPILE", format!("{}-", cc_cfg.prefix)));
|
||||||
|
} else {
|
||||||
|
env_vars.push(("CC", flags.cc.clone()));
|
||||||
|
env_vars.push(("AR", flags.ar.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run build script: {}", build_script.display()))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("Custom build script failed with status: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Executable
+146
@@ -0,0 +1,146 @@
|
|||||||
|
//! Meson build system
|
||||||
|
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
use crate::fakeroot;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn build(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let flags = &spec.build.flags;
|
||||||
|
let build_dir = src_dir.join("builddir");
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
fs::create_dir_all(&build_dir)?;
|
||||||
|
fs::create_dir_all(destdir)?;
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||||
|
|
||||||
|
// Use cross-compilation tools if configured
|
||||||
|
let cc = if let Some(cc_cfg) = cross {
|
||||||
|
cc_cfg.cc.clone()
|
||||||
|
} else {
|
||||||
|
flags.cc.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !flags.cflags.is_empty() {
|
||||||
|
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
|
||||||
|
}
|
||||||
|
if !flags.chost.is_empty() {
|
||||||
|
env_vars.push(("CHOST", flags.chost.clone()));
|
||||||
|
}
|
||||||
|
if !flags.cbuild.is_empty() {
|
||||||
|
env_vars.push(("CBUILD", flags.cbuild.clone()));
|
||||||
|
}
|
||||||
|
if !flags.ldflags.is_empty() {
|
||||||
|
let ldflags = if flags.libc.is_empty() {
|
||||||
|
flags.ldflags.join(" ")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} -Wl,--dynamic-linker={}",
|
||||||
|
flags.ldflags.join(" "),
|
||||||
|
flags.libc
|
||||||
|
)
|
||||||
|
};
|
||||||
|
env_vars.push(("LDFLAGS", ldflags));
|
||||||
|
}
|
||||||
|
env_vars.push(("CC", cc));
|
||||||
|
|
||||||
|
// Add cross-compilation env
|
||||||
|
if let Some(cc_cfg) = cross {
|
||||||
|
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||||
|
env_vars.push(("AR", cc_cfg.ar.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract prefix from configure flags
|
||||||
|
let prefix = flags
|
||||||
|
.configure
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.starts_with("--prefix="))
|
||||||
|
.map(|s| s.trim_start_matches("--prefix="))
|
||||||
|
.unwrap_or(&flags.prefix);
|
||||||
|
|
||||||
|
// Generate cross file if cross-compiling
|
||||||
|
let cross_file = if let Some(cc_cfg) = cross {
|
||||||
|
Some(cc_cfg.generate_meson_cross_file(&build_dir)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run meson setup
|
||||||
|
println!("Running meson setup...");
|
||||||
|
let mut meson_cmd = Command::new("meson");
|
||||||
|
meson_cmd.current_dir(src_dir);
|
||||||
|
meson_cmd.arg("setup");
|
||||||
|
meson_cmd.arg(&build_dir);
|
||||||
|
meson_cmd.arg(format!("--prefix={}", prefix));
|
||||||
|
meson_cmd.arg("--buildtype=release");
|
||||||
|
|
||||||
|
// Add cross file for cross-compilation
|
||||||
|
if let Some(ref cf) = cross_file {
|
||||||
|
meson_cmd.arg(format!("--cross-file={}", cf.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut meson_cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = meson_cmd.status().context("Failed to run meson setup")?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("meson setup failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ninja build
|
||||||
|
println!("Running ninja...");
|
||||||
|
let mut ninja_cmd = Command::new("ninja");
|
||||||
|
ninja_cmd.current_dir(&build_dir);
|
||||||
|
ninja_cmd.arg("-j").arg(num_cpus().to_string());
|
||||||
|
|
||||||
|
crate::builder::prepare_command(&mut ninja_cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = ninja_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run ninja for {}", spec.package.name))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("ninja build failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run meson install with fakeroot if not root
|
||||||
|
println!(
|
||||||
|
"Running meson install{}...",
|
||||||
|
if fakeroot::is_root() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (with fakeroot)"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut install_cmd = fakeroot::wrap_install_command("meson", destdir);
|
||||||
|
install_cmd.arg("install");
|
||||||
|
install_cmd.arg("-C").arg(&build_dir);
|
||||||
|
|
||||||
|
let mut install_env = env_vars.clone();
|
||||||
|
install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||||
|
crate::builder::prepare_command(&mut install_cmd, &install_env);
|
||||||
|
|
||||||
|
let status = install_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run meson install for {}", spec.package.name))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("meson install failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
//! Build system abstraction
|
||||||
|
|
||||||
|
mod autotools;
|
||||||
|
mod bin;
|
||||||
|
mod cmake;
|
||||||
|
mod custom;
|
||||||
|
mod meson;
|
||||||
|
mod rust;
|
||||||
|
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
use crate::package::{BuildType, PackageSpec};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Prepare a Command with a hermetic environment and some essential variables preserved.
|
||||||
|
pub fn prepare_command(cmd: &mut Command, env_vars: &[(&str, String)]) {
|
||||||
|
cmd.env_clear();
|
||||||
|
// Preserve essential environment variables
|
||||||
|
for var in &["PATH", "LANG", "HOME", "SHELL", "DESTDIR"] {
|
||||||
|
if let Ok(val) = std::env::var(var) {
|
||||||
|
cmd.env(var, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set requested environment variables
|
||||||
|
for (key, val) in env_vars {
|
||||||
|
cmd.env(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a package using the appropriate build system
|
||||||
|
pub fn build(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let Some(cc) = cross {
|
||||||
|
println!(
|
||||||
|
"Cross-compiling for {} with {:?}...",
|
||||||
|
cc.prefix, spec.build.build_type
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Building with {:?}...", spec.build.build_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean destdir to prevent stale files/directories (e.g., directories where symlinks should be)
|
||||||
|
if destdir.exists() {
|
||||||
|
std::fs::remove_dir_all(destdir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match spec.build.build_type {
|
||||||
|
BuildType::Autotools => autotools::build(spec, src_dir, destdir, cross),
|
||||||
|
BuildType::CMake => cmake::build(spec, src_dir, destdir, cross),
|
||||||
|
BuildType::Meson => meson::build(spec, src_dir, destdir, cross),
|
||||||
|
BuildType::Custom => custom::build(spec, src_dir, destdir, cross),
|
||||||
|
BuildType::Rust => rust::build(spec, src_dir, destdir, cross),
|
||||||
|
BuildType::Bin => bin::build(spec, src_dir, destdir, cross),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prepare_command() {
|
||||||
|
let mut cmd = Command::new("ls");
|
||||||
|
// Set an env var that should be cleared
|
||||||
|
cmd.env("FORBIDDEN", "value");
|
||||||
|
// Set PATH manually in the current process to ensure it's picked up if it exists
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("PATH", "/usr/bin");
|
||||||
|
std::env::set_var("HOME", "/home/test");
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_command(&mut cmd, &[("MYVAR", "myval".to_string())]);
|
||||||
|
|
||||||
|
let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect();
|
||||||
|
assert!(envs.get(std::ffi::OsStr::new("PATH")).is_some());
|
||||||
|
assert!(envs.get(std::ffi::OsStr::new("HOME")).is_some());
|
||||||
|
assert!(envs.get(std::ffi::OsStr::new("FORBIDDEN")).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
envs.get(std::ffi::OsStr::new("MYVAR")),
|
||||||
|
Some(&Some(std::ffi::OsString::from("myval").as_os_str()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prepare_command_preserves_destdir() {
|
||||||
|
let mut cmd = std::process::Command::new("ls");
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("DESTDIR", "/tmp/dest");
|
||||||
|
}
|
||||||
|
prepare_command(&mut cmd, &[]);
|
||||||
|
let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect();
|
||||||
|
assert_eq!(
|
||||||
|
envs.get(std::ffi::OsStr::new("DESTDIR")),
|
||||||
|
Some(&Some(std::ffi::OsString::from("/tmp/dest").as_os_str()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+179
@@ -0,0 +1,179 @@
|
|||||||
|
//! Rust/Cargo build system
|
||||||
|
|
||||||
|
use crate::cross::CrossConfig;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use crate::source::hooks;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn build(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
src_dir: &Path,
|
||||||
|
destdir: &Path,
|
||||||
|
cross: Option<&CrossConfig>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let flags = &spec.build.flags;
|
||||||
|
|
||||||
|
// Create destdir
|
||||||
|
fs::create_dir_all(destdir)?;
|
||||||
|
|
||||||
|
// Isolate from parent workspace by adding empty [workspace] if not present
|
||||||
|
// This prevents "believes it's in a workspace when it's not" errors
|
||||||
|
let cargo_toml = src_dir.join("Cargo.toml");
|
||||||
|
if cargo_toml.exists() {
|
||||||
|
let contents = fs::read_to_string(&cargo_toml)
|
||||||
|
.with_context(|| format!("Failed to read {}", cargo_toml.display()))?;
|
||||||
|
if !contents.contains("[workspace]") {
|
||||||
|
let isolated = format!("{}\n\n[workspace]\n", contents);
|
||||||
|
fs::write(&cargo_toml, isolated).with_context(|| {
|
||||||
|
format!("Failed to isolate Cargo.toml: {}", cargo_toml.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine profile
|
||||||
|
let is_release = flags.profile == "release";
|
||||||
|
let profile_dir = if is_release { "release" } else { "debug" };
|
||||||
|
|
||||||
|
// Determine target triple
|
||||||
|
let target = if !flags.target.is_empty() {
|
||||||
|
Some(flags.target.clone())
|
||||||
|
} else {
|
||||||
|
cross.map(|cc_cfg| cc_cfg.prefix.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build environment
|
||||||
|
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||||
|
|
||||||
|
// RUSTFLAGS
|
||||||
|
if !flags.rustflags.is_empty() {
|
||||||
|
env_vars.push(("RUSTFLAGS", flags.rustflags.join(" ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cross-compiling, set linker via CARGO_TARGET_*_LINKER
|
||||||
|
if let Some(cc_cfg) = cross {
|
||||||
|
// Convert target triple to uppercase with underscores for env var
|
||||||
|
let target_env = target
|
||||||
|
.as_ref()
|
||||||
|
.unwrap_or(&cc_cfg.prefix)
|
||||||
|
.to_uppercase()
|
||||||
|
.replace('-', "_");
|
||||||
|
let linker_var = format!("CARGO_TARGET_{}_LINKER", target_env);
|
||||||
|
env_vars.push((linker_var.leak(), cc_cfg.cc.clone()));
|
||||||
|
env_vars.push(("CC".to_string().leak(), cc_cfg.cc.clone()));
|
||||||
|
env_vars.push(("AR".to_string().leak(), cc_cfg.ar.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default rustup toolchain if not already set
|
||||||
|
if std::env::var("RUSTUP_TOOLCHAIN").is_err() {
|
||||||
|
env_vars.push(("RUSTUP_TOOLCHAIN", "stable".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cargo build
|
||||||
|
println!(
|
||||||
|
"Running cargo build ({})...",
|
||||||
|
if is_release { "release" } else { "debug" }
|
||||||
|
);
|
||||||
|
let mut cargo_cmd = Command::new("cargo");
|
||||||
|
cargo_cmd.current_dir(src_dir);
|
||||||
|
cargo_cmd.arg("build");
|
||||||
|
|
||||||
|
if is_release {
|
||||||
|
cargo_cmd.arg("--release");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add target if specified
|
||||||
|
if let Some(ref t) = target {
|
||||||
|
cargo_cmd.arg("--target").arg(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add additional cargo args
|
||||||
|
for arg in &flags.cargs {
|
||||||
|
cargo_cmd.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment
|
||||||
|
crate::builder::prepare_command(&mut cargo_cmd, &env_vars);
|
||||||
|
|
||||||
|
let status = cargo_cmd
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run cargo build for {}", spec.package.name))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("cargo build failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run post-compile hooks
|
||||||
|
hooks::run_post_compile_commands(spec, src_dir, destdir)?;
|
||||||
|
|
||||||
|
// Install binaries to destdir
|
||||||
|
println!("Installing binaries to DESTDIR...");
|
||||||
|
|
||||||
|
// Determine target directory
|
||||||
|
let target_dir = if let Some(ref t) = target {
|
||||||
|
src_dir.join("target").join(t).join(profile_dir)
|
||||||
|
} else {
|
||||||
|
src_dir.join("target").join(profile_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use bindir from flags (default: /usr/bin)
|
||||||
|
let bin_dir = destdir.join(flags.bindir.trim_start_matches('/'));
|
||||||
|
fs::create_dir_all(&bin_dir)?;
|
||||||
|
|
||||||
|
// Find and copy executable files
|
||||||
|
if target_dir.exists() {
|
||||||
|
for entry in fs::read_dir(&target_dir)
|
||||||
|
.with_context(|| format!("Failed to read target directory: {}", target_dir.display()))?
|
||||||
|
{
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Skip directories and non-executable files
|
||||||
|
if path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an executable (no extension on Linux, or special check)
|
||||||
|
let file_name = path.file_name().unwrap().to_string_lossy();
|
||||||
|
|
||||||
|
// Skip common non-binary files
|
||||||
|
if file_name.ends_with(".d")
|
||||||
|
|| file_name.ends_with(".rlib")
|
||||||
|
|| file_name.ends_with(".rmeta")
|
||||||
|
|| file_name.contains(".so")
|
||||||
|
|| file_name.starts_with("lib")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is executable
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if path
|
||||||
|
.metadata()
|
||||||
|
.ok()
|
||||||
|
.filter(|m| m.permissions().mode() & 0o111 != 0 && m.is_file())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let dest = bin_dir.join(&*file_name);
|
||||||
|
println!(" Installing: {}", file_name);
|
||||||
|
fs::copy(&path, &dest).with_context(|| {
|
||||||
|
format!("Failed to copy {} to {}", path.display(), dest.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Preserve executable permission
|
||||||
|
let mut perms = fs::metadata(&dest)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(&dest, perms)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run post-install hooks
|
||||||
|
hooks::run_post_install_commands(spec, src_dir, destdir)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Executable
+371
@@ -0,0 +1,371 @@
|
|||||||
|
//! Global configuration for Depot
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Global configuration settings
|
||||||
|
pub struct Config {
|
||||||
|
/// Directory for cached source tarballs
|
||||||
|
pub cache_dir: PathBuf,
|
||||||
|
/// Directory for building packages
|
||||||
|
pub build_dir: PathBuf,
|
||||||
|
/// Directory for package database
|
||||||
|
pub db_dir: PathBuf,
|
||||||
|
/// System-level build overrides from /etc/depot.d/build.toml
|
||||||
|
pub build_overrides: toml::Value,
|
||||||
|
/// System-level package overrides from /etc/depot.d/package.toml
|
||||||
|
pub package_overrides: toml::Value,
|
||||||
|
/// Appends found in system TOML files (key -> values to append)
|
||||||
|
pub appends: HashMap<String, Vec<toml::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Create config with paths relative to the given rootfs
|
||||||
|
pub fn for_rootfs(rootfs: &Path) -> Self {
|
||||||
|
let abs_rootfs = if rootfs.exists() {
|
||||||
|
rootfs.canonicalize().unwrap_or_else(|_| {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map(|cwd| cwd.join(rootfs))
|
||||||
|
.unwrap_or_else(|_| rootfs.to_path_buf())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map(|cwd| cwd.join(rootfs))
|
||||||
|
.unwrap_or_else(|_| rootfs.to_path_buf())
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_system_root = abs_rootfs == Path::new("/") || abs_rootfs.as_os_str() == "/";
|
||||||
|
let is_root = crate::fakeroot::is_root();
|
||||||
|
|
||||||
|
let (cache_dir, build_dir, db_dir) = if is_system_root && !is_root {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/tmp"));
|
||||||
|
(
|
||||||
|
home.join(".cache/depot/sources"),
|
||||||
|
home.join(".cache/depot/build"),
|
||||||
|
home.join(".local/share/depot"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
abs_rootfs.join("var/cache/depot/sources"),
|
||||||
|
abs_rootfs.join("var/cache/depot/build"),
|
||||||
|
abs_rootfs.join("var/lib/depot"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = Self {
|
||||||
|
cache_dir,
|
||||||
|
build_dir,
|
||||||
|
db_dir,
|
||||||
|
build_overrides: toml::Value::Table(toml::map::Map::new()),
|
||||||
|
package_overrides: toml::Value::Table(toml::map::Map::new()),
|
||||||
|
appends: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = config.load_system(&abs_rootfs) {
|
||||||
|
eprintln!("Warning: Failed to load system config: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load system-level and user-level overrides
|
||||||
|
pub fn load_system(&mut self, rootfs: &Path) -> Result<()> {
|
||||||
|
let mut config_paths = vec![rootfs.join("etc/depot.toml")];
|
||||||
|
|
||||||
|
// Add user-level config paths if we are not root or if HOME is set
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let home_path = PathBuf::from(home);
|
||||||
|
config_paths.push(home_path.join(".config/depot.toml"));
|
||||||
|
config_paths.push(home_path.join(".local/share/depot.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in config_paths {
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("Failed to read config: {}", path.display()))?;
|
||||||
|
let (val, appends) = self.preprocess_toml(&content)?;
|
||||||
|
|
||||||
|
// If it has a [build] section, merge it into build_overrides
|
||||||
|
if let Some(build) = val.get("build") {
|
||||||
|
merge_toml_values(&mut self.build_overrides, build);
|
||||||
|
}
|
||||||
|
// If it has a [package] section, merge it into package_overrides
|
||||||
|
if let Some(pkg) = val.get("package") {
|
||||||
|
merge_toml_values(&mut self.package_overrides, pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (k, v) in appends {
|
||||||
|
self.appends.insert(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep existing etc/depot.d/ support for backward compatibility/modular config
|
||||||
|
let build_path = rootfs.join("etc/depot.d/build.toml");
|
||||||
|
if build_path.exists() {
|
||||||
|
let content = fs::read_to_string(&build_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to read system build config: {}",
|
||||||
|
build_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (val, appends) = self.preprocess_toml(&content)?;
|
||||||
|
merge_toml_values(&mut self.build_overrides, &val);
|
||||||
|
for (k, v) in appends {
|
||||||
|
self.appends.insert(format!("build.{}", k), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_path = rootfs.join("etc/depot.d/package.toml");
|
||||||
|
if package_path.exists() {
|
||||||
|
let content = fs::read_to_string(&package_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to read system package config: {}",
|
||||||
|
package_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (val, appends) = self.preprocess_toml(&content)?;
|
||||||
|
merge_toml_values(&mut self.package_overrides, &val);
|
||||||
|
for (k, v) in appends {
|
||||||
|
self.appends.insert(format!("package.{}", k), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_toml_values(base: &mut toml::Value, over: &toml::Value) {
|
||||||
|
if let (Some(base_table), Some(over_table)) = (base.as_table_mut(), over.as_table()) {
|
||||||
|
for (k, v) in over_table {
|
||||||
|
if v.is_table() && base_table.contains_key(k) && base_table[k].is_table() {
|
||||||
|
merge_toml_values(&mut base_table[k], v);
|
||||||
|
} else {
|
||||||
|
base_table.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Preprocess TOML to support `key += value` syntax.
|
||||||
|
/// Returns the base toml::Value and a map of append operations.
|
||||||
|
fn preprocess_toml(
|
||||||
|
&self,
|
||||||
|
input: &str,
|
||||||
|
) -> Result<(toml::Value, HashMap<String, Vec<toml::Value>>)> {
|
||||||
|
let mut base_text = String::new();
|
||||||
|
let mut appends = HashMap::new();
|
||||||
|
|
||||||
|
for line in input.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
|
base_text.push_str(line);
|
||||||
|
base_text.push('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(plus_idx) = trimmed.find("+=") {
|
||||||
|
let key = trimmed[..plus_idx].trim().to_string();
|
||||||
|
let val_str = trimmed[plus_idx + 2..].trim();
|
||||||
|
let val: toml::Value = toml::from_str::<toml::Value>(&format!("v = {}", val_str))
|
||||||
|
.context("Failed to parse append value")?
|
||||||
|
.get("v")
|
||||||
|
.cloned()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
appends.entry(key).or_insert_with(Vec::new).push(val);
|
||||||
|
} else {
|
||||||
|
base_text.push_str(line);
|
||||||
|
base_text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_val: toml::Value = toml::from_str::<toml::Value>(&base_text)?;
|
||||||
|
Ok((base_val, appends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::for_rootfs(Path::new("/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_for_rootfs() {
|
||||||
|
let root = PathBuf::from("/tmp/test_root");
|
||||||
|
let config = Config::for_rootfs(&root);
|
||||||
|
|
||||||
|
// Canonicalization might happen, so let's just check ends_with or construct reliably
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.cache_dir
|
||||||
|
.to_string_lossy()
|
||||||
|
.contains("var/cache/depot/sources")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.build_dir
|
||||||
|
.to_string_lossy()
|
||||||
|
.contains("var/cache/depot/build")
|
||||||
|
);
|
||||||
|
assert!(config.db_dir.to_string_lossy().contains("var/lib/depot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_preprocess_toml() {
|
||||||
|
let config = Config::for_rootfs(Path::new("/tmp/nonexistent"));
|
||||||
|
let input = r#"
|
||||||
|
[flags]
|
||||||
|
cc = "clang"
|
||||||
|
cflags = ["-O2"]
|
||||||
|
|
||||||
|
# An append operation
|
||||||
|
cflags += ["-DDEBUG"]
|
||||||
|
ldflags += "-L/usr/local/lib"
|
||||||
|
"#;
|
||||||
|
let (base, appends) = config.preprocess_toml(input).unwrap();
|
||||||
|
|
||||||
|
// Base value should have the non-append parts
|
||||||
|
assert_eq!(
|
||||||
|
base.get("flags")
|
||||||
|
.and_then(|f| f.get("cc"))
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("clang")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base.get("flags")
|
||||||
|
.and_then(|f| f.get("cflags"))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.len()),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Appends should be captured
|
||||||
|
assert!(appends.contains_key("cflags"));
|
||||||
|
assert_eq!(appends.get("cflags").unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
appends.get("cflags").unwrap()[0].as_array().unwrap()[0].as_str(),
|
||||||
|
Some("-DDEBUG")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(appends.contains_key("ldflags"));
|
||||||
|
assert_eq!(
|
||||||
|
appends.get("ldflags").unwrap()[0].as_str(),
|
||||||
|
Some("-L/usr/local/lib")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_system() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let etc = root.join("etc/depot.d");
|
||||||
|
fs::create_dir_all(&etc).unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
etc.join("build.toml"),
|
||||||
|
r#"
|
||||||
|
[flags]
|
||||||
|
cflags = ["-O2"]
|
||||||
|
cflags += ["-g"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = Config::for_rootfs(root);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.build_overrides
|
||||||
|
.get("flags")
|
||||||
|
.and_then(|f| f.get("cflags"))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.len()),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert!(config.appends.contains_key("build.cflags"));
|
||||||
|
assert_eq!(
|
||||||
|
config.appends.get("build.cflags").unwrap()[0]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()[0]
|
||||||
|
.as_str(),
|
||||||
|
Some("-g")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_non_root_fallback() {
|
||||||
|
// We can't easily mock is_root() here without more complex infrastructure,
|
||||||
|
// but we can at least check if the logic for abs_rootfs == "/" triggers
|
||||||
|
// the HOME join if we were non-root.
|
||||||
|
let config = Config::for_rootfs(Path::new("/"));
|
||||||
|
|
||||||
|
if !crate::fakeroot::is_root() {
|
||||||
|
let home = std::env::var("HOME").unwrap();
|
||||||
|
assert!(config.cache_dir.to_string_lossy().starts_with(&home));
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.cache_dir
|
||||||
|
.to_string_lossy()
|
||||||
|
.contains(".cache/depot/sources")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If running as root (e.g. in some CI), it should use /var
|
||||||
|
assert_eq!(config.cache_dir, PathBuf::from("/var/cache/depot/sources"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_depot_toml() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
let etc = root.join("etc");
|
||||||
|
fs::create_dir_all(&etc).unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
etc.join("depot.toml"),
|
||||||
|
r#"
|
||||||
|
[build]
|
||||||
|
prefix = "/opt/depot"
|
||||||
|
cc = "clang"
|
||||||
|
|
||||||
|
[build.flags]
|
||||||
|
cflags = ["-O3"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = Config::for_rootfs(root);
|
||||||
|
// Config construction calls load_system automatically
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.build_overrides
|
||||||
|
.get("prefix")
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some("/opt/depot")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.build_overrides.get("cc").and_then(|v| v.as_str()),
|
||||||
|
Some("clang")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.build_overrides
|
||||||
|
.get("flags")
|
||||||
|
.and_then(|f| f.get("cflags"))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.len()),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+233
@@ -0,0 +1,233 @@
|
|||||||
|
//! Cross-compilation support
|
||||||
|
//!
|
||||||
|
//! Provides configuration for cross-compiling packages with a target triplet prefix.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Cross-compilation configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CrossConfig {
|
||||||
|
/// Target triplet prefix (e.g., "x86_64-linux-musl")
|
||||||
|
pub prefix: String,
|
||||||
|
/// C compiler
|
||||||
|
pub cc: String,
|
||||||
|
/// C++ compiler
|
||||||
|
pub cxx: String,
|
||||||
|
/// Archiver
|
||||||
|
pub ar: String,
|
||||||
|
/// Ranlib
|
||||||
|
pub ranlib: String,
|
||||||
|
/// Strip
|
||||||
|
pub strip: String,
|
||||||
|
/// Linker (ld)
|
||||||
|
pub ld: String,
|
||||||
|
/// nm
|
||||||
|
pub nm: String,
|
||||||
|
/// objcopy
|
||||||
|
pub objcopy: String,
|
||||||
|
/// objdump
|
||||||
|
pub objdump: String,
|
||||||
|
/// readelf
|
||||||
|
pub readelf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrossConfig {
|
||||||
|
/// Create a cross-compilation config by discovering tools in PATH
|
||||||
|
pub fn from_prefix(prefix: &str) -> Result<Self> {
|
||||||
|
let cc = find_tool(prefix, &["gcc", "clang"], true)?;
|
||||||
|
let cxx = find_tool(prefix, &["g++", "clang++"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-g++", prefix));
|
||||||
|
let ar = find_tool(prefix, &["ar", "llvm-ar"], true)?;
|
||||||
|
let ranlib = find_tool(prefix, &["ranlib", "llvm-ranlib"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-ranlib", prefix));
|
||||||
|
let strip = find_tool(prefix, &["strip", "llvm-strip"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-strip", prefix));
|
||||||
|
let ld = find_tool(prefix, &["ld", "ld.lld"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-ld", prefix));
|
||||||
|
let nm = find_tool(prefix, &["nm", "llvm-nm"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-nm", prefix));
|
||||||
|
let objcopy = find_tool(prefix, &["objcopy", "llvm-objcopy"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-objcopy", prefix));
|
||||||
|
let objdump = find_tool(prefix, &["objdump", "llvm-objdump"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-objdump", prefix));
|
||||||
|
let readelf = find_tool(prefix, &["readelf", "llvm-readelf"], false)
|
||||||
|
.unwrap_or_else(|_| format!("{}-readelf", prefix));
|
||||||
|
|
||||||
|
println!("Cross-compilation tools discovered:");
|
||||||
|
println!(" CC: {}", cc);
|
||||||
|
println!(" CXX: {}", cxx);
|
||||||
|
println!(" AR: {}", ar);
|
||||||
|
println!(" RANLIB: {}", ranlib);
|
||||||
|
println!(" STRIP: {}", strip);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
cc,
|
||||||
|
cxx,
|
||||||
|
ar,
|
||||||
|
ranlib,
|
||||||
|
strip,
|
||||||
|
ld,
|
||||||
|
nm,
|
||||||
|
objcopy,
|
||||||
|
objdump,
|
||||||
|
readelf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the host triple for autotools --host flag
|
||||||
|
pub fn host_triple(&self) -> &str {
|
||||||
|
&self.prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the build machine triple (native compiler)
|
||||||
|
pub fn build_triple() -> Result<String> {
|
||||||
|
let output = Command::new("gcc")
|
||||||
|
.arg("-dumpmachine")
|
||||||
|
.output()
|
||||||
|
.or_else(|_| Command::new("cc").arg("-dumpmachine").output())
|
||||||
|
.context("Failed to determine build machine triple")?;
|
||||||
|
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a CMake toolchain file for cross-compilation
|
||||||
|
pub fn generate_cmake_toolchain(&self, build_dir: &Path) -> Result<PathBuf> {
|
||||||
|
let toolchain_path = build_dir.join("cross-toolchain.cmake");
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
r#"# CMake toolchain file for cross-compilation
|
||||||
|
# Generated by nyapm for target: {}
|
||||||
|
|
||||||
|
set(CMAKE_SYSTEM_NAME Linux)
|
||||||
|
set(CMAKE_SYSTEM_PROCESSOR {})
|
||||||
|
|
||||||
|
set(CMAKE_C_COMPILER {})
|
||||||
|
set(CMAKE_CXX_COMPILER {})
|
||||||
|
set(CMAKE_AR {} CACHE FILEPATH "Archiver")
|
||||||
|
set(CMAKE_RANLIB {} CACHE FILEPATH "Ranlib")
|
||||||
|
set(CMAKE_STRIP {} CACHE FILEPATH "Strip")
|
||||||
|
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||||
|
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||||
|
"#,
|
||||||
|
self.prefix,
|
||||||
|
self.target_arch(),
|
||||||
|
self.cc,
|
||||||
|
self.cxx,
|
||||||
|
self.ar,
|
||||||
|
self.ranlib,
|
||||||
|
self.strip,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::create_dir_all(build_dir)?;
|
||||||
|
let mut file = fs::File::create(&toolchain_path)?;
|
||||||
|
file.write_all(content.as_bytes())?;
|
||||||
|
|
||||||
|
Ok(toolchain_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a Meson cross file for cross-compilation
|
||||||
|
pub fn generate_meson_cross_file(&self, build_dir: &Path) -> Result<PathBuf> {
|
||||||
|
let cross_path = build_dir.join("cross-file.ini");
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
r#"# Meson cross file for cross-compilation
|
||||||
|
# Generated by nyapm for target: {}
|
||||||
|
|
||||||
|
[binaries]
|
||||||
|
c = '{}'
|
||||||
|
cpp = '{}'
|
||||||
|
ar = '{}'
|
||||||
|
strip = '{}'
|
||||||
|
ld = '{}'
|
||||||
|
nm = '{}'
|
||||||
|
objcopy = '{}'
|
||||||
|
objdump = '{}'
|
||||||
|
readelf = '{}'
|
||||||
|
|
||||||
|
[host_machine]
|
||||||
|
system = 'linux'
|
||||||
|
cpu_family = '{}'
|
||||||
|
cpu = '{}'
|
||||||
|
endian = 'little'
|
||||||
|
"#,
|
||||||
|
self.prefix,
|
||||||
|
self.cc,
|
||||||
|
self.cxx,
|
||||||
|
self.ar,
|
||||||
|
self.strip,
|
||||||
|
self.ld,
|
||||||
|
self.nm,
|
||||||
|
self.objcopy,
|
||||||
|
self.objdump,
|
||||||
|
self.readelf,
|
||||||
|
self.cpu_family(),
|
||||||
|
self.target_arch(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::create_dir_all(build_dir)?;
|
||||||
|
let mut file = fs::File::create(&cross_path)?;
|
||||||
|
file.write_all(content.as_bytes())?;
|
||||||
|
|
||||||
|
Ok(cross_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract target architecture from prefix
|
||||||
|
fn target_arch(&self) -> &str {
|
||||||
|
self.prefix.split('-').next().unwrap_or("unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get CPU family for Meson
|
||||||
|
fn cpu_family(&self) -> &str {
|
||||||
|
let arch = self.target_arch();
|
||||||
|
match arch {
|
||||||
|
"x86_64" | "amd64" => "x86_64",
|
||||||
|
"i686" | "i586" | "i486" | "i386" => "x86",
|
||||||
|
"aarch64" | "arm64" => "aarch64",
|
||||||
|
"arm" | "armv7" | "armv7l" => "arm",
|
||||||
|
"riscv64" => "riscv64",
|
||||||
|
"riscv32" => "riscv32",
|
||||||
|
"powerpc64" | "ppc64" => "ppc64",
|
||||||
|
"powerpc" | "ppc" => "ppc",
|
||||||
|
"mips64" => "mips64",
|
||||||
|
"mips" => "mips",
|
||||||
|
_ => arch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a cross-compilation tool in PATH
|
||||||
|
fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result<String> {
|
||||||
|
for suffix in suffixes {
|
||||||
|
let tool_name = format!("{}-{}", prefix, suffix);
|
||||||
|
if tool_exists(&tool_name) {
|
||||||
|
return Ok(tool_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if required {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Could not find cross tool: {}-{{{}}}",
|
||||||
|
prefix,
|
||||||
|
suffixes.join("|")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!("Tool not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tool exists in PATH
|
||||||
|
fn tool_exists(name: &str) -> bool {
|
||||||
|
Command::new("which")
|
||||||
|
.arg(name)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
Executable
+581
@@ -0,0 +1,581 @@
|
|||||||
|
//! SQLite-based package database
|
||||||
|
|
||||||
|
pub mod repo;
|
||||||
|
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use crate::staging;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Initialize database and register a package
|
||||||
|
pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> Result<()> {
|
||||||
|
// Create parent directory (auto-create db dir if missing)
|
||||||
|
if let Some(parent) = db_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = Connection::open(db_path)?;
|
||||||
|
init_db(&conn)?;
|
||||||
|
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
|
||||||
|
// Generate manifest with files and directories
|
||||||
|
let manifest = staging::generate_manifest_with_dirs(destdir)?;
|
||||||
|
|
||||||
|
// Insert/update package without changing its primary key (UPSERT keeps the existing row).
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO packages (name, version, revision, description, homepage, license)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
version=excluded.version,
|
||||||
|
revision=excluded.revision,
|
||||||
|
description=excluded.description,
|
||||||
|
homepage=excluded.homepage,
|
||||||
|
license=excluded.license",
|
||||||
|
params![
|
||||||
|
spec.package.name,
|
||||||
|
spec.package.version,
|
||||||
|
spec.package.revision,
|
||||||
|
spec.package.description,
|
||||||
|
spec.package.homepage,
|
||||||
|
spec.package.license,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let pkg_id: i64 = tx.query_row(
|
||||||
|
"SELECT id FROM packages WHERE name = ?1",
|
||||||
|
params![spec.package.name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Replace provides + file + directory lists for this package.
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM provides WHERE package_id = ?1",
|
||||||
|
params![pkg_id],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM directories WHERE package_id = ?1",
|
||||||
|
params![pkg_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Insert provides
|
||||||
|
for provides in &spec.alternatives.provides {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO provides (package_id, provides_name) VALUES (?1, ?2)",
|
||||||
|
params![pkg_id, provides],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert files
|
||||||
|
for file in &manifest.files {
|
||||||
|
// Enforce single-owner semantics for file paths.
|
||||||
|
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()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Registered {} files and {} directories in database",
|
||||||
|
manifest.files.len(),
|
||||||
|
manifest.directories.len()
|
||||||
|
);
|
||||||
|
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)?;
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)?;
|
||||||
|
|
||||||
|
// 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(()) => {
|
||||||
|
println!(" Removed file: {}", file);
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
// Already gone, keep going.
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
removal_errors.push(format!("{}: {}", file, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove directories (only if empty and not owned by another package)
|
||||||
|
let mut dirs_removed = 0;
|
||||||
|
for dir in &directories {
|
||||||
|
let path = rootfs.join(dir);
|
||||||
|
|
||||||
|
// Skip if outside rootfs
|
||||||
|
if !path.starts_with(rootfs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory is owned by another package
|
||||||
|
let other_owners: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM directories WHERE path = ?1 AND package_id != ?2",
|
||||||
|
params![dir, pkg_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if other_owners > 0 {
|
||||||
|
println!(" Keeping directory (owned by other package): {}", dir);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to remove (will fail if not empty, which is fine)
|
||||||
|
match fs::remove_dir(&path) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!(" Removed directory: {}", dir);
|
||||||
|
dirs_removed += 1;
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
// Already gone
|
||||||
|
}
|
||||||
|
Err(e) if e.raw_os_error() == Some(39) || e.raw_os_error() == Some(66) => {
|
||||||
|
// ENOTEMPTY (39 on Linux, 66 on macOS) - directory not empty
|
||||||
|
println!(" Keeping directory (not empty): {}", dir);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Other errors (permission, etc.) - just skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from database
|
||||||
|
conn.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM directories WHERE package_id = ?1",
|
||||||
|
params![pkg_id],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provides WHERE package_id = ?1",
|
||||||
|
params![pkg_id],
|
||||||
|
)?;
|
||||||
|
conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Removed {} files and {} directories",
|
||||||
|
files.len(),
|
||||||
|
dirs_removed
|
||||||
|
);
|
||||||
|
|
||||||
|
if !removal_errors.is_empty() {
|
||||||
|
eprintln!("Warning: failed to remove some paths:");
|
||||||
|
for err in removal_errors {
|
||||||
|
eprintln!(" {}", 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)?;
|
||||||
|
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
println!("Package: {} v{}-{}", name, version, revision);
|
||||||
|
println!("Description: {}", description);
|
||||||
|
println!("Homepage: {}", homepage);
|
||||||
|
println!("License: {}", license);
|
||||||
|
|
||||||
|
// Count files
|
||||||
|
let file_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM files f JOIN packages p ON f.package_id = p.id WHERE p.name = ?1",
|
||||||
|
params![name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!("Files: {}", file_count);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all installed packages
|
||||||
|
pub fn list_packages(db_path: &Path) -> Result<()> {
|
||||||
|
if !db_path.exists() {
|
||||||
|
println!("No packages installed.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT name, version FROM packages ORDER BY name")?;
|
||||||
|
let packages = stmt.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!("{:<30} VERSION", "PACKAGE");
|
||||||
|
println!("{}", "-".repeat(50));
|
||||||
|
|
||||||
|
for pkg in packages {
|
||||||
|
let (name, version) = pkg?;
|
||||||
|
println!("{:<30} {}", name, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_db(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS packages (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
description TEXT,
|
||||||
|
homepage TEXT,
|
||||||
|
license TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS provides (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
package_id INTEGER NOT NULL,
|
||||||
|
provides_name TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (package_id) REFERENCES packages(id),
|
||||||
|
UNIQUE(package_id, provides_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
package_id INTEGER NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (package_id) REFERENCES packages(id),
|
||||||
|
UNIQUE(path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS directories (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
package_id INTEGER NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (package_id) REFERENCES packages(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_package ON files(package_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provides_name ON provides(provides_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_directories_package ON directories(package_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate which files need to be removed during an upgrade.
|
||||||
|
/// Returns paths that exist in the old version but NOT in the new version.
|
||||||
|
pub fn calculate_upgrade_paths(
|
||||||
|
db_path: &Path,
|
||||||
|
name: &str,
|
||||||
|
new_files: &[String],
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let old_files = get_package_files(db_path, name)?;
|
||||||
|
let mut new_set = std::collections::HashSet::new();
|
||||||
|
for f in new_files {
|
||||||
|
new_set.insert(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remove_paths: Vec<String> = old_files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| !new_set.contains(p))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(remove_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::package::{
|
||||||
|
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn mk_spec(name: &str, version: &str) -> PackageSpec {
|
||||||
|
PackageSpec {
|
||||||
|
package: PackageInfo {
|
||||||
|
name: name.into(),
|
||||||
|
version: version.into(),
|
||||||
|
revision: 1,
|
||||||
|
description: "d".into(),
|
||||||
|
homepage: "h".into(),
|
||||||
|
license: "MIT".into(),
|
||||||
|
},
|
||||||
|
alternatives: Alternatives {
|
||||||
|
provides: vec![format!("{}-virtual", name)],
|
||||||
|
replaces: Vec::new(),
|
||||||
|
},
|
||||||
|
manual_sources: Vec::new(),
|
||||||
|
source: vec![Source {
|
||||||
|
url: "https://example.com/foo.tar.gz".into(),
|
||||||
|
sha256: "skip".into(),
|
||||||
|
extract_dir: "foo".into(),
|
||||||
|
patches: Vec::new(),
|
||||||
|
post_extract: Vec::new(),
|
||||||
|
}],
|
||||||
|
build: Build {
|
||||||
|
build_type: BuildType::Custom,
|
||||||
|
flags: BuildFlags::default(),
|
||||||
|
},
|
||||||
|
dependencies: Dependencies::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 remove_package_tolerates_missing_files_and_cleans_db() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = tmp.path().join("packages.db");
|
||||||
|
let rootfs = tmp.path().join("root");
|
||||||
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||||||
|
|
||||||
|
let spec = mk_spec("foo", "1.0");
|
||||||
|
let dest = tmp.path().join("dest");
|
||||||
|
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||||
|
std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap();
|
||||||
|
register_package(&db_path, &spec, &dest).unwrap();
|
||||||
|
|
||||||
|
// Create the installed file in rootfs (one real)
|
||||||
|
std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap();
|
||||||
|
std::fs::write(rootfs.join("usr/bin/foo"), "bin").unwrap();
|
||||||
|
|
||||||
|
// Inject an extra missing file into DB to ensure we tolerate it.
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
let pkg_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM packages WHERE name = ?1",
|
||||||
|
params!["foo"],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO files (package_id, path) VALUES (?1, ?2)",
|
||||||
|
params![pkg_id, "usr/bin/does_not_exist"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
remove_package(&db_path, "foo", &rootfs).unwrap();
|
||||||
|
assert!(get_package_version(&db_path, "foo").unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_package_upgrade_removes_orphaned_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = tmp.path().join("packages.db");
|
||||||
|
let rootfs = tmp.path().join("root");
|
||||||
|
let tx_base = tmp.path().join("tx");
|
||||||
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||||||
|
|
||||||
|
// 1. Install v1: usr/bin/foo, usr/bin/shared_dir/old_file
|
||||||
|
let spec_v1 = mk_spec("foo", "1.0");
|
||||||
|
let dest1 = tmp.path().join("dest1");
|
||||||
|
std::fs::create_dir_all(dest1.join("usr/bin/shared_dir")).unwrap();
|
||||||
|
std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap();
|
||||||
|
std::fs::write(dest1.join("usr/bin/shared_dir/old_file"), "old").unwrap();
|
||||||
|
|
||||||
|
register_package(&db_path, &spec_v1, &dest1).unwrap();
|
||||||
|
let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[]).unwrap();
|
||||||
|
|
||||||
|
assert!(rootfs.join("usr/bin/foo").exists());
|
||||||
|
assert!(rootfs.join("usr/bin/shared_dir/old_file").exists());
|
||||||
|
|
||||||
|
// 2. Prepare v2: usr/bin/foo (updated), usr/bin/new_file
|
||||||
|
// (shared_dir/old_file is removed from spec)
|
||||||
|
let spec_v2 = mk_spec("foo", "2.0");
|
||||||
|
let dest2 = tmp.path().join("dest2");
|
||||||
|
std::fs::create_dir_all(dest2.join("usr/bin")).unwrap();
|
||||||
|
std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap();
|
||||||
|
std::fs::write(dest2.join("usr/bin/new_file"), "new").unwrap();
|
||||||
|
|
||||||
|
let manifest2 = crate::staging::generate_manifest_with_dirs(&dest2).unwrap();
|
||||||
|
let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2.files).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
remove_paths,
|
||||||
|
vec!["usr/bin/shared_dir/old_file".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let tx = crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths).unwrap();
|
||||||
|
register_package(&db_path, &spec_v2, &dest2).unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
|
||||||
|
// 3. Verify filesystem
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
|
||||||
|
"v2"
|
||||||
|
);
|
||||||
|
assert!(rootfs.join("usr/bin/new_file").exists());
|
||||||
|
assert!(!rootfs.join("usr/bin/shared_dir/old_file").exists());
|
||||||
|
|
||||||
|
// Check DB
|
||||||
|
let files = get_package_files(&db_path, "foo").unwrap();
|
||||||
|
assert!(files.contains(&"usr/bin/foo".to_string()));
|
||||||
|
assert!(files.contains(&"usr/bin/new_file".to_string()));
|
||||||
|
assert!(!files.contains(&"usr/bin/shared_dir/old_file".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get set of all installed package names
|
||||||
|
pub fn get_installed_packages(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
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 version of a specific installed package
|
||||||
|
pub fn get_package_version(db_path: &Path, name: &str) -> Result<Option<String>> {
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
let version: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT version FROM packages WHERE name = ?1",
|
||||||
|
params![name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
//! Repository management and SQLite database generation
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use zstd::stream::write::Encoder;
|
||||||
|
|
||||||
|
pub struct RepoManager {
|
||||||
|
pub repo_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoManager {
|
||||||
|
pub fn new(repo_dir: PathBuf) -> Self {
|
||||||
|
Self { repo_dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a compressed SQLite repository database from a directory of packages
|
||||||
|
pub fn create_repo_db(&self) -> Result<PathBuf> {
|
||||||
|
let db_path = self.repo_dir.join("repo.db");
|
||||||
|
let compressed_db_path = self.repo_dir.join("repo.db.zst");
|
||||||
|
|
||||||
|
// Remove existing DB if it exists
|
||||||
|
if db_path.exists() {
|
||||||
|
fs::remove_file(&db_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = Connection::open(&db_path)
|
||||||
|
.with_context(|| format!("Failed to create repo database at {}", db_path.display()))?;
|
||||||
|
|
||||||
|
self.init_repo_schema(&mut conn)?;
|
||||||
|
|
||||||
|
// Find all .depot.pkg.tar.zst files in repo_dir
|
||||||
|
for entry in fs::read_dir(&self.repo_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.to_string_lossy().ends_with(".depot.pkg.tar.zst") {
|
||||||
|
self.index_package(&mut conn, &path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.close().map_err(|(_, e)| e)?;
|
||||||
|
|
||||||
|
// Compress the database
|
||||||
|
self.compress_db(&db_path, &compressed_db_path)?;
|
||||||
|
|
||||||
|
// Remove the uncompressed DB
|
||||||
|
fs::remove_file(&db_path)?;
|
||||||
|
|
||||||
|
Ok(compressed_db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_repo_schema(&self, conn: &mut Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE packages (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
homepage TEXT,
|
||||||
|
license TEXT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE provides (
|
||||||
|
package_id INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(package_id) REFERENCES packages(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_packages_name ON packages(name);
|
||||||
|
CREATE INDEX idx_provides_name ON provides(name);",
|
||||||
|
)
|
||||||
|
.context("Failed to initialize repo schema")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_package(&self, conn: &mut Connection, pkg_path: &Path) -> Result<()> {
|
||||||
|
println!("Indexing package {}...", pkg_path.display());
|
||||||
|
|
||||||
|
let filename = pkg_path.file_name().unwrap().to_string_lossy();
|
||||||
|
let size = pkg_path.metadata()?.len();
|
||||||
|
let sha256 = self.calculate_sha256(pkg_path)?;
|
||||||
|
|
||||||
|
// Read .metadata.toml from archive
|
||||||
|
let file = fs::File::open(pkg_path)?;
|
||||||
|
let zstd_decoder = zstd::stream::read::Decoder::new(file)?;
|
||||||
|
let mut archive = tar::Archive::new(zstd_decoder);
|
||||||
|
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut version = String::new();
|
||||||
|
let mut revision = 1;
|
||||||
|
let mut description = None;
|
||||||
|
let mut homepage = None;
|
||||||
|
let mut license = None;
|
||||||
|
let mut provides = Vec::new();
|
||||||
|
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
let path = entry.path()?;
|
||||||
|
if path.to_string_lossy() == ".metadata.toml" {
|
||||||
|
let mut content = String::new();
|
||||||
|
use std::io::Read;
|
||||||
|
entry.read_to_string(&mut content)?;
|
||||||
|
let metadata: toml::Value = toml::from_str(&content).with_context(|| {
|
||||||
|
format!("Failed to parse .metadata.toml in {}", pkg_path.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
name = metadata
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
version = metadata
|
||||||
|
.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
revision = metadata
|
||||||
|
.get("revision")
|
||||||
|
.and_then(|v| v.as_integer())
|
||||||
|
.unwrap_or(1) as u32;
|
||||||
|
description = metadata
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
homepage = metadata
|
||||||
|
.get("homepage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
license = metadata
|
||||||
|
.get("license")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
if let Some(provides_arr) = metadata.get("provides").and_then(|v| v.as_array()) {
|
||||||
|
provides = provides_arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
// Fallback for packages WITHOUT metadata (e.g. legacy or during transition)
|
||||||
|
let name_parts: Vec<&str> = filename.split('-').collect();
|
||||||
|
if name_parts.len() < 4 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Invalid package filename and no .metadata.toml: {}",
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
name = name_parts[0].to_string();
|
||||||
|
version = name_parts[1].to_string();
|
||||||
|
revision = name_parts[2].parse().unwrap_or(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO packages (name, version, revision, description, homepage, license, filename, size, sha256)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
revision as i64,
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
license,
|
||||||
|
filename,
|
||||||
|
size as i64,
|
||||||
|
sha256
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let package_id = conn.last_insert_rowid();
|
||||||
|
|
||||||
|
// Insert into provides
|
||||||
|
for provide in provides {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO provides (package_id, name) VALUES (?1, ?2)",
|
||||||
|
params![package_id, provide],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_sha256(&self, path: &Path) -> Result<String> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut file = fs::File::open(path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
std::io::copy(&mut file, &mut hasher)?;
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_db(&self, source: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let mut input = fs::File::open(source)?;
|
||||||
|
let output = fs::File::create(dest)?;
|
||||||
|
let mut encoder = Encoder::new(output, 19)?; // High compression for repo DB
|
||||||
|
encoder.multithread(num_cpus() as u32)?;
|
||||||
|
std::io::copy(&mut input, &mut encoder)?;
|
||||||
|
encoder.finish()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_init_repo_schema() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
let manager = RepoManager::new(PathBuf::from("."));
|
||||||
|
manager.init_repo_schema(&mut conn).unwrap();
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
let exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='packages'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_index_package() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let repo_dir = tmp.path();
|
||||||
|
let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst");
|
||||||
|
|
||||||
|
// Create a valid .tar.zst with .metadata.toml
|
||||||
|
let file = fs::File::create(&pkg_path).unwrap();
|
||||||
|
let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap();
|
||||||
|
let mut tar = tar::Builder::new(encoder);
|
||||||
|
|
||||||
|
let metadata = r#"
|
||||||
|
name = "test"
|
||||||
|
version = "1.0"
|
||||||
|
revision = 1
|
||||||
|
description = "test description"
|
||||||
|
homepage = "https://example.com"
|
||||||
|
license = "MIT"
|
||||||
|
provides = ["test-feature"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
build = []
|
||||||
|
runtime = []
|
||||||
|
"#;
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_path(".metadata.toml").unwrap();
|
||||||
|
header.set_size(metadata.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
tar.append(&header, metadata.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
let encoder = tar.into_inner().unwrap();
|
||||||
|
encoder.finish().unwrap();
|
||||||
|
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
let manager = RepoManager::new(repo_dir.to_path_buf());
|
||||||
|
manager.init_repo_schema(&mut conn).unwrap();
|
||||||
|
manager.index_package(&mut conn, &pkg_path).unwrap();
|
||||||
|
|
||||||
|
let (name, version, revision, desc, home, lic): (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
i64,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT name, version, revision, description, homepage, license FROM packages",
|
||||||
|
[],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get(0)?,
|
||||||
|
r.get(1)?,
|
||||||
|
r.get(2)?,
|
||||||
|
r.get(3)?,
|
||||||
|
r.get(4)?,
|
||||||
|
r.get(5)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(name, "test");
|
||||||
|
assert_eq!(version, "1.0");
|
||||||
|
assert_eq!(revision, 1);
|
||||||
|
assert_eq!(desc, Some("test description".to_string()));
|
||||||
|
assert_eq!(home, Some("https://example.com".to_string()));
|
||||||
|
assert_eq!(lic, Some("MIT".to_string()));
|
||||||
|
|
||||||
|
let provides_count: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM provides", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(provides_count, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+263
@@ -0,0 +1,263 @@
|
|||||||
|
//! Dependency resolution for packages
|
||||||
|
//!
|
||||||
|
//! Supports versioned dependencies with operators:
|
||||||
|
//! - `package` - any version
|
||||||
|
//! - `package#1.2.3` - exactly version 1.2.3
|
||||||
|
//! - `package>1.2.3` - greater than 1.2.3
|
||||||
|
//! - `package<1.2.3` - less than 1.2.3
|
||||||
|
//! - `package>=1.2.3` - greater than or equal to 1.2.3
|
||||||
|
//! - `package<=1.2.3` - less than or equal to 1.2.3
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Version comparison operator
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum VersionOp {
|
||||||
|
Exact, // #
|
||||||
|
Gt, // >
|
||||||
|
Lt, // <
|
||||||
|
Gte, // >=
|
||||||
|
Lte, // <=
|
||||||
|
Any, // no operator
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed dependency with optional version constraint
|
||||||
|
struct ParsedDep<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
version: Option<&'a str>,
|
||||||
|
op: VersionOp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a dependency string into name, version, and operator
|
||||||
|
fn parse_dep(dep: &str) -> ParsedDep<'_> {
|
||||||
|
// Try operators in order of specificity (>= before >, etc.)
|
||||||
|
if let Some((name, ver)) = dep.split_once(">=") {
|
||||||
|
return ParsedDep {
|
||||||
|
name,
|
||||||
|
version: Some(ver),
|
||||||
|
op: VersionOp::Gte,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some((name, ver)) = dep.split_once("<=") {
|
||||||
|
return ParsedDep {
|
||||||
|
name,
|
||||||
|
version: Some(ver),
|
||||||
|
op: VersionOp::Lte,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some((name, ver)) = dep.split_once('>') {
|
||||||
|
return ParsedDep {
|
||||||
|
name,
|
||||||
|
version: Some(ver),
|
||||||
|
op: VersionOp::Gt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some((name, ver)) = dep.split_once('<') {
|
||||||
|
return ParsedDep {
|
||||||
|
name,
|
||||||
|
version: Some(ver),
|
||||||
|
op: VersionOp::Lt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some((name, ver)) = dep.split_once('#') {
|
||||||
|
return ParsedDep {
|
||||||
|
name,
|
||||||
|
version: Some(ver),
|
||||||
|
op: VersionOp::Exact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedDep {
|
||||||
|
name: dep,
|
||||||
|
version: None,
|
||||||
|
op: VersionOp::Any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two version strings using semver if possible, fallback to string compare
|
||||||
|
fn compare_versions(installed: &str, required: &str, op: VersionOp) -> bool {
|
||||||
|
// Try semver comparison first
|
||||||
|
if let (Ok(inst), Ok(req)) = (
|
||||||
|
semver::Version::parse(installed),
|
||||||
|
semver::Version::parse(required),
|
||||||
|
) {
|
||||||
|
return match op {
|
||||||
|
VersionOp::Exact => inst == req,
|
||||||
|
VersionOp::Gt => inst > req,
|
||||||
|
VersionOp::Lt => inst < req,
|
||||||
|
VersionOp::Gte => inst >= req,
|
||||||
|
VersionOp::Lte => inst <= req,
|
||||||
|
VersionOp::Any => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string comparison for non-semver versions
|
||||||
|
match op {
|
||||||
|
VersionOp::Exact => installed == required,
|
||||||
|
VersionOp::Gt => installed > required,
|
||||||
|
VersionOp::Lt => installed < required,
|
||||||
|
VersionOp::Gte => installed >= required,
|
||||||
|
VersionOp::Lte => installed <= required,
|
||||||
|
VersionOp::Any => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a versioned dependency is satisfied
|
||||||
|
fn is_dep_satisfied(
|
||||||
|
dep: &str,
|
||||||
|
installed: &std::collections::HashSet<String>,
|
||||||
|
provides: &std::collections::HashSet<String>,
|
||||||
|
db_path: &Path,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let parsed = parse_dep(dep);
|
||||||
|
|
||||||
|
// Check if package is installed or provided
|
||||||
|
if !installed.contains(parsed.name) && !provides.contains(parsed.name) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no version required, we're good
|
||||||
|
let Some(required) = parsed.version else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check version matches
|
||||||
|
if let Some(installed_version) = db::get_package_version(db_path, parsed.name)? {
|
||||||
|
Ok(compare_versions(&installed_version, required, parsed.op))
|
||||||
|
} else {
|
||||||
|
// Package might be provided by an alternative, accept it
|
||||||
|
Ok(provides.contains(parsed.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if all build dependencies are satisfied
|
||||||
|
pub fn check_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
|
||||||
|
if !db_path.exists() {
|
||||||
|
return Ok(spec.dependencies.build.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = db::get_installed_packages(db_path)?;
|
||||||
|
let provides = db::get_all_provides(db_path)?;
|
||||||
|
|
||||||
|
for dep in &spec.dependencies.build {
|
||||||
|
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||||
|
missing.push(dep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if all runtime dependencies are satisfied
|
||||||
|
pub fn check_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
|
||||||
|
if !db_path.exists() {
|
||||||
|
return Ok(spec.dependencies.runtime.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = db::get_installed_packages(db_path)?;
|
||||||
|
let provides = db::get_all_provides(db_path)?;
|
||||||
|
|
||||||
|
for dep in &spec.dependencies.runtime {
|
||||||
|
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||||
|
missing.push(dep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print dependency status
|
||||||
|
pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> {
|
||||||
|
let missing_build = check_build_deps(spec, db_path)?;
|
||||||
|
let missing_runtime = check_runtime_deps(spec, db_path)?;
|
||||||
|
|
||||||
|
if !spec.dependencies.build.is_empty() {
|
||||||
|
println!("Build dependencies: {}", spec.dependencies.build.join(", "));
|
||||||
|
if !missing_build.is_empty() {
|
||||||
|
println!(" Missing: {}", missing_build.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !spec.dependencies.runtime.is_empty() {
|
||||||
|
println!(
|
||||||
|
"Runtime dependencies: {}",
|
||||||
|
spec.dependencies.runtime.join(", ")
|
||||||
|
);
|
||||||
|
if !missing_runtime.is_empty() {
|
||||||
|
println!(" Missing: {}", missing_runtime.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify all build dependencies are installed, error if not
|
||||||
|
pub fn require_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> {
|
||||||
|
let missing = check_build_deps(spec, db_path)?;
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Missing build dependencies: {}\nInstall them first with: nyapm install <package>",
|
||||||
|
missing.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dep() {
|
||||||
|
let cases = vec![
|
||||||
|
("package", "package", None, VersionOp::Any),
|
||||||
|
("pkg#1.0.0", "pkg", Some("1.0.0"), VersionOp::Exact),
|
||||||
|
("pkg>1.0", "pkg", Some("1.0"), VersionOp::Gt),
|
||||||
|
("pkg<2.0", "pkg", Some("2.0"), VersionOp::Lt),
|
||||||
|
("pkg>=1.5", "pkg", Some("1.5"), VersionOp::Gte),
|
||||||
|
("pkg<=2.5", "pkg", Some("2.5"), VersionOp::Lte),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (input, name, ver, op) in cases {
|
||||||
|
let parsed = parse_dep(input);
|
||||||
|
assert_eq!(parsed.name, name, "Failed name for {}", input);
|
||||||
|
assert_eq!(parsed.version, ver, "Failed version for {}", input);
|
||||||
|
assert_eq!(parsed.op, op, "Failed op for {}", input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compare_versions_semver() {
|
||||||
|
assert!(compare_versions("1.0.0", "1.0.0", VersionOp::Exact));
|
||||||
|
assert!(!compare_versions("1.0.1", "1.0.0", VersionOp::Exact));
|
||||||
|
|
||||||
|
assert!(compare_versions("1.1.0", "1.0.0", VersionOp::Gt));
|
||||||
|
assert!(!compare_versions("1.0.0", "1.0.0", VersionOp::Gt));
|
||||||
|
|
||||||
|
assert!(compare_versions("0.9.0", "1.0.0", VersionOp::Lt));
|
||||||
|
assert!(!compare_versions("1.0.0", "1.0.0", VersionOp::Lt));
|
||||||
|
|
||||||
|
assert!(compare_versions("1.0.0", "1.0.0", VersionOp::Gte));
|
||||||
|
assert!(compare_versions("1.1.0", "1.0.0", VersionOp::Gte));
|
||||||
|
|
||||||
|
assert!(compare_versions("1.0.0", "1.0.0", VersionOp::Lte));
|
||||||
|
assert!(compare_versions("0.9.0", "1.0.0", VersionOp::Lte));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compare_versions_fallback() {
|
||||||
|
// String comparison fallback
|
||||||
|
assert!(compare_versions("b", "a", VersionOp::Gt));
|
||||||
|
assert!(compare_versions("a", "b", VersionOp::Lt));
|
||||||
|
assert!(compare_versions("foo", "foo", VersionOp::Exact));
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
//! Fakeroot support for running commands as pseudo-root
|
||||||
|
//! Uses the system fakeroot command when not running as root
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Check if we're running as root
|
||||||
|
pub fn is_root() -> bool {
|
||||||
|
nix::unistd::geteuid().is_root()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a command for fakeroot execution
|
||||||
|
/// For make install, we use the system fakeroot command
|
||||||
|
pub fn wrap_install_command(program: &str, destdir: &Path) -> Command {
|
||||||
|
if is_root() {
|
||||||
|
Command::new(program)
|
||||||
|
} else {
|
||||||
|
// Use system fakeroot command which handles LD_PRELOAD internally
|
||||||
|
let mut cmd = Command::new("fakeroot");
|
||||||
|
cmd.arg("--");
|
||||||
|
cmd.arg(program);
|
||||||
|
// Fakeroot will ensure file ownership appears as root
|
||||||
|
cmd.env("DESTDIR", destdir);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+97
@@ -0,0 +1,97 @@
|
|||||||
|
//! Package index for fast lookups
|
||||||
|
//!
|
||||||
|
//! Caches package name -> spec path and provides -> spec path mappings.
|
||||||
|
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Cached package index for O(1) lookups
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct PackageIndex {
|
||||||
|
/// Package name -> spec path
|
||||||
|
by_name: HashMap<String, PathBuf>,
|
||||||
|
/// Provided name -> spec paths (can be multiple)
|
||||||
|
by_provides: HashMap<String, Vec<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageIndex {
|
||||||
|
/// Build index by scanning packages/*/*.toml
|
||||||
|
pub fn build() -> Self {
|
||||||
|
let mut index = Self::default();
|
||||||
|
let packages_dir = PathBuf::from("packages");
|
||||||
|
|
||||||
|
if !packages_dir.exists() {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan all package directories
|
||||||
|
if let Ok(entries) = fs::read_dir(&packages_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dir = entry.path();
|
||||||
|
|
||||||
|
// Find all .toml files in this directory
|
||||||
|
if let Ok(files) = fs::read_dir(&dir) {
|
||||||
|
for file in files.flatten() {
|
||||||
|
let path = file.path();
|
||||||
|
if path.extension().map(|e| e == "toml").unwrap_or(false) {
|
||||||
|
// Try to parse the spec
|
||||||
|
if let Ok(spec) = PackageSpec::from_file(&path) {
|
||||||
|
// Index by name
|
||||||
|
index
|
||||||
|
.by_name
|
||||||
|
.insert(spec.package.name.clone(), path.clone());
|
||||||
|
|
||||||
|
// Index by provides
|
||||||
|
for provided in &spec.alternatives.provides {
|
||||||
|
index
|
||||||
|
.by_provides
|
||||||
|
.entry(provided.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Indexed {} packages ({} provides)",
|
||||||
|
index.by_name.len(),
|
||||||
|
index.by_provides.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a spec by package name or provides
|
||||||
|
pub fn find(&self, name: &str) -> Option<PathBuf> {
|
||||||
|
// First try by name
|
||||||
|
if let Some(path) = self.by_name.get(name) {
|
||||||
|
return Some(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try by provides
|
||||||
|
if let Some(paths) = self.by_provides.get(name) {
|
||||||
|
if paths.len() > 1 {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: Multiple packages provide '{}': {:?}",
|
||||||
|
name,
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return paths.first().cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+514
@@ -0,0 +1,514 @@
|
|||||||
|
//! Depot - Not Your Average Package Manager
|
||||||
|
//! A source-based package manager for Linux
|
||||||
|
|
||||||
|
mod builder;
|
||||||
|
mod config;
|
||||||
|
mod cross;
|
||||||
|
mod db;
|
||||||
|
mod deps;
|
||||||
|
mod fakeroot;
|
||||||
|
mod index;
|
||||||
|
mod package;
|
||||||
|
mod source;
|
||||||
|
mod staging;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "Depot")]
|
||||||
|
#[command(about = "Depot - Source-based package manager for Linux", long_about = None)]
|
||||||
|
#[command(version)]
|
||||||
|
struct Cli {
|
||||||
|
/// Custom root filesystem path
|
||||||
|
#[arg(long, short = 'r', default_value = "/", global = true)]
|
||||||
|
rootfs: PathBuf,
|
||||||
|
|
||||||
|
/// Skip dependency checks
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
no_deps: bool,
|
||||||
|
|
||||||
|
/// Cross-compilation prefix (e.g., x86_64-linux-musl, aarch64-linux-gnu)
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
cross_prefix: Option<String>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Build and install a package from a spec file
|
||||||
|
Install {
|
||||||
|
/// Path to package spec (.toml) or package archive (.tar.zst)
|
||||||
|
#[arg(value_name = "SPEC_OR_ARCHIVE")]
|
||||||
|
spec_or_archive: PathBuf,
|
||||||
|
|
||||||
|
/// Explicitly specify path to package spec (.toml file)
|
||||||
|
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
|
||||||
|
spec: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Remove an installed package
|
||||||
|
Remove {
|
||||||
|
/// Package name to remove
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
|
/// Build a package without installing
|
||||||
|
Build {
|
||||||
|
/// Path to package spec (.toml file)
|
||||||
|
#[arg(value_name = "SPEC")]
|
||||||
|
spec_pos: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Explicitly specify path to package spec (.toml file)
|
||||||
|
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
|
||||||
|
spec: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Show information about a package
|
||||||
|
Info {
|
||||||
|
/// Path to package spec or installed package name
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
|
/// List installed packages
|
||||||
|
List,
|
||||||
|
/// Repository management
|
||||||
|
Repo {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: RepoCommands,
|
||||||
|
},
|
||||||
|
/// Show current configuration
|
||||||
|
Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum RepoCommands {
|
||||||
|
/// Create a repository database from a directory of packages
|
||||||
|
Create {
|
||||||
|
/// Directory containing .depot.pkg.tar.zst files
|
||||||
|
#[arg(default_value = ".")]
|
||||||
|
dir: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Install {
|
||||||
|
spec_or_archive,
|
||||||
|
spec,
|
||||||
|
} => {
|
||||||
|
let spec_path = spec.unwrap_or(spec_or_archive);
|
||||||
|
println!("Installing package from: {}", spec_path.display());
|
||||||
|
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
|
||||||
|
let (pkg_spec, staging_dir): (package::PackageSpec, Option<tempfile::TempDir>) =
|
||||||
|
if spec_path.to_string_lossy().ends_with(".tar.zst") {
|
||||||
|
// Install from archive
|
||||||
|
println!("Detected package archive: {}", spec_path.display());
|
||||||
|
let tmp_dir = tempfile::TempDir::new()?;
|
||||||
|
let extract_dir = tmp_dir.path().to_path_buf();
|
||||||
|
|
||||||
|
// Extract metadata.toml first to get spec
|
||||||
|
let file = fs::File::open(&spec_path)?;
|
||||||
|
let zstd_decoder = zstd::stream::read::Decoder::new(file)?;
|
||||||
|
let mut archive = tar::Archive::new(zstd_decoder);
|
||||||
|
|
||||||
|
let mut metadata_content = String::new();
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
if entry.path()?.to_string_lossy() == ".metadata.toml" {
|
||||||
|
use std::io::Read;
|
||||||
|
entry.read_to_string(&mut metadata_content)?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata_content.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Package archive does not contain .metadata.toml: {}",
|
||||||
|
spec_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to parse the metadata.toml but we don't have a direct "from_metadata"
|
||||||
|
// Let's implement a minimal reconstruction or use the metadata to fill a spec.
|
||||||
|
// Actually, PackageSpec needs a lot of fields.
|
||||||
|
// Let's extract the WHOLE archive to a temporary staging dir and use it.
|
||||||
|
let file = fs::File::open(&spec_path)?;
|
||||||
|
let zstd_decoder = zstd::stream::read::Decoder::new(file)?;
|
||||||
|
let mut archive = tar::Archive::new(zstd_decoder);
|
||||||
|
archive.unpack(&extract_dir)?;
|
||||||
|
|
||||||
|
let metadata: toml::Value = toml::from_str(&metadata_content)?;
|
||||||
|
|
||||||
|
// Create a minimal spec from metadata
|
||||||
|
let mut spec = package::PackageSpec {
|
||||||
|
package: package::PackageInfo {
|
||||||
|
name: metadata
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
version: metadata
|
||||||
|
.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
revision: metadata
|
||||||
|
.get("revision")
|
||||||
|
.and_then(|v| v.as_integer())
|
||||||
|
.unwrap_or(1) as u32,
|
||||||
|
description: metadata
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
homepage: metadata
|
||||||
|
.get("homepage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
license: metadata
|
||||||
|
.get("license")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
alternatives: package::Alternatives::default(),
|
||||||
|
manual_sources: Vec::new(),
|
||||||
|
source: Vec::new(),
|
||||||
|
build: package::Build {
|
||||||
|
build_type: package::BuildType::Bin,
|
||||||
|
flags: package::BuildFlags::default(),
|
||||||
|
},
|
||||||
|
dependencies: package::Dependencies {
|
||||||
|
build: Vec::new(),
|
||||||
|
runtime: if let Some(deps) = metadata
|
||||||
|
.get("dependencies")
|
||||||
|
.and_then(|v| v.get("runtime"))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
{
|
||||||
|
deps.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec_dir: PathBuf::from("."),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) {
|
||||||
|
spec.alternatives.provides = provides
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
(spec, Some(tmp_dir))
|
||||||
|
} else {
|
||||||
|
// Install from spec (normal build)
|
||||||
|
let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?;
|
||||||
|
pkg_spec.apply_config(&config);
|
||||||
|
|
||||||
|
// ... existing build logic ...
|
||||||
|
// Jump to the part where build actually happens.
|
||||||
|
// To keep the code clean, I'll move the build/stage logic into a helper or similar?
|
||||||
|
// Actually, I'll just structure it so we can skip build if staging_dir is Some.
|
||||||
|
(pkg_spec, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Package: {} v{}-{}",
|
||||||
|
pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get config relative to rootfs
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
|
||||||
|
// Ensure database directory exists
|
||||||
|
std::fs::create_dir_all(&config.db_dir).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to create database directory: {}",
|
||||||
|
config.db_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
|
||||||
|
// Check dependencies and prompt for auto-install if needed
|
||||||
|
if !cli.no_deps {
|
||||||
|
deps::print_dep_status(&pkg_spec, &db_path)?;
|
||||||
|
|
||||||
|
// Collect all missing dependencies (build + runtime)
|
||||||
|
let mut missing = deps::check_build_deps(&pkg_spec, &db_path)?;
|
||||||
|
let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?;
|
||||||
|
|
||||||
|
for dep in missing_runtime {
|
||||||
|
if !missing.contains(&dep) {
|
||||||
|
missing.push(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
// Check for dependency cycles via DEPOT_DEPCHAIN env var
|
||||||
|
let dep_chain = std::env::var("DEPOT_DEPCHAIN").unwrap_or_default();
|
||||||
|
let chain_set: std::collections::HashSet<&str> =
|
||||||
|
dep_chain.split(',').filter(|s| !s.is_empty()).collect();
|
||||||
|
|
||||||
|
if chain_set.contains(pkg_spec.package.name.as_str()) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Dependency cycle detected! {} is already in chain: {}",
|
||||||
|
pkg_spec.package.name,
|
||||||
|
dep_chain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nMissing dependencies: {}", missing.join(", "));
|
||||||
|
println!("Do you want to attempt to install them? [Y/n] ");
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
let input = input.trim().to_lowercase();
|
||||||
|
|
||||||
|
if input == "y" || input == "yes" || input.is_empty() {
|
||||||
|
// Build package index for fast lookups
|
||||||
|
let pkg_index = index::PackageIndex::build();
|
||||||
|
|
||||||
|
// Build new dep chain
|
||||||
|
let new_chain = if dep_chain.is_empty() {
|
||||||
|
pkg_spec.package.name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{},{}", dep_chain, pkg_spec.package.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attempt to install missing deps
|
||||||
|
for dep in missing {
|
||||||
|
// Use package index for O(1) lookup
|
||||||
|
let candidate = pkg_index.find(&dep);
|
||||||
|
|
||||||
|
if let Some(dep_spec_path) = candidate {
|
||||||
|
println!("Installing dependency: {}...", dep);
|
||||||
|
|
||||||
|
let mut cmd = std::process::Command::new(std::env::current_exe()?);
|
||||||
|
cmd.arg("-r").arg(&cli.rootfs);
|
||||||
|
|
||||||
|
if cli.no_deps {
|
||||||
|
cmd.arg("--no-deps");
|
||||||
|
}
|
||||||
|
if let Some(ref p) = cli.cross_prefix {
|
||||||
|
cmd.arg("--cross-prefix").arg(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg("install").arg(&dep_spec_path);
|
||||||
|
cmd.env("DEPOT_DEPCHAIN", &new_chain);
|
||||||
|
|
||||||
|
let status = cmd.status()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("Failed to install dependency: {}", dep);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Could not find package spec for dependency: {}",
|
||||||
|
dep
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce build dependencies (runtime deps are warnings only if not installed/prompt declined)
|
||||||
|
deps::require_build_deps(&pkg_spec, &db_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure database directory exists
|
||||||
|
std::fs::create_dir_all(&config.db_dir).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to create database directory: {}",
|
||||||
|
config.db_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
|
||||||
|
let destdir = if let Some(dir) = &staging_dir {
|
||||||
|
dir.path().to_path_buf()
|
||||||
|
} else {
|
||||||
|
// 1-2. Fetch + extract sources (supports archives and git URL#rev)
|
||||||
|
let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?;
|
||||||
|
|
||||||
|
// 3. Build
|
||||||
|
let destdir = config
|
||||||
|
.build_dir
|
||||||
|
.join("destdir")
|
||||||
|
.join(&pkg_spec.package.name);
|
||||||
|
|
||||||
|
// Build with optional cross-compilation
|
||||||
|
let cross_config = cli
|
||||||
|
.cross_prefix
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| cross::CrossConfig::from_prefix(p))
|
||||||
|
.transpose()?;
|
||||||
|
builder::build(&pkg_spec, &src_dir, &destdir, cross_config.as_ref())?;
|
||||||
|
|
||||||
|
// 3.1 Copy license files into staged tree
|
||||||
|
staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?;
|
||||||
|
|
||||||
|
destdir
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Stage (clean .la files, etc.)
|
||||||
|
staging::process(&destdir, &pkg_spec)?;
|
||||||
|
|
||||||
|
// 5. Install/update to rootfs (atomic)
|
||||||
|
let new_files = staging::generate_manifest_with_dirs(&destdir)?;
|
||||||
|
|
||||||
|
let remove_paths =
|
||||||
|
db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_files.files)?;
|
||||||
|
|
||||||
|
let tx_base = config.build_dir.join("tx");
|
||||||
|
let tx = staging::install_atomic(&destdir, &cli.rootfs, &tx_base, &remove_paths)?;
|
||||||
|
|
||||||
|
// 6. Register in database (rollback install on DB error)
|
||||||
|
if let Err(e) = db::register_package(&db_path, &pkg_spec, &destdir) {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
// 7. Check runtime dependencies (warn only)
|
||||||
|
if !cli.no_deps {
|
||||||
|
let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?;
|
||||||
|
if !missing_runtime.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"\x1b[33mWarning: Missing runtime dependencies: {}\x1b[0m",
|
||||||
|
missing_runtime.join(", ")
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"\x1b[33mContinuing without runtime deps; binaries may not run correctly.\x1b[0m"
|
||||||
|
);
|
||||||
|
eprintln!("\x1b[33mUse --no-deps to suppress this warning.\x1b[0m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Successfully installed {} v{}",
|
||||||
|
pkg_spec.package.name, pkg_spec.package.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Commands::Remove { package } => {
|
||||||
|
println!("Removing package: {}", package);
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
db::remove_package(&db_path, &package, &cli.rootfs)?;
|
||||||
|
println!("Successfully removed {}", package);
|
||||||
|
}
|
||||||
|
Commands::Build { spec_pos, spec } => {
|
||||||
|
if crate::fakeroot::is_root() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"The 'build' command must be run as a non-root user to ensure a clean build environment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let spec_path = spec.or(spec_pos).context("No spec file provided")?;
|
||||||
|
println!("Building package from: {}", spec_path.display());
|
||||||
|
let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?;
|
||||||
|
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
|
||||||
|
// Apply system overrides
|
||||||
|
pkg_spec.apply_config(&config);
|
||||||
|
|
||||||
|
// Ensure database directory exists
|
||||||
|
std::fs::create_dir_all(&config.db_dir).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to create database directory: {}",
|
||||||
|
config.db_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
|
||||||
|
// Check build dependencies
|
||||||
|
if !cli.no_deps {
|
||||||
|
deps::print_dep_status(&pkg_spec, &db_path)?;
|
||||||
|
deps::require_build_deps(&pkg_spec, &db_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?;
|
||||||
|
|
||||||
|
let destdir = config
|
||||||
|
.build_dir
|
||||||
|
.join("destdir")
|
||||||
|
.join(&pkg_spec.package.name);
|
||||||
|
// Build with optional cross-compilation
|
||||||
|
let cross_config = cli
|
||||||
|
.cross_prefix
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| cross::CrossConfig::from_prefix(p))
|
||||||
|
.transpose()?;
|
||||||
|
builder::build(&pkg_spec, &src_dir, &destdir, cross_config.as_ref())?;
|
||||||
|
|
||||||
|
staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?;
|
||||||
|
|
||||||
|
staging::process(&destdir, &pkg_spec)?;
|
||||||
|
|
||||||
|
// Create package archive
|
||||||
|
let packager = package::Packager::new(pkg_spec, destdir.clone(), config);
|
||||||
|
let arch = cli
|
||||||
|
.cross_prefix
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(std::env::consts::ARCH);
|
||||||
|
let pkg_file = packager.create_package(Path::new("."), arch)?;
|
||||||
|
|
||||||
|
println!("Build complete. Package created: {}", pkg_file.display());
|
||||||
|
}
|
||||||
|
Commands::Info { package } => {
|
||||||
|
// Try as file first, then as installed package name
|
||||||
|
let path = PathBuf::from(&package);
|
||||||
|
if path.exists() {
|
||||||
|
let pkg_spec = package::PackageSpec::from_file(&path)?;
|
||||||
|
println!("{}", pkg_spec);
|
||||||
|
|
||||||
|
// Also show dependency status
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
deps::print_dep_status(&pkg_spec, &db_path)?;
|
||||||
|
} else {
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
db::show_package_info(&db_path, &package)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::List => {
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
let db_path = config.db_dir.join("packages.db");
|
||||||
|
db::list_packages(&db_path)?;
|
||||||
|
}
|
||||||
|
Commands::Repo { command } => match command {
|
||||||
|
RepoCommands::Create { dir } => {
|
||||||
|
let repo = db::repo::RepoManager::new(dir);
|
||||||
|
let db_path = repo.create_repo_db()?;
|
||||||
|
println!("Created repository database: {}", db_path.display());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Commands::Config => {
|
||||||
|
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||||
|
println!("Cache Directory: {}", config.cache_dir.display());
|
||||||
|
println!("Build Directory: {}", config.build_dir.display());
|
||||||
|
println!("Database Directory: {}", config.db_dir.display());
|
||||||
|
println!("\nBuild Overrides: {}", config.build_overrides);
|
||||||
|
println!("Package Overrides: {}", config.package_overrides);
|
||||||
|
if !config.appends.is_empty() {
|
||||||
|
println!("\nAppends:");
|
||||||
|
for (k, v) in &config.appends {
|
||||||
|
println!(" {} = {:?}", k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
//! Package specification parsing
|
||||||
|
|
||||||
|
mod packager;
|
||||||
|
mod spec;
|
||||||
|
|
||||||
|
pub use packager::Packager;
|
||||||
|
pub use spec::*;
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//! Package creation and archive management
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tar::Builder;
|
||||||
|
use zstd::stream::write::Encoder;
|
||||||
|
|
||||||
|
pub struct Packager {
|
||||||
|
pub spec: PackageSpec,
|
||||||
|
pub destdir: PathBuf,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Packager {
|
||||||
|
pub fn new(spec: PackageSpec, destdir: PathBuf, config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
spec,
|
||||||
|
destdir,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a package archive (.depot.pkg.tar.zst) from the destdir
|
||||||
|
pub fn create_package(&self, output_dir: &Path, arch: &str) -> Result<PathBuf> {
|
||||||
|
let filename = self.spec.package_filename(arch);
|
||||||
|
let output_path = output_dir.join(&filename);
|
||||||
|
|
||||||
|
println!("Creating package {}...", filename);
|
||||||
|
|
||||||
|
// Generate .files.yaml
|
||||||
|
self.generate_files_yaml()?;
|
||||||
|
|
||||||
|
// Generate .metadata.toml
|
||||||
|
self.generate_metadata_toml()?;
|
||||||
|
|
||||||
|
// Create tar.zst
|
||||||
|
let file = fs::File::create(&output_path)
|
||||||
|
.with_context(|| format!("Failed to create output file: {}", output_path.display()))?;
|
||||||
|
|
||||||
|
// Respect zstd level from config (default to 3 if not specified)
|
||||||
|
let level = self
|
||||||
|
.config
|
||||||
|
.package_overrides
|
||||||
|
.get("compression_level")
|
||||||
|
.and_then(|v| v.as_integer())
|
||||||
|
.unwrap_or(3) as i32;
|
||||||
|
|
||||||
|
let mut encoder = Encoder::new(file, level)?;
|
||||||
|
let _ = encoder.multithread(num_cpus() as u32);
|
||||||
|
|
||||||
|
let mut tar = Builder::new(encoder);
|
||||||
|
|
||||||
|
// Manual walk to ensure symlinks aren't followed (preserving them as links)
|
||||||
|
for entry in walkdir::WalkDir::new(&self.destdir)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
|
let path = entry.path();
|
||||||
|
let rel_path = path.strip_prefix(&self.destdir)?;
|
||||||
|
|
||||||
|
// Skip the root of the destdir
|
||||||
|
if rel_path.as_os_str().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_type = entry.file_type();
|
||||||
|
if file_type.is_dir() {
|
||||||
|
tar.append_dir(rel_path, path)?;
|
||||||
|
} else if file_type.is_symlink() {
|
||||||
|
// For symlinks, we need to read the link and append to tar correctly
|
||||||
|
let target = fs::read_link(path)?;
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_metadata_in_mode(
|
||||||
|
&fs::symlink_metadata(path)?,
|
||||||
|
tar::HeaderMode::Deterministic,
|
||||||
|
);
|
||||||
|
tar.append_link(&mut header, rel_path, target)?;
|
||||||
|
} else {
|
||||||
|
// Files
|
||||||
|
let mut file = fs::File::open(path)?;
|
||||||
|
tar.append_file(rel_path, &mut file)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoder = tar.into_inner()?;
|
||||||
|
encoder.finish()?;
|
||||||
|
|
||||||
|
println!("Created package: {}", output_path.display());
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_metadata_toml(&self) -> Result<()> {
|
||||||
|
let metadata_path = self.destdir.join(".metadata.toml");
|
||||||
|
|
||||||
|
// Construct a simple metadata structure
|
||||||
|
let mut map = toml::map::Map::new();
|
||||||
|
map.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
toml::Value::String(self.spec.package.name.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"version".to_string(),
|
||||||
|
toml::Value::String(self.spec.package.version.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"revision".to_string(),
|
||||||
|
toml::Value::Integer(self.spec.package.revision as i64),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"description".to_string(),
|
||||||
|
toml::Value::String(self.spec.package.description.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"homepage".to_string(),
|
||||||
|
toml::Value::String(self.spec.package.homepage.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"license".to_string(),
|
||||||
|
toml::Value::String(self.spec.package.license.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add provides
|
||||||
|
map.insert(
|
||||||
|
"provides".to_string(),
|
||||||
|
toml::Value::Array(
|
||||||
|
self.spec
|
||||||
|
.alternatives
|
||||||
|
.provides
|
||||||
|
.iter()
|
||||||
|
.map(|s| toml::Value::String(s.clone()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add dependencies if useful for repo indexing
|
||||||
|
let mut build_deps = toml::map::Map::new();
|
||||||
|
build_deps.insert(
|
||||||
|
"build".to_string(),
|
||||||
|
toml::Value::Array(
|
||||||
|
self.spec
|
||||||
|
.dependencies
|
||||||
|
.build
|
||||||
|
.iter()
|
||||||
|
.map(|s| toml::Value::String(s.clone()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
build_deps.insert(
|
||||||
|
"runtime".to_string(),
|
||||||
|
toml::Value::Array(
|
||||||
|
self.spec
|
||||||
|
.dependencies
|
||||||
|
.runtime
|
||||||
|
.iter()
|
||||||
|
.map(|s| toml::Value::String(s.clone()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
map.insert("dependencies".to_string(), toml::Value::Table(build_deps));
|
||||||
|
|
||||||
|
let toml_str = toml::to_string(&toml::Value::Table(map))
|
||||||
|
.context("Failed to serialize metadata to TOML")?;
|
||||||
|
|
||||||
|
fs::write(&metadata_path, toml_str)
|
||||||
|
.with_context(|| format!("Failed to write metadata: {}", metadata_path.display()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_files_yaml(&self) -> Result<()> {
|
||||||
|
let files_path = self.destdir.join(".files.yaml");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
|
||||||
|
// Recursively list all files in destdir
|
||||||
|
self.collect_files(&self.destdir, &self.destdir, &mut files)?;
|
||||||
|
|
||||||
|
let mut out_str = String::new();
|
||||||
|
{
|
||||||
|
let mut emitter = yaml_rust2::YamlEmitter::new(&mut out_str);
|
||||||
|
let yaml_vec: Vec<yaml_rust2::Yaml> =
|
||||||
|
files.into_iter().map(yaml_rust2::Yaml::String).collect();
|
||||||
|
emitter
|
||||||
|
.dump(&yaml_rust2::Yaml::Array(yaml_vec))
|
||||||
|
.context("Failed to emit YAML")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&files_path, out_str)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(&self, base: &Path, current: &Path, files: &mut Vec<String>) -> Result<()> {
|
||||||
|
for entry in fs::read_dir(current)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let file_type = entry.file_type()?;
|
||||||
|
let relative = path.strip_prefix(base)?.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if file_type.is_dir() {
|
||||||
|
self.collect_files(base, &path, files)?;
|
||||||
|
} else {
|
||||||
|
files.push(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_cpus() -> usize {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::package::{Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo};
|
||||||
|
|
||||||
|
fn mk_packager(destdir: PathBuf) -> Packager {
|
||||||
|
Packager::new(
|
||||||
|
PackageSpec {
|
||||||
|
package: PackageInfo {
|
||||||
|
name: "test".into(),
|
||||||
|
version: "1.0".into(),
|
||||||
|
revision: 1,
|
||||||
|
description: "d".into(),
|
||||||
|
homepage: "h".into(),
|
||||||
|
license: "MIT".into(),
|
||||||
|
},
|
||||||
|
alternatives: Alternatives::default(),
|
||||||
|
manual_sources: Vec::new(),
|
||||||
|
source: Vec::new(),
|
||||||
|
build: Build {
|
||||||
|
build_type: BuildType::Custom,
|
||||||
|
flags: BuildFlags::default(),
|
||||||
|
},
|
||||||
|
dependencies: Dependencies::default(),
|
||||||
|
spec_dir: PathBuf::from("."),
|
||||||
|
},
|
||||||
|
destdir,
|
||||||
|
Config::for_rootfs(Path::new("/tmp/nonexistent")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collect_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dest = tmp.path();
|
||||||
|
fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||||
|
fs::write(dest.join("usr/bin/foo"), "x").unwrap();
|
||||||
|
fs::create_dir_all(dest.join("etc")).unwrap();
|
||||||
|
fs::write(dest.join("etc/config"), "y").unwrap();
|
||||||
|
|
||||||
|
let packager = mk_packager(dest.to_path_buf());
|
||||||
|
let mut files = Vec::new();
|
||||||
|
packager.collect_files(dest, dest, &mut files).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(files.len(), 2);
|
||||||
|
assert!(files.contains(&"usr/bin/foo".to_string()));
|
||||||
|
assert!(files.contains(&"etc/config".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_files_yaml() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dest = tmp.path();
|
||||||
|
fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||||
|
fs::write(dest.join("usr/bin/foo"), "x").unwrap();
|
||||||
|
|
||||||
|
let packager = mk_packager(dest.to_path_buf());
|
||||||
|
packager.generate_files_yaml().unwrap();
|
||||||
|
|
||||||
|
let yaml_path = dest.join(".files.yaml");
|
||||||
|
assert!(yaml_path.exists());
|
||||||
|
let yaml_content = fs::read_to_string(yaml_path).unwrap();
|
||||||
|
assert!(yaml_content.contains("usr/bin/foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_metadata_toml() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dest = tmp.path();
|
||||||
|
|
||||||
|
let packager = mk_packager(dest.to_path_buf());
|
||||||
|
packager.generate_metadata_toml().unwrap();
|
||||||
|
|
||||||
|
let meta_path = dest.join(".metadata.toml");
|
||||||
|
assert!(meta_path.exists());
|
||||||
|
let content = fs::read_to_string(meta_path).unwrap();
|
||||||
|
let val: toml::Value = toml::from_str(&content).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(val.get("name").and_then(|v| v.as_str()), Some("test"));
|
||||||
|
assert_eq!(val.get("version").and_then(|v| v.as_str()), Some("1.0"));
|
||||||
|
assert_eq!(val.get("revision").and_then(|v| v.as_integer()), Some(1));
|
||||||
|
assert_eq!(val.get("license").and_then(|v| v.as_str()), Some("MIT"));
|
||||||
|
|
||||||
|
let deps = val.get("dependencies").unwrap();
|
||||||
|
assert!(deps.get("build").unwrap().as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+712
@@ -0,0 +1,712 @@
|
|||||||
|
//! Package specification structures and TOML parsing
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Deserializer;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Complete package specification from TOML
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PackageSpec {
|
||||||
|
pub package: PackageInfo,
|
||||||
|
#[serde(default)]
|
||||||
|
pub alternatives: Alternatives,
|
||||||
|
/// Manual (local) sources to copy before fetching remote sources.
|
||||||
|
#[serde(default)]
|
||||||
|
pub manual_sources: Vec<ManualSource>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_sources")]
|
||||||
|
pub source: Vec<Source>,
|
||||||
|
pub build: Build,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dependencies: Dependencies,
|
||||||
|
|
||||||
|
/// Directory containing the spec file (used to resolve relative paths such as patches).
|
||||||
|
#[serde(skip)]
|
||||||
|
pub spec_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageSpec {
|
||||||
|
/// Load package spec from a TOML file
|
||||||
|
pub fn from_file(path: &Path) -> Result<Self> {
|
||||||
|
// Canonicalize path to ensure spec_dir is absolute
|
||||||
|
let abs_path = path
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("Failed to resolve path: {}", path.display()))?;
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&abs_path)
|
||||||
|
.with_context(|| format!("Failed to read package spec: {}", abs_path.display()))?;
|
||||||
|
let mut spec: PackageSpec = toml::from_str(&content)
|
||||||
|
.with_context(|| format!("Failed to parse package spec: {}", abs_path.display()))?;
|
||||||
|
spec.spec_dir = abs_path
|
||||||
|
.parent()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
|
||||||
|
// Require at least one source (remote or manual)
|
||||||
|
if spec.source.is_empty() && spec.manual_sources.is_empty() {
|
||||||
|
anyhow::bail!("Package must have at least one source or manual_sources entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand variables like $name and $version in a string
|
||||||
|
pub fn expand_vars(&self, input: &str) -> String {
|
||||||
|
let specdir = self.spec_dir.to_string_lossy();
|
||||||
|
input
|
||||||
|
.replace("$name", &self.package.name)
|
||||||
|
.replace("$version", &self.package.version)
|
||||||
|
.replace("$specdir", &specdir)
|
||||||
|
.replace("$NYAPM_SPECDIR", &specdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sources(&self) -> &[Source] {
|
||||||
|
&self.source
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply system configuration overrides and appends
|
||||||
|
pub fn apply_config(&mut self, config: &crate::config::Config) {
|
||||||
|
// Apply build overrides from /etc/depot.d/build.toml
|
||||||
|
self.apply_toml_overrides(&config.build_overrides, "build");
|
||||||
|
|
||||||
|
// Apply appends from /etc/depot.d/build.toml (e.g. build.flags.cflags += ["-O3"])
|
||||||
|
for (key, values) in &config.appends {
|
||||||
|
if let Some(subkey) = key.strip_prefix("build.flags.") {
|
||||||
|
self.apply_append(subkey, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_toml_overrides(&mut self, overrides: &toml::Value, _prefix: &str) {
|
||||||
|
// Support both [build.flags] and top-level [build] fields
|
||||||
|
if let Some(table) = overrides.as_table() {
|
||||||
|
self.apply_flags_table(table);
|
||||||
|
}
|
||||||
|
if let Some(table) = overrides.get("flags").and_then(|f| f.as_table()) {
|
||||||
|
self.apply_flags_table(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_flags_table(&mut self, table: &toml::map::Map<String, toml::Value>) {
|
||||||
|
for (k, v) in table {
|
||||||
|
match k.as_str() {
|
||||||
|
"cflags" => {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build.flags.cflags = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|x| x.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.cflags = vec![s.to_string()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ldflags" => {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build.flags.ldflags = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|x| x.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.ldflags = vec![s.to_string()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cc" => {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.cc = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ar" => {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.ar = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"prefix" => {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.prefix = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"chost" => {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.chost = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cbuild" => {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.cbuild = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add more fields as needed
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_append(&mut self, key: &str, values: &[toml::Value]) {
|
||||||
|
match key {
|
||||||
|
"cflags" => {
|
||||||
|
for v in values {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build
|
||||||
|
.flags
|
||||||
|
.cflags
|
||||||
|
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.cflags.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ldflags" => {
|
||||||
|
for v in values {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build
|
||||||
|
.flags
|
||||||
|
.ldflags
|
||||||
|
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.ldflags.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"configure" => {
|
||||||
|
for v in values {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build
|
||||||
|
.flags
|
||||||
|
.configure
|
||||||
|
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.configure.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cargs" => {
|
||||||
|
for v in values {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build
|
||||||
|
.flags
|
||||||
|
.cargs
|
||||||
|
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.cargs.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"rustflags" => {
|
||||||
|
for v in values {
|
||||||
|
if let Some(arr) = v.as_array() {
|
||||||
|
self.build
|
||||||
|
.flags
|
||||||
|
.rustflags
|
||||||
|
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
self.build.flags.rustflags.push(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cc" => {
|
||||||
|
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||||
|
self.build.flags.cc = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ar" => {
|
||||||
|
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||||
|
self.build.flags.ar = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"prefix" => {
|
||||||
|
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||||
|
self.build.flags.prefix = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"chost" => {
|
||||||
|
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||||
|
self.build.flags.chost = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cbuild" => {
|
||||||
|
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||||
|
self.build.flags.cbuild = s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod spec_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_source_table() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("pkg.toml");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[package]
|
||||||
|
name = "foo"
|
||||||
|
version = "1.0"
|
||||||
|
description = "d"
|
||||||
|
homepage = "h"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[source]
|
||||||
|
url = "https://example.com/foo-$version.tar.gz"
|
||||||
|
sha256 = "skip"
|
||||||
|
extract_dir = "foo-$version"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
type = "custom"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let spec = PackageSpec::from_file(&path).unwrap();
|
||||||
|
assert_eq!(spec.package.name, "foo");
|
||||||
|
assert_eq!(spec.sources().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
spec.expand_vars(&spec.sources()[0].url),
|
||||||
|
"https://example.com/foo-1.0.tar.gz"
|
||||||
|
);
|
||||||
|
assert!(spec.sources()[0].patches.is_empty());
|
||||||
|
assert!(spec.sources()[0].post_extract.is_empty());
|
||||||
|
assert_eq!(spec.spec_dir, tmp.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_source_array() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("pkg.toml");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[package]
|
||||||
|
name = "foo"
|
||||||
|
version = "1.0"
|
||||||
|
description = "d"
|
||||||
|
homepage = "h"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[[source]]
|
||||||
|
url = "https://example.com/foo.tar.gz"
|
||||||
|
sha256 = "skip"
|
||||||
|
extract_dir = "foo"
|
||||||
|
|
||||||
|
[[source]]
|
||||||
|
url = "https://example.com/bar.tar.gz"
|
||||||
|
sha256 = "skip"
|
||||||
|
extract_dir = "bar"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
type = "custom"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let spec = PackageSpec::from_file(&path).unwrap();
|
||||||
|
assert_eq!(spec.sources().len(), 2);
|
||||||
|
assert_eq!(spec.sources()[0].extract_dir, "foo");
|
||||||
|
assert_eq!(spec.sources()[1].extract_dir, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_empty_sources() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp.path().join("pkg.toml");
|
||||||
|
|
||||||
|
// `source = []` is not accepted (must have at least one entry)
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"
|
||||||
|
[package]
|
||||||
|
name = "foo"
|
||||||
|
version = "1.0"
|
||||||
|
description = "d"
|
||||||
|
homepage = "h"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
source = []
|
||||||
|
|
||||||
|
[build]
|
||||||
|
type = "custom"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(PackageSpec::from_file(&path).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_config() {
|
||||||
|
let mut spec = mk_spec("foo", "1.0");
|
||||||
|
let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent"));
|
||||||
|
|
||||||
|
// Mock some overrides and appends
|
||||||
|
config.build_overrides = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[flags]
|
||||||
|
cc = "my-cc"
|
||||||
|
cflags = ["-O2"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
config.appends.insert(
|
||||||
|
"build.flags.cflags".to_string(),
|
||||||
|
vec![toml::Value::String("-g".to_string())],
|
||||||
|
);
|
||||||
|
config.appends.insert(
|
||||||
|
"build.flags.rustflags".to_string(),
|
||||||
|
vec![toml::Value::Array(vec![
|
||||||
|
toml::Value::String("-C".to_string()),
|
||||||
|
toml::Value::String("opt-level=3".to_string()),
|
||||||
|
])],
|
||||||
|
);
|
||||||
|
|
||||||
|
spec.apply_config(&config);
|
||||||
|
|
||||||
|
assert_eq!(spec.build.flags.cc, "my-cc");
|
||||||
|
assert!(spec.build.flags.cflags.contains(&"-O2".to_string()));
|
||||||
|
assert!(spec.build.flags.cflags.contains(&"-g".to_string()));
|
||||||
|
assert!(spec.build.flags.rustflags.contains(&"-C".to_string()));
|
||||||
|
assert!(
|
||||||
|
spec.build
|
||||||
|
.flags
|
||||||
|
.rustflags
|
||||||
|
.contains(&"opt-level=3".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chost_cbuild_overrides() {
|
||||||
|
let mut spec = mk_spec("foo", "1.0");
|
||||||
|
let config = crate::config::Config {
|
||||||
|
cache_dir: "/tmp".into(),
|
||||||
|
build_dir: "/tmp".into(),
|
||||||
|
db_dir: "/tmp".into(),
|
||||||
|
build_overrides: toml::from_str(
|
||||||
|
r#"
|
||||||
|
chost = "x86_64-sfg-linux-gnu"
|
||||||
|
cbuild = "x86_64-pc-linux-gnu"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
package_overrides: toml::Value::Table(toml::map::Map::new()),
|
||||||
|
appends: std::collections::HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
spec.apply_config(&config);
|
||||||
|
assert_eq!(spec.build.flags.chost, "x86_64-sfg-linux-gnu");
|
||||||
|
assert_eq!(spec.build.flags.cbuild, "x86_64-pc-linux-gnu");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_package_filename() {
|
||||||
|
let mut spec = mk_spec("foo", "1.0");
|
||||||
|
spec.package.revision = 2;
|
||||||
|
assert_eq!(
|
||||||
|
spec.package_filename("x86_64"),
|
||||||
|
"foo-1.0-2-x86_64.depot.pkg.tar.zst"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mk_spec(name: &str, version: &str) -> PackageSpec {
|
||||||
|
PackageSpec {
|
||||||
|
package: PackageInfo {
|
||||||
|
name: name.into(),
|
||||||
|
version: version.into(),
|
||||||
|
revision: 1,
|
||||||
|
description: "d".into(),
|
||||||
|
homepage: "h".into(),
|
||||||
|
license: "MIT".into(),
|
||||||
|
},
|
||||||
|
alternatives: Alternatives::default(),
|
||||||
|
manual_sources: Vec::new(),
|
||||||
|
source: vec![Source {
|
||||||
|
url: "h".into(),
|
||||||
|
sha256: "s".into(),
|
||||||
|
extract_dir: "e".into(),
|
||||||
|
patches: Vec::new(),
|
||||||
|
post_extract: Vec::new(),
|
||||||
|
}],
|
||||||
|
build: Build {
|
||||||
|
build_type: BuildType::Custom,
|
||||||
|
flags: BuildFlags::default(),
|
||||||
|
},
|
||||||
|
dependencies: Dependencies::default(),
|
||||||
|
spec_dir: PathBuf::from("."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PackageSpec {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
"Package: {} v{}",
|
||||||
|
self.package.name, self.package.version
|
||||||
|
)?;
|
||||||
|
writeln!(f, "Description: {}", self.package.description)?;
|
||||||
|
writeln!(f, "Homepage: {}", self.package.homepage)?;
|
||||||
|
writeln!(f, "License: {}", self.package.license)?;
|
||||||
|
writeln!(f, "Sources: {}", self.source.len())?;
|
||||||
|
writeln!(f, "Build Type: {:?}", self.build.build_type)?;
|
||||||
|
if !self.alternatives.provides.is_empty() {
|
||||||
|
writeln!(f, "Provides: {}", self.alternatives.provides.join(", "))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package metadata
|
||||||
|
#[derive(Debug, Deserialize, serde::Serialize, Clone)]
|
||||||
|
pub struct PackageInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
/// Maintenance revision of the package (defaults to 1)
|
||||||
|
#[serde(default = "default_revision")]
|
||||||
|
pub revision: u32,
|
||||||
|
pub description: String,
|
||||||
|
pub homepage: String,
|
||||||
|
pub license: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_revision() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageSpec {
|
||||||
|
/// Generate the standard package filename: <name>-<version>-<revision>-<arch>.depot.pkg.tar.zst
|
||||||
|
pub fn package_filename(&self, arch: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}-{}-{}-{}.depot.pkg.tar.zst",
|
||||||
|
self.package.name, self.package.version, self.package.revision, arch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package alternatives (provides/replaces)
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct Alternatives {
|
||||||
|
#[serde(default)]
|
||||||
|
pub provides: Vec<String>,
|
||||||
|
/// Reserved for future package replacement feature
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub replaces: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source tarball information
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Source {
|
||||||
|
pub url: String,
|
||||||
|
/// SHA256 checksum or "skip" to bypass
|
||||||
|
pub sha256: String,
|
||||||
|
/// Directory name after extraction (supports $name, $version)
|
||||||
|
pub extract_dir: String,
|
||||||
|
|
||||||
|
/// Patch files or URLs to apply after extraction.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// patches = ["fix-build.patch", "<https://example.com/patches/foo.patch>"]
|
||||||
|
#[serde(default)]
|
||||||
|
pub patches: Vec<String>,
|
||||||
|
|
||||||
|
/// Commands to run after extraction (and after patches), executed in the source directory.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// post_extract = ["autoreconf -fi"]
|
||||||
|
#[serde(default)]
|
||||||
|
pub post_extract: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manual (local) source file to copy before fetching remote sources.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ManualSource {
|
||||||
|
/// Filename in the spec directory
|
||||||
|
pub file: String,
|
||||||
|
/// SHA256 checksum (optional, use "skip" to bypass verification)
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
/// Destination path relative to build work directory (default: same as file)
|
||||||
|
#[serde(default)]
|
||||||
|
pub dest: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum OneOrManySources {
|
||||||
|
One(Source),
|
||||||
|
Many(Vec<Source>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_sources<'de, D>(deserializer: D) -> std::result::Result<Vec<Source>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
// Try to deserialize; if the field is missing/null, return empty vec
|
||||||
|
let parsed = Option::<OneOrManySources>::deserialize(deserializer)?;
|
||||||
|
match parsed {
|
||||||
|
Some(OneOrManySources::One(s)) => Ok(vec![s]),
|
||||||
|
Some(OneOrManySources::Many(v)) => Ok(v),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build configuration
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Build {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub build_type: BuildType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flags: BuildFlags,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported build systems
|
||||||
|
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum BuildType {
|
||||||
|
Autotools,
|
||||||
|
CMake,
|
||||||
|
Meson,
|
||||||
|
Custom,
|
||||||
|
Rust,
|
||||||
|
/// Binary distribution installer (e.g., .deb, .rpm)
|
||||||
|
Bin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build flags and toolchain configuration
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct BuildFlags {
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_or_array")]
|
||||||
|
pub cflags: Vec<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_or_array")]
|
||||||
|
pub ldflags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub configure: Vec<String>,
|
||||||
|
/// C compiler
|
||||||
|
#[serde(default = "default_cc")]
|
||||||
|
pub cc: String,
|
||||||
|
/// Archiver
|
||||||
|
#[serde(default = "default_ar")]
|
||||||
|
pub ar: String,
|
||||||
|
/// Dynamic loader path
|
||||||
|
#[serde(default)]
|
||||||
|
pub libc: String,
|
||||||
|
/// Root filesystem for installation (per-package override)
|
||||||
|
#[serde(default = "default_rootfs")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub rootfs: String,
|
||||||
|
/// Commands to run after compile (after make, before make install)
|
||||||
|
#[serde(default)]
|
||||||
|
pub post_compile: Vec<String>,
|
||||||
|
/// Commands to run after install (after make install)
|
||||||
|
#[serde(default)]
|
||||||
|
pub post_install: Vec<String>,
|
||||||
|
|
||||||
|
/// Installation prefix (default: /usr)
|
||||||
|
#[serde(default = "default_prefix")]
|
||||||
|
pub prefix: String,
|
||||||
|
|
||||||
|
/// Target architecture triple (CHOST equivalent)
|
||||||
|
#[serde(default)]
|
||||||
|
pub chost: String,
|
||||||
|
|
||||||
|
/// Build architecture triple (CBUILD equivalent)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cbuild: String,
|
||||||
|
|
||||||
|
// Rust-specific fields
|
||||||
|
/// Rust build profile: "debug" or "release" (default: release)
|
||||||
|
#[serde(default = "default_profile")]
|
||||||
|
pub profile: String,
|
||||||
|
/// Rust target triple (e.g., x86_64-unknown-linux-musl). Optional.
|
||||||
|
#[serde(default)]
|
||||||
|
pub target: String,
|
||||||
|
/// RUSTFLAGS environment variable
|
||||||
|
#[serde(default, deserialize_with = "deserialize_string_or_array")]
|
||||||
|
pub rustflags: Vec<String>,
|
||||||
|
/// Additional cargo arguments (short name)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cargs: Vec<String>,
|
||||||
|
/// Binary installation directory relative to DESTDIR (default: /usr/bin)
|
||||||
|
#[serde(default = "default_bindir")]
|
||||||
|
pub bindir: String,
|
||||||
|
|
||||||
|
/// Subdirectory within extracted source to use as the actual source root.
|
||||||
|
/// Useful for monorepos like llvm-project where you want to build just one component.
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_subdir: String,
|
||||||
|
/// Binary package type when using BuildType::Bin (e.g. "deb")
|
||||||
|
#[serde(default)]
|
||||||
|
pub binary_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_string_or_array<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Vec<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum StringOrArray {
|
||||||
|
String(String),
|
||||||
|
Array(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
match Option::<StringOrArray>::deserialize(deserializer)? {
|
||||||
|
Some(StringOrArray::String(s)) => Ok(s.split_whitespace().map(String::from).collect()),
|
||||||
|
Some(StringOrArray::Array(a)) => Ok(a),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cc() -> String {
|
||||||
|
// Prefer clang if available (supports -print-resource-dir and other useful flags)
|
||||||
|
if std::process::Command::new("which")
|
||||||
|
.arg("clang")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return "clang".to_string();
|
||||||
|
}
|
||||||
|
"gcc".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ar() -> String {
|
||||||
|
"ar".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rootfs() -> String {
|
||||||
|
"/".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_profile() -> String {
|
||||||
|
"release".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bindir() -> String {
|
||||||
|
"/usr/bin".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_prefix() -> String {
|
||||||
|
"/usr".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Package dependencies
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct Dependencies {
|
||||||
|
#[serde(default)]
|
||||||
|
pub build: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: Vec<String>,
|
||||||
|
}
|
||||||
Executable
+495
@@ -0,0 +1,495 @@
|
|||||||
|
//! Archive extraction support
|
||||||
|
|
||||||
|
use crate::package::{PackageSpec, Source};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
use std::os::unix::fs as unix_fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use zstd::stream::read::Decoder as ZstdDecoder;
|
||||||
|
|
||||||
|
/// Extract an archive source to the build directory.
|
||||||
|
pub fn extract_archive(
|
||||||
|
archive_path: &Path,
|
||||||
|
spec: &PackageSpec,
|
||||||
|
source: &Source,
|
||||||
|
build_dir: &Path,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let extract_dir_name = spec.expand_vars(&source.extract_dir);
|
||||||
|
let extract_path = build_dir.join(&extract_dir_name);
|
||||||
|
|
||||||
|
// Create build directory
|
||||||
|
fs::create_dir_all(build_dir)
|
||||||
|
.with_context(|| format!("Failed to create build dir: {}", build_dir.display()))?;
|
||||||
|
|
||||||
|
// Remove existing extraction if present
|
||||||
|
if extract_path.exists() {
|
||||||
|
fs::remove_dir_all(&extract_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = archive_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!("Extracting: {}", filename);
|
||||||
|
|
||||||
|
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||||
|
extract_tar_gz(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||||
|
extract_tar_xz(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") {
|
||||||
|
extract_tar_bz2(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tzst") {
|
||||||
|
extract_tar_zst(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".zip") {
|
||||||
|
extract_zip(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".tar") {
|
||||||
|
extract_tar(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".deb") {
|
||||||
|
extract_deb(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".rpm") {
|
||||||
|
extract_rpm(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".gz") {
|
||||||
|
extract_gz_file(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".xz") {
|
||||||
|
extract_xz_file(archive_path, build_dir)?;
|
||||||
|
} else if filename.ends_with(".zst") {
|
||||||
|
extract_zst_file(archive_path, build_dir)?;
|
||||||
|
} else {
|
||||||
|
bail!("Unsupported archive format: {}", filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !extract_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Expected extraction directory not found: {}",
|
||||||
|
extract_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Extracted to: {}", extract_path.display());
|
||||||
|
Ok(extract_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar_gz(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let decoder = GzDecoder::new(file);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar_xz(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let decoder = xz2::read::XzDecoder::new(file);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar_bz2(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let decoder = bzip2::read::BzDecoder::new(file);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut archive = tar::Archive::new(file);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file)?;
|
||||||
|
archive.extract(dest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar_zst(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let decoder = ZstdDecoder::new(file)?;
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_gz_file(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut decoder = GzDecoder::new(file);
|
||||||
|
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
|
||||||
|
let out_name = if let Some(stripped) = filename.strip_suffix(".gz") {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
filename
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
let mut out = File::create(dest.join(out_name))?;
|
||||||
|
std::io::copy(&mut decoder, &mut out)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_xz_file(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut decoder = xz2::read::XzDecoder::new(file);
|
||||||
|
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
|
||||||
|
let out_name = if let Some(stripped) = filename.strip_suffix(".xz") {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
filename
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
let mut out = File::create(dest.join(out_name))?;
|
||||||
|
std::io::copy(&mut decoder, &mut out)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_zst_file(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut decoder = ZstdDecoder::new(file)?;
|
||||||
|
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
|
||||||
|
let out_name = if let Some(stripped) = filename.strip_suffix(".zst") {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
filename
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
let mut out = File::create(dest.join(out_name))?;
|
||||||
|
std::io::copy(&mut decoder, &mut out)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_deb(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
// Debian packages are ar archives containing (among others) a data.tar.* member
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut ar = ar::Archive::new(file);
|
||||||
|
|
||||||
|
// Iterate members and look for data.tar, data.tar.gz, data.tar.xz, data.tar.zst
|
||||||
|
while let Some(entry_result) = ar.next_entry() {
|
||||||
|
let entry = entry_result?;
|
||||||
|
let id = String::from_utf8_lossy(entry.header().identifier()).to_string();
|
||||||
|
let lower = id.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("data.tar") {
|
||||||
|
// Determine compression
|
||||||
|
if lower.ends_with(".gz") {
|
||||||
|
let decoder = GzDecoder::new(entry);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
return Ok(());
|
||||||
|
} else if lower.ends_with(".xz") {
|
||||||
|
let decoder = xz2::read::XzDecoder::new(entry);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
return Ok(());
|
||||||
|
} else if lower.ends_with(".zst") {
|
||||||
|
let decoder = ZstdDecoder::new(entry)?;
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
// plain tar
|
||||||
|
let mut archive = tar::Archive::new(entry);
|
||||||
|
archive.unpack(dest)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No data member found
|
||||||
|
anyhow::bail!("No data.tar.* member found in deb: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cpio_newc_from_reader<R: Read>(mut r: R, dest: &Path) -> Result<()> {
|
||||||
|
use std::str;
|
||||||
|
loop {
|
||||||
|
// read 6-byte magic
|
||||||
|
let mut magic = [0u8; 6];
|
||||||
|
if let Err(e) = r.read_exact(&mut magic) {
|
||||||
|
// EOF
|
||||||
|
return Err(e).with_context(|| "Failed reading cpio magic")?;
|
||||||
|
}
|
||||||
|
if &magic != b"070701" {
|
||||||
|
anyhow::bail!("Unsupported cpio magic: {:?}", &magic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// read remaining 104 bytes of header (total header is 110)
|
||||||
|
let mut rest = [0u8; 104];
|
||||||
|
r.read_exact(&mut rest)?;
|
||||||
|
let header_str = str::from_utf8(&rest).context("Invalid cpio header encoding")?;
|
||||||
|
// parse 13 hex fields of 8 chars each
|
||||||
|
if header_str.len() < 8 * 13 {
|
||||||
|
anyhow::bail!("Truncated cpio header");
|
||||||
|
}
|
||||||
|
let mut fields = [0u64; 13];
|
||||||
|
for i in 0..13 {
|
||||||
|
let part = &header_str[i * 8..i * 8 + 8];
|
||||||
|
fields[i] = u64::from_str_radix(part, 16)
|
||||||
|
.with_context(|| format!("Invalid header field hex: {}", part))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let namesize = fields[11] as usize;
|
||||||
|
let filesize = fields[6] as usize;
|
||||||
|
|
||||||
|
// read name
|
||||||
|
let mut name_buf = vec![0u8; namesize];
|
||||||
|
r.read_exact(&mut name_buf)?;
|
||||||
|
let name = match name_buf.iter().position(|&b| b == 0) {
|
||||||
|
Some(p) => String::from_utf8_lossy(&name_buf[..p]).to_string(),
|
||||||
|
None => String::from_utf8_lossy(&name_buf).to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// skip header padding to 4 bytes
|
||||||
|
let header_total = 110 + namesize;
|
||||||
|
let header_pad = (4 - (header_total % 4)) % 4;
|
||||||
|
if header_pad > 0 {
|
||||||
|
let mut tmp = vec![0u8; header_pad];
|
||||||
|
r.read_exact(&mut tmp)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "TRAILER!!!" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = fields[1];
|
||||||
|
let file_path = dest.join(&name);
|
||||||
|
|
||||||
|
if (mode & 0o170000) == 0o040000 {
|
||||||
|
// directory
|
||||||
|
fs::create_dir_all(&file_path)?;
|
||||||
|
} else if (mode & 0o170000) == 0o120000 {
|
||||||
|
// symlink
|
||||||
|
let mut link_buf = vec![0u8; filesize];
|
||||||
|
r.read_exact(&mut link_buf)?;
|
||||||
|
let target = String::from_utf8_lossy(&link_buf).to_string();
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&file_path);
|
||||||
|
unix_fs::symlink(target, &file_path)?;
|
||||||
|
} else {
|
||||||
|
// regular file
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut out = File::create(&file_path)?;
|
||||||
|
let mut remaining = filesize;
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
while remaining > 0 {
|
||||||
|
let to_read = std::cmp::min(remaining, buf.len());
|
||||||
|
let n = r.read(&mut buf[..to_read])?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.write_all(&buf[..n])?;
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
// set permissions
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(
|
||||||
|
&file_path,
|
||||||
|
fs::Permissions::from_mode((mode & 0o7777) as u32),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip file data padding
|
||||||
|
let data_pad = (4 - (filesize % 4)) % 4;
|
||||||
|
if data_pad > 0 {
|
||||||
|
let mut tmp = vec![0u8; data_pad];
|
||||||
|
r.read_exact(&mut tmp)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_rpm(path: &Path, dest: &Path) -> Result<()> {
|
||||||
|
// Read entire file and search for compression/cpio magic
|
||||||
|
let data = std::fs::read(path)?;
|
||||||
|
|
||||||
|
// search for known signatures
|
||||||
|
let gz_sig = b"\x1f\x8b";
|
||||||
|
let xz_sig = b"\xfd7zXZ\x00";
|
||||||
|
let zst_sig = b"\x28\xb5\x2f\xfd";
|
||||||
|
let cpio_sig = b"070701";
|
||||||
|
|
||||||
|
if let Some(pos) = find_subslice(&data, gz_sig) {
|
||||||
|
let cursor = Cursor::new(&data[pos..]);
|
||||||
|
let decoder = GzDecoder::new(cursor);
|
||||||
|
extract_cpio_newc_from_reader(decoder, dest)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(pos) = find_subslice(&data, xz_sig) {
|
||||||
|
let cursor = Cursor::new(&data[pos..]);
|
||||||
|
let decoder = xz2::read::XzDecoder::new(cursor);
|
||||||
|
extract_cpio_newc_from_reader(decoder, dest)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(pos) = find_subslice(&data, zst_sig) {
|
||||||
|
let cursor = Cursor::new(&data[pos..]);
|
||||||
|
let decoder = ZstdDecoder::new(cursor)?;
|
||||||
|
extract_cpio_newc_from_reader(decoder, dest)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(pos) = find_subslice(&data, cpio_sig) {
|
||||||
|
let cursor = Cursor::new(&data[pos..]);
|
||||||
|
extract_cpio_newc_from_reader(cursor, dest)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"No recognizable cpio payload found in rpm: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
hay.windows(needle.len()).position(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_deb_roundtrip() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let deb_path = tmp.path().join("test.deb");
|
||||||
|
let extract_dir = tmp.path().join("out-deb");
|
||||||
|
|
||||||
|
// create a small tar.gz payload with one file
|
||||||
|
let mut tar_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let gz = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default());
|
||||||
|
let mut tar = tar::Builder::new(gz);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
let data = b"hello-deb";
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
tar.append_data(&mut header, "usr/bin/hello-deb", &data[..])
|
||||||
|
.unwrap();
|
||||||
|
tar.finish().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// write ar archive with member data.tar.gz (use temp files because ar::Builder expects File)
|
||||||
|
{
|
||||||
|
let db = tmp.path().join("debian-binary");
|
||||||
|
let ct = tmp.path().join("control.tar.gz");
|
||||||
|
let dt = tmp.path().join("data.tar.gz");
|
||||||
|
std::fs::write(&db, b"2.0\n").unwrap();
|
||||||
|
std::fs::write(&ct, b"").unwrap();
|
||||||
|
std::fs::write(&dt, &tar_buf[..]).unwrap();
|
||||||
|
|
||||||
|
let mut f = File::create(&deb_path).unwrap();
|
||||||
|
let mut builder = ar::Builder::new(&mut f);
|
||||||
|
let mut dbf = File::open(&db).unwrap();
|
||||||
|
let mut ctf = File::open(&ct).unwrap();
|
||||||
|
let mut dtf = File::open(&dt).unwrap();
|
||||||
|
builder.append_file(b"debian-binary", &mut dbf).unwrap();
|
||||||
|
builder.append_file(b"control.tar.gz", &mut ctf).unwrap();
|
||||||
|
builder.append_file(b"data.tar.gz", &mut dtf).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(&extract_dir).unwrap();
|
||||||
|
extract_deb(&deb_path, &extract_dir).unwrap();
|
||||||
|
assert!(extract_dir.join("usr/bin/hello-deb").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_cpio_newc_one_file(w: &mut Vec<u8>, name: &str, data: &[u8]) {
|
||||||
|
// magic + 13 fields of 8 hex chars each
|
||||||
|
fn h8(v: u64) -> String {
|
||||||
|
format!("{:08x}", v)
|
||||||
|
}
|
||||||
|
let namesize = name.len() + 1;
|
||||||
|
let filesize = data.len();
|
||||||
|
let mut header = Vec::new();
|
||||||
|
header.extend_from_slice(b"070701");
|
||||||
|
// ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, check
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // ino
|
||||||
|
header.extend_from_slice(h8(0o100644).as_bytes()); // mode regular file with perms
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // uid
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // gid
|
||||||
|
header.extend_from_slice(h8(1).as_bytes()); // nlink
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // mtime
|
||||||
|
header.extend_from_slice(h8(filesize as u64).as_bytes());
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // devmajor
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // devminor
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // rdevmajor
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // rdevminor
|
||||||
|
header.extend_from_slice(h8(namesize as u64).as_bytes());
|
||||||
|
header.extend_from_slice(h8(0).as_bytes()); // check
|
||||||
|
w.extend_from_slice(&header);
|
||||||
|
w.extend_from_slice(name.as_bytes());
|
||||||
|
w.push(0);
|
||||||
|
// pad to 4
|
||||||
|
let pad = (4 - ((110 + namesize) % 4)) % 4;
|
||||||
|
for _ in 0..pad {
|
||||||
|
w.push(0);
|
||||||
|
}
|
||||||
|
// file data
|
||||||
|
w.extend_from_slice(data);
|
||||||
|
let dpad = (4 - (filesize % 4)) % 4;
|
||||||
|
for _ in 0..dpad {
|
||||||
|
w.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_cpio_trailer(w: &mut Vec<u8>) {
|
||||||
|
write_cpio_newc_one_file(w, "TRAILER!!!", &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_rpm_roundtrip() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let rpm_path = tmp.path().join("test.rpm");
|
||||||
|
let extract_dir = tmp.path().join("out-rpm");
|
||||||
|
|
||||||
|
// build cpio newc stream with one file
|
||||||
|
let mut cpio = Vec::new();
|
||||||
|
write_cpio_newc_one_file(&mut cpio, "usr/bin/hello-rpm", b"hello-rpm");
|
||||||
|
write_cpio_trailer(&mut cpio);
|
||||||
|
|
||||||
|
// gzip compress
|
||||||
|
let mut gz = Vec::new();
|
||||||
|
{
|
||||||
|
let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
|
||||||
|
enc.write_all(&cpio).unwrap();
|
||||||
|
enc.finish().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// write fake rpm: some header bytes then gz payload
|
||||||
|
{
|
||||||
|
let mut f = File::create(&rpm_path).unwrap();
|
||||||
|
f.write_all(b"RPMHEAD").unwrap();
|
||||||
|
f.write_all(&gz).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(&extract_dir).unwrap();
|
||||||
|
extract_rpm(&rpm_path, &extract_dir).unwrap();
|
||||||
|
assert!(extract_dir.join("usr/bin/hello-rpm").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+156
@@ -0,0 +1,156 @@
|
|||||||
|
//! Source tarball fetching with checksum verification
|
||||||
|
|
||||||
|
use crate::package::{PackageSpec, Source};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Fetch an archive source tarball, returning path to downloaded file.
|
||||||
|
pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> Result<PathBuf> {
|
||||||
|
let url = spec.expand_vars(&source.url);
|
||||||
|
let filename = derive_filename_from_url(&url);
|
||||||
|
let dest_path = cache_dir.join(&filename);
|
||||||
|
|
||||||
|
// Create cache directory if needed
|
||||||
|
fs::create_dir_all(cache_dir)
|
||||||
|
.with_context(|| format!("Failed to create cache dir: {}", cache_dir.display()))?;
|
||||||
|
|
||||||
|
// Check if already cached and verified
|
||||||
|
if dest_path.exists() && verify_checksum(&dest_path, &source.sha256)? {
|
||||||
|
println!("Using cached source: {}", dest_path.display());
|
||||||
|
return Ok(dest_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Fetching: {}", url);
|
||||||
|
|
||||||
|
// Download with progress bar
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let mut response = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.with_context(|| format!("Failed to fetch: {}", url))?;
|
||||||
|
|
||||||
|
let total_size = response.content_length().unwrap_or(0);
|
||||||
|
let pb = ProgressBar::new(total_size);
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("#>-"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut file = File::create(&dest_path)
|
||||||
|
.with_context(|| format!("Failed to create: {}", dest_path.display()))?;
|
||||||
|
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
let mut downloaded = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bytes_read = response.read(&mut buffer)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
file.write_all(&buffer[..bytes_read])?;
|
||||||
|
downloaded += bytes_read as u64;
|
||||||
|
pb.set_position(downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.finish_with_message("Download complete");
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
if !verify_checksum(&dest_path, &source.sha256)? {
|
||||||
|
fs::remove_file(&dest_path)?;
|
||||||
|
bail!("Checksum verification failed for {}", filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Checksum verified: {}", filename);
|
||||||
|
Ok(dest_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify SHA256 checksum of a file
|
||||||
|
fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
|
||||||
|
// Skip verification if requested
|
||||||
|
if expected.to_lowercase() == "skip" {
|
||||||
|
println!("Checksum verification skipped");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bytes_read = file.read(&mut buffer)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..bytes_read]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = hasher.finalize();
|
||||||
|
let actual = format!("{:x}", result);
|
||||||
|
|
||||||
|
Ok(actual == expected.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a stable filename from a URL.
|
||||||
|
///
|
||||||
|
/// Rules:
|
||||||
|
/// - Parse as URL and use the last path segment if it looks like a filename (contains a dot)
|
||||||
|
/// - Otherwise fall back to a stable hash-based name: source-{sha256(url)[..12]}.download
|
||||||
|
fn derive_filename_from_url(url: &str) -> String {
|
||||||
|
// try to parse the URL
|
||||||
|
if let Some(last) = Url::parse(url).ok().and_then(|parsed| {
|
||||||
|
parsed
|
||||||
|
.path_segments()?
|
||||||
|
.rfind(|s| !s.is_empty())
|
||||||
|
.filter(|l| l.contains('.'))
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
}) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: stable short-hash name
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(url.as_bytes());
|
||||||
|
let h = hasher.finalize();
|
||||||
|
let hex = format!("{:x}", h);
|
||||||
|
format!("source-{}.download", &hex[..12])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filename_from_simple_url() {
|
||||||
|
assert_eq!(
|
||||||
|
derive_filename_from_url("https://example.com/foo-1.2.3.tar.gz"),
|
||||||
|
"foo-1.2.3.tar.gz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filename_from_url_with_query() {
|
||||||
|
assert_eq!(
|
||||||
|
derive_filename_from_url("https://example.com/foo.tar.gz?raw=1"),
|
||||||
|
"foo.tar.gz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filename_from_url_without_real_name() {
|
||||||
|
let name = derive_filename_from_url("https://github.com/org/repo/releases/download?id=123");
|
||||||
|
assert!(name.starts_with("source-") && name.ends_with(".download"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filename_from_non_url_string() {
|
||||||
|
let name = derive_filename_from_url("not-a-url-at-all");
|
||||||
|
assert!(name.starts_with("source-") && name.ends_with(".download"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
//! Git source support via libgit2 (git2 crate)
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use git2::{Cred, FetchOptions, Oid, RemoteCallbacks, Repository};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Checkout a repository URL at a specific revision into `checkout_dir`.
|
||||||
|
///
|
||||||
|
/// The URL is expected to be the base URL (without the `#rev` fragment).
|
||||||
|
/// The revision may be a tag name, branch name, or commit hash.
|
||||||
|
///
|
||||||
|
/// Repositories are mirrored under `git_cache_dir` to avoid repeated network fetches.
|
||||||
|
pub fn checkout(
|
||||||
|
url: &str,
|
||||||
|
rev: &str,
|
||||||
|
checkout_dir: &Path,
|
||||||
|
git_cache_dir: &Path,
|
||||||
|
pkgname: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
fs::create_dir_all(git_cache_dir)
|
||||||
|
.with_context(|| format!("Failed to create git cache dir: {}", git_cache_dir.display()))?;
|
||||||
|
|
||||||
|
let mirror_dir = git_cache_dir.join(mirror_key(url));
|
||||||
|
ensure_mirror(url, &mirror_dir, pkgname)?;
|
||||||
|
|
||||||
|
if checkout_dir.exists() {
|
||||||
|
fs::remove_dir_all(checkout_dir)
|
||||||
|
.with_context(|| format!("Failed to remove existing checkout: {}", checkout_dir.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone from local mirror for speed.
|
||||||
|
let mirror_url = mirror_dir
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Invalid mirror path"))?;
|
||||||
|
|
||||||
|
println!("Cloning git source into {}...", checkout_dir.display());
|
||||||
|
Repository::clone(mirror_url, checkout_dir)
|
||||||
|
.with_context(|| format!("Failed to clone from mirror for {}", url))?;
|
||||||
|
|
||||||
|
let repo = Repository::open(checkout_dir)?;
|
||||||
|
checkout_rev(&repo, rev)
|
||||||
|
.with_context(|| format!("Failed to checkout revision '{}'", rev))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mirror_key(url: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(url.as_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
format!("{:x}", digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str) -> Result<()> {
|
||||||
|
if !mirror_dir.exists() {
|
||||||
|
println!("Cloning git mirror for {} ({})...", pkgname, url);
|
||||||
|
let mut fo = FetchOptions::new();
|
||||||
|
fo.remote_callbacks(remote_callbacks());
|
||||||
|
let mut builder = git2::build::RepoBuilder::new();
|
||||||
|
builder.fetch_options(fo);
|
||||||
|
builder.bare(true);
|
||||||
|
builder
|
||||||
|
.clone(url, mirror_dir)
|
||||||
|
.with_context(|| format!("Failed to clone git mirror: {}", url))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch updates
|
||||||
|
let repo = Repository::open_bare(mirror_dir)
|
||||||
|
.with_context(|| format!("Failed to open git mirror: {}", mirror_dir.display()))?;
|
||||||
|
|
||||||
|
let mut remote = repo
|
||||||
|
.find_remote("origin")
|
||||||
|
.or_else(|_| repo.remote_anonymous(url))
|
||||||
|
.with_context(|| format!("Failed to create remote for {}", url))?;
|
||||||
|
|
||||||
|
let mut fo = FetchOptions::new();
|
||||||
|
fo.remote_callbacks(remote_callbacks());
|
||||||
|
|
||||||
|
// Fetch all remote refs (tags + heads). Empty refspec uses default.
|
||||||
|
remote
|
||||||
|
.fetch(&[] as &[&str], Some(&mut fo), None)
|
||||||
|
.with_context(|| format!("Failed to fetch updates for {}", url))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkout_rev(repo: &Repository, rev: &str) -> Result<()> {
|
||||||
|
// Try a few reasonable ways to resolve the rev.
|
||||||
|
let obj = repo
|
||||||
|
.revparse_single(rev)
|
||||||
|
.or_else(|_| repo.revparse_single(&format!("refs/tags/{rev}")))
|
||||||
|
.or_else(|_| repo.revparse_single(&format!("refs/heads/{rev}")))
|
||||||
|
.with_context(|| format!("Could not resolve git rev: {}", rev))?;
|
||||||
|
|
||||||
|
// Peel tags to commit if needed.
|
||||||
|
let commit = obj
|
||||||
|
.peel_to_commit()
|
||||||
|
.with_context(|| format!("Could not peel rev to commit: {}", rev))?;
|
||||||
|
|
||||||
|
let oid: Oid = commit.id();
|
||||||
|
repo.set_head_detached(oid)?;
|
||||||
|
repo.checkout_tree(commit.as_object(), None)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_callbacks() -> RemoteCallbacks<'static> {
|
||||||
|
let mut callbacks = RemoteCallbacks::new();
|
||||||
|
|
||||||
|
// Try SSH agent / default key locations.
|
||||||
|
callbacks.credentials(|_url, username_from_url, _allowed| {
|
||||||
|
if let Some(name) = username_from_url {
|
||||||
|
Cred::ssh_key_from_agent(name)
|
||||||
|
} else {
|
||||||
|
Cred::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks
|
||||||
|
}
|
||||||
Executable
+296
@@ -0,0 +1,296 @@
|
|||||||
|
//! Post-extraction hooks: apply patches and run commands
|
||||||
|
|
||||||
|
use crate::package::{PackageSpec, Source};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Apply patches and run `post_extract` commands in the extracted source tree.
|
||||||
|
pub fn post_extract(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
source: &Source,
|
||||||
|
src_dir: &Path,
|
||||||
|
cache_dir: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
apply_patches(spec, source, src_dir, cache_dir)?;
|
||||||
|
run_post_extract_commands(spec, source, src_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_patches(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
source: &Source,
|
||||||
|
src_dir: &Path,
|
||||||
|
cache_dir: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
if source.patches.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let patch_cache_dir = cache_dir.join("patches").join(&spec.package.name);
|
||||||
|
fs::create_dir_all(&patch_cache_dir).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to create patch cache dir: {}",
|
||||||
|
patch_cache_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!("Applying {} patch(es)...", source.patches.len());
|
||||||
|
|
||||||
|
for p in &source.patches {
|
||||||
|
let p = spec.expand_vars(p);
|
||||||
|
let patch_path = resolve_patch_path(spec, &p, &patch_cache_dir)?;
|
||||||
|
|
||||||
|
println!(" patch: {}", patch_path.display());
|
||||||
|
|
||||||
|
// Apply with patch(1). Keep it simple: -p1 is the common case.
|
||||||
|
let status = Command::new("patch")
|
||||||
|
.current_dir(src_dir)
|
||||||
|
.arg("-p1")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(&patch_path)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to execute patch for {}", patch_path.display()))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("Patch failed: {}", patch_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path) -> Result<()> {
|
||||||
|
if source.post_extract.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Running {} post-extract command(s)...",
|
||||||
|
source.post_extract.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for cmd in &source.post_extract {
|
||||||
|
let cmd = spec.expand_vars(cmd);
|
||||||
|
println!(" post_extract: {}", cmd);
|
||||||
|
|
||||||
|
// Use a shell for convenience; this is a package manager, so specs are trusted input.
|
||||||
|
let status = Command::new("sh")
|
||||||
|
.current_dir(src_dir)
|
||||||
|
.env("NYAPM_SPECDIR", &spec.spec_dir)
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run post_extract command: {}", cmd))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("post_extract command failed: {}", cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run post-compile commands (after make, before make install).
|
||||||
|
pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> {
|
||||||
|
let commands = &spec.build.flags.post_compile;
|
||||||
|
if commands.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Running {} post-compile command(s)...", commands.len());
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
let cmd = spec.expand_vars(cmd);
|
||||||
|
println!(" post_compile: {}", cmd);
|
||||||
|
|
||||||
|
let status = Command::new("sh")
|
||||||
|
.current_dir(src_dir)
|
||||||
|
.env("NYAPM_SPECDIR", &spec.spec_dir)
|
||||||
|
.env("DESTDIR", destdir)
|
||||||
|
.env("CC", &spec.build.flags.cc)
|
||||||
|
.env("AR", &spec.build.flags.ar)
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run post_compile command: {}", cmd))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("post_compile command failed: {}", cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run post-install commands (after make install).
|
||||||
|
pub fn run_post_install_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> {
|
||||||
|
let commands = &spec.build.flags.post_install;
|
||||||
|
if commands.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Running {} post-install command(s)...", commands.len());
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
let cmd = spec.expand_vars(cmd);
|
||||||
|
println!(" post_install: {}", cmd);
|
||||||
|
|
||||||
|
let status = Command::new("sh")
|
||||||
|
.current_dir(src_dir)
|
||||||
|
.env("NYAPM_SPECDIR", &spec.spec_dir)
|
||||||
|
.env("DESTDIR", destdir)
|
||||||
|
.env("CC", &spec.build.flags.cc)
|
||||||
|
.env("AR", &spec.build.flags.ar)
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run post_install command: {}", cmd))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("post_install command failed: {}", cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_patch_path(spec: &PackageSpec, patch: &str, patch_cache_dir: &Path) -> Result<PathBuf> {
|
||||||
|
if patch.starts_with("http://") || patch.starts_with("https://") {
|
||||||
|
let filename = patch
|
||||||
|
.split('/')
|
||||||
|
.rfind(|s| !s.is_empty())
|
||||||
|
.unwrap_or("patch.patch");
|
||||||
|
let dest = patch_cache_dir.join(filename);
|
||||||
|
if dest.exists() {
|
||||||
|
return Ok(dest);
|
||||||
|
}
|
||||||
|
download(patch, &dest).with_context(|| format!("Failed to download patch: {}", patch))?;
|
||||||
|
return Ok(dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as local path, relative to the spec file.
|
||||||
|
let local = spec.spec_dir.join(patch);
|
||||||
|
if !local.exists() {
|
||||||
|
bail!(
|
||||||
|
"Patch not found: {} (resolved to {})",
|
||||||
|
patch,
|
||||||
|
local.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(local)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download(url: &str, dest: &Path) -> Result<()> {
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let mut response = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.with_context(|| format!("Failed to fetch: {}", url))?;
|
||||||
|
|
||||||
|
let total_size = response.content_length().unwrap_or(0);
|
||||||
|
let pb = ProgressBar::new(total_size);
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("#>-")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut file =
|
||||||
|
fs::File::create(dest).with_context(|| format!("Failed to create: {}", dest.display()))?;
|
||||||
|
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
let mut downloaded = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bytes_read = response.read(&mut buffer)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
file.write_all(&buffer[..bytes_read])?;
|
||||||
|
downloaded += bytes_read as u64;
|
||||||
|
pb.set_position(downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.finish_with_message("Patch download complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::resolve_patch_path;
|
||||||
|
use crate::package::{
|
||||||
|
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn dummy_spec(spec_dir: &std::path::Path) -> PackageSpec {
|
||||||
|
PackageSpec {
|
||||||
|
package: PackageInfo {
|
||||||
|
name: "foo".into(),
|
||||||
|
version: "1.0".into(),
|
||||||
|
revision: 1,
|
||||||
|
description: "d".into(),
|
||||||
|
homepage: "h".into(),
|
||||||
|
license: "MIT".into(),
|
||||||
|
},
|
||||||
|
alternatives: Alternatives::default(),
|
||||||
|
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(),
|
||||||
|
}],
|
||||||
|
build: Build {
|
||||||
|
build_type: BuildType::Custom,
|
||||||
|
flags: BuildFlags::default(),
|
||||||
|
},
|
||||||
|
dependencies: Dependencies::default(),
|
||||||
|
spec_dir: spec_dir.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_patch_relative_to_spec_dir() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let spec_dir = tmp.path().join("spec");
|
||||||
|
std::fs::create_dir_all(&spec_dir).unwrap();
|
||||||
|
let patch_path = spec_dir.join("fix.patch");
|
||||||
|
std::fs::write(&patch_path, "diff --git a/a b/a\n").unwrap();
|
||||||
|
|
||||||
|
let spec = dummy_spec(&spec_dir);
|
||||||
|
let cache = tmp.path().join("cache");
|
||||||
|
std::fs::create_dir_all(&cache).unwrap();
|
||||||
|
let resolved = resolve_patch_path(&spec, "fix.patch", &cache).unwrap();
|
||||||
|
assert_eq!(resolved, patch_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_patch_url_uses_cached_file_if_present() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let spec_dir = tmp.path().join("spec");
|
||||||
|
std::fs::create_dir_all(&spec_dir).unwrap();
|
||||||
|
let spec = dummy_spec(&spec_dir);
|
||||||
|
|
||||||
|
let cache = tmp.path().join("cache");
|
||||||
|
let patch_cache_dir = cache.join("patches").join("foo");
|
||||||
|
std::fs::create_dir_all(&patch_cache_dir).unwrap();
|
||||||
|
|
||||||
|
// URL-derived filename is the last segment.
|
||||||
|
let cached = patch_cache_dir.join("fix.patch");
|
||||||
|
std::fs::write(&cached, "dummy").unwrap();
|
||||||
|
|
||||||
|
let url = "https://example.com/fix.patch";
|
||||||
|
let resolved = resolve_patch_path(&spec, url, &patch_cache_dir).unwrap();
|
||||||
|
assert_eq!(resolved, cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+249
@@ -0,0 +1,249 @@
|
|||||||
|
//! Source fetching and extraction
|
||||||
|
|
||||||
|
mod extractor;
|
||||||
|
mod fetcher;
|
||||||
|
mod git;
|
||||||
|
pub mod hooks;
|
||||||
|
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::os::unix::fs as unix_fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// Copy manual (local) sources to the build directory before fetching remote sources.
|
||||||
|
///
|
||||||
|
/// Manual sources are checked first, allowing local files like utility source code
|
||||||
|
/// to be available during the build process.
|
||||||
|
pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> {
|
||||||
|
if spec.manual_sources.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(build_dir)?;
|
||||||
|
println!("Copying {} manual source(s)...", spec.manual_sources.len());
|
||||||
|
|
||||||
|
for manual in &spec.manual_sources {
|
||||||
|
let src_path = spec.spec_dir.join(&manual.file);
|
||||||
|
if !src_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Manual source not found: {} (expected at {})",
|
||||||
|
manual.file,
|
||||||
|
src_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SHA256 if provided (unless "skip")
|
||||||
|
if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") {
|
||||||
|
let actual_hash = compute_sha256(&src_path)?;
|
||||||
|
if actual_hash != *expected_hash {
|
||||||
|
bail!(
|
||||||
|
"SHA256 mismatch for {}: expected {}, got {}",
|
||||||
|
manual.file,
|
||||||
|
expected_hash,
|
||||||
|
actual_hash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine destination
|
||||||
|
let dest_name = manual.dest.as_deref().unwrap_or(&manual.file);
|
||||||
|
let dest_path = build_dir.join(dest_name);
|
||||||
|
|
||||||
|
// Create parent directories if needed
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" {} -> {}", manual.file, dest_path.display());
|
||||||
|
fs::copy(&src_path, &dest_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to copy {} to {}",
|
||||||
|
src_path.display(),
|
||||||
|
dest_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_sha256(path: &Path) -> Result<String> {
|
||||||
|
let mut file = fs::File::open(path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buffer)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch + extract all sources.
|
||||||
|
///
|
||||||
|
/// Returns the primary source directory (the first source entry, or work_dir for manual-only packages).
|
||||||
|
pub fn prepare(spec: &PackageSpec, cache_dir: &Path, build_dir: &Path) -> Result<PathBuf> {
|
||||||
|
// If no remote sources, create work_dir and copy manual sources there
|
||||||
|
if spec.sources().is_empty() {
|
||||||
|
let work_dir = build_dir.join(&spec.package.name);
|
||||||
|
fs::create_dir_all(&work_dir)?;
|
||||||
|
copy_manual_sources(spec, &work_dir)?;
|
||||||
|
return Ok(work_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy manual sources first (before any remote fetching)
|
||||||
|
copy_manual_sources(spec, build_dir)?;
|
||||||
|
|
||||||
|
let mut primary: Option<PathBuf> = None;
|
||||||
|
|
||||||
|
for (idx, src) in spec.sources().iter().enumerate() {
|
||||||
|
let src_dir = prepare_one(spec, src, cache_dir, build_dir)
|
||||||
|
.with_context(|| format!("Failed to prepare source #{}", idx + 1))?;
|
||||||
|
if idx == 0 {
|
||||||
|
primary = Some(src_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
primary.ok_or_else(|| anyhow::anyhow!("No sources in spec"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_one(
|
||||||
|
spec: &PackageSpec,
|
||||||
|
source: &crate::package::Source,
|
||||||
|
cache_dir: &Path,
|
||||||
|
build_dir: &Path,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let url = spec.expand_vars(&source.url);
|
||||||
|
let extract_dir_name = spec.expand_vars(&source.extract_dir);
|
||||||
|
|
||||||
|
// Local file:// handling (directories or archives)
|
||||||
|
if let Some(path_str) = url.strip_prefix("file://") {
|
||||||
|
let local_path = PathBuf::from(path_str);
|
||||||
|
if local_path.is_dir() {
|
||||||
|
let dst = build_dir.join(&extract_dir_name);
|
||||||
|
if dst.exists() {
|
||||||
|
fs::remove_dir_all(&dst)?;
|
||||||
|
}
|
||||||
|
copy_dir_recursive(&local_path, &dst)?;
|
||||||
|
hooks::post_extract(spec, source, &dst, cache_dir)?;
|
||||||
|
return Ok(dst);
|
||||||
|
} else if local_path.is_file() {
|
||||||
|
let src_dir = extractor::extract_archive(&local_path, spec, source, build_dir)?;
|
||||||
|
hooks::post_extract(spec, source, &src_dir, cache_dir)?;
|
||||||
|
return Ok(src_dir);
|
||||||
|
} else {
|
||||||
|
bail!("Local file source not found: {}", local_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic: if the URL contains '#', treat it as a git URL with a revision.
|
||||||
|
// (except when it clearly looks like an archive URL)
|
||||||
|
if let Some((base, rev)) = split_git_url(&url) {
|
||||||
|
let checkout_dir = build_dir.join(&extract_dir_name);
|
||||||
|
git::checkout(
|
||||||
|
&base,
|
||||||
|
&rev,
|
||||||
|
&checkout_dir,
|
||||||
|
&cache_dir.join("git"),
|
||||||
|
&spec.package.name,
|
||||||
|
)?;
|
||||||
|
hooks::post_extract(spec, source, &checkout_dir, cache_dir)?;
|
||||||
|
return Ok(checkout_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive_path = fetcher::fetch_archive(spec, source, cache_dir)?;
|
||||||
|
let src_dir = extractor::extract_archive(&archive_path, spec, source, build_dir)?;
|
||||||
|
hooks::post_extract(spec, source, &src_dir, cache_dir)?;
|
||||||
|
Ok(src_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(dst)?;
|
||||||
|
for entry in WalkDir::new(src) {
|
||||||
|
let entry = entry?;
|
||||||
|
let rel = entry.path().strip_prefix(src).unwrap();
|
||||||
|
let target = dst.join(rel);
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target)?;
|
||||||
|
} else if entry.file_type().is_symlink() {
|
||||||
|
let target_link = fs::read_link(entry.path())?;
|
||||||
|
unix_fs::symlink(target_link, &target)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_git_url(url: &str) -> Option<(String, String)> {
|
||||||
|
// Check for explicit revision with #
|
||||||
|
if let Some((base, rev)) = url.split_once('#') {
|
||||||
|
// Ignore fragment for obvious archives.
|
||||||
|
let lower = base.to_ascii_lowercase();
|
||||||
|
let is_archive = lower.ends_with(".tar.gz")
|
||||||
|
|| lower.ends_with(".tgz")
|
||||||
|
|| lower.ends_with(".tar.xz")
|
||||||
|
|| lower.ends_with(".txz")
|
||||||
|
|| lower.ends_with(".tar.bz2")
|
||||||
|
|| lower.ends_with(".tbz2")
|
||||||
|
|| lower.ends_with(".zip")
|
||||||
|
|| lower.ends_with(".tar");
|
||||||
|
if is_archive {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if rev.trim().is_empty() {
|
||||||
|
// Empty revision after # - use HEAD
|
||||||
|
return Some((base.to_string(), "HEAD".to_string()));
|
||||||
|
}
|
||||||
|
return Some((base.to_string(), rev.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for bare .git URL without revision - default to HEAD
|
||||||
|
let lower = url.to_ascii_lowercase();
|
||||||
|
if lower.ends_with(".git") {
|
||||||
|
return Some((url.to_string(), "HEAD".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::split_git_url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_git_url_accepts_git_with_rev() {
|
||||||
|
let (base, rev) = split_git_url("https://example.com/repo.git#v1.2.3").unwrap();
|
||||||
|
assert_eq!(base, "https://example.com/repo.git");
|
||||||
|
assert_eq!(rev, "v1.2.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_git_url_accepts_bare_git_url() {
|
||||||
|
let (base, rev) = split_git_url("https://example.com/repo.git").unwrap();
|
||||||
|
assert_eq!(base, "https://example.com/repo.git");
|
||||||
|
assert_eq!(rev, "HEAD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_git_url_rejects_archive_urls() {
|
||||||
|
assert!(split_git_url("https://example.com/foo.tar.gz#deadbeef").is_none());
|
||||||
|
assert!(split_git_url("https://example.com/foo.zip#v1").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_git_url_empty_rev_defaults_to_head() {
|
||||||
|
let (base, rev) = split_git_url("https://example.com/repo.git#").unwrap();
|
||||||
|
assert_eq!(base, "https://example.com/repo.git");
|
||||||
|
assert_eq!(rev, "HEAD");
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+485
@@ -0,0 +1,485 @@
|
|||||||
|
//! Staging phase - file collection and cleanup
|
||||||
|
|
||||||
|
use crate::package::PackageSpec;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// Process staged files - remove .la files, strip binaries, etc.
|
||||||
|
pub fn process(destdir: &Path, _spec: &PackageSpec) -> Result<()> {
|
||||||
|
println!("Processing staged files...");
|
||||||
|
|
||||||
|
let mut removed_count = 0;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Remove libtool .la files
|
||||||
|
if path.extension().map(|e| e == "la").unwrap_or(false) {
|
||||||
|
println!(" Removing: {}", path.display());
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
removed_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removed_count > 0 {
|
||||||
|
println!("Removed {} .la file(s)", removed_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy license files into the staged tree.
|
||||||
|
///
|
||||||
|
/// Copies common license file patterns from the source directory root into:
|
||||||
|
/// `destdir/documentation/licenses/<pkgname>/...`
|
||||||
|
pub fn add_licenses(src_dir: &Path, destdir: &Path, pkgname: &str) -> Result<usize> {
|
||||||
|
let mut copied = 0usize;
|
||||||
|
let dst_base = destdir.join("usr/share/licenses").join(pkgname);
|
||||||
|
|
||||||
|
// Common patterns requested: LICENSE*, COPYING*
|
||||||
|
// (plus a couple of very common variants)
|
||||||
|
let is_license_name = |name: &str| {
|
||||||
|
let upper = name.to_ascii_uppercase();
|
||||||
|
upper.starts_with("LICENSE")
|
||||||
|
|| upper.starts_with("COPYING")
|
||||||
|
|| upper.starts_with("LICENCE")
|
||||||
|
|| upper.starts_with("NOTICE")
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(src_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return Ok(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_license_name(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(&dst_base)
|
||||||
|
.with_context(|| format!("Failed to create license dir: {}", dst_base.display()))?;
|
||||||
|
|
||||||
|
let dst = dst_base.join(name);
|
||||||
|
fs::copy(&path, &dst)
|
||||||
|
.with_context(|| format!("Failed to copy license {}", path.display()))?;
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if copied > 0 {
|
||||||
|
println!(
|
||||||
|
"Copied {} license file(s) to {}/",
|
||||||
|
copied,
|
||||||
|
dst_base.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FsTransaction {
|
||||||
|
rootfs: PathBuf,
|
||||||
|
tx_dir: PathBuf,
|
||||||
|
backed_up: Vec<String>,
|
||||||
|
created: Vec<String>,
|
||||||
|
removed: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsTransaction {
|
||||||
|
fn backup_path(&self, rel: &str) -> PathBuf {
|
||||||
|
self.tx_dir.join("backup").join(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn removed_backup_path(&self, rel: &str) -> PathBuf {
|
||||||
|
self.tx_dir.join("removed").join(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll back file operations performed by `install_atomic`.
|
||||||
|
pub fn rollback(&self) -> Result<()> {
|
||||||
|
// Restore removed files
|
||||||
|
for rel in &self.removed {
|
||||||
|
let src = self.removed_backup_path(rel);
|
||||||
|
let dst = self.rootfs.join(rel);
|
||||||
|
if src.exists() {
|
||||||
|
if let Some(parent) = dst.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
// Best-effort remove if something exists now.
|
||||||
|
let _ = fs::remove_file(&dst);
|
||||||
|
match fs::rename(&src, &dst) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(_) => {
|
||||||
|
fs::copy(&src, &dst)?;
|
||||||
|
fs::remove_file(&src)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore overwritten files
|
||||||
|
for rel in &self.backed_up {
|
||||||
|
let src = self.backup_path(rel);
|
||||||
|
let dst = self.rootfs.join(rel);
|
||||||
|
if src.exists() {
|
||||||
|
if let Some(parent) = dst.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&dst);
|
||||||
|
match fs::rename(&src, &dst) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(_) => {
|
||||||
|
fs::copy(&src, &dst)?;
|
||||||
|
fs::remove_file(&src)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove files that were newly created
|
||||||
|
for rel in &self.created {
|
||||||
|
let dst = self.rootfs.join(rel);
|
||||||
|
let _ = fs::remove_file(dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit the transaction (delete backup directory).
|
||||||
|
pub fn commit(self) -> Result<()> {
|
||||||
|
if self.tx_dir.exists() {
|
||||||
|
fs::remove_dir_all(&self.tx_dir)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install staged files using a rollback-capable transaction.
|
||||||
|
///
|
||||||
|
/// This is used for both first-time installs and updates. For updates, pass a
|
||||||
|
/// list of relative paths to remove (old manifest minus new manifest).
|
||||||
|
pub fn install_atomic(
|
||||||
|
destdir: &Path,
|
||||||
|
rootfs: &Path,
|
||||||
|
tx_base_dir: &Path,
|
||||||
|
remove_paths: &[String],
|
||||||
|
) -> Result<FsTransaction> {
|
||||||
|
fs::create_dir_all(tx_base_dir)
|
||||||
|
.with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?;
|
||||||
|
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let pid = std::process::id();
|
||||||
|
let tx_dir = tx_base_dir.join(format!("tx-{}-{}", ts, pid));
|
||||||
|
let backup_dir = tx_dir.join("backup");
|
||||||
|
let removed_dir = tx_dir.join("removed");
|
||||||
|
fs::create_dir_all(&backup_dir)?;
|
||||||
|
fs::create_dir_all(&removed_dir)?;
|
||||||
|
|
||||||
|
let mut tx = FsTransaction {
|
||||||
|
rootfs: rootfs.to_path_buf(),
|
||||||
|
tx_dir,
|
||||||
|
backed_up: Vec::new(),
|
||||||
|
created: Vec::new(),
|
||||||
|
removed: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Result<()> = (|| {
|
||||||
|
// First, create all directories from destdir (for packages with only directories)
|
||||||
|
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let src_path = entry.path();
|
||||||
|
let file_type = entry.file_type();
|
||||||
|
|
||||||
|
if !file_type.is_dir() || src_path == destdir {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel_path = src_path
|
||||||
|
.strip_prefix(destdir)
|
||||||
|
.context("Failed to strip destdir prefix")?;
|
||||||
|
|
||||||
|
let dest_path = rootfs.join(rel_path);
|
||||||
|
if !dest_path.exists() {
|
||||||
|
fs::create_dir_all(&dest_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy in new files.
|
||||||
|
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let src_path = entry.path();
|
||||||
|
let metadata = src_path
|
||||||
|
.symlink_metadata()
|
||||||
|
.context("Failed to get metadata")?;
|
||||||
|
let file_type = metadata.file_type();
|
||||||
|
|
||||||
|
// We want to install files AND symlinks (to anything)
|
||||||
|
if !file_type.is_file() && !file_type.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel_path = src_path
|
||||||
|
.strip_prefix(destdir)
|
||||||
|
.context("Failed to strip destdir prefix")?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let dest_path = rootfs.join(&rel_path);
|
||||||
|
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if dest_path.symlink_metadata().is_ok() {
|
||||||
|
// lexists checks existence without following symlinks
|
||||||
|
// Backup existing
|
||||||
|
let backup_path = tx.backup_path(&rel_path);
|
||||||
|
if let Some(parent) = backup_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if symlink, read link and recreate at backup.
|
||||||
|
let dest_meta = dest_path.symlink_metadata()?;
|
||||||
|
if dest_meta.file_type().is_symlink() {
|
||||||
|
let target = fs::read_link(&dest_path)?;
|
||||||
|
std::os::unix::fs::symlink(&target, &backup_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&dest_path, &backup_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.backed_up.push(rel_path.clone());
|
||||||
|
} else {
|
||||||
|
tx.created.push(rel_path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install new file/symlink
|
||||||
|
// Remove destination if it exists (we backed it up) so we can overwrite
|
||||||
|
if dest_path.symlink_metadata().is_ok() {
|
||||||
|
if dest_path.is_dir() {
|
||||||
|
fs::remove_dir_all(&dest_path)?;
|
||||||
|
} else {
|
||||||
|
fs::remove_file(&dest_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
let target = fs::read_link(src_path)?;
|
||||||
|
std::os::unix::fs::symlink(target, &dest_path)
|
||||||
|
.with_context(|| format!("Failed to create symlink: {}", rel_path))?;
|
||||||
|
} else {
|
||||||
|
fs::copy(src_path, &dest_path)
|
||||||
|
.with_context(|| format!("Failed to install: {}", rel_path))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove obsolete files (update only)
|
||||||
|
for rel in remove_paths {
|
||||||
|
let rel = rel.as_str();
|
||||||
|
let dest_path = rootfs.join(rel);
|
||||||
|
if !dest_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !dest_path.is_file() {
|
||||||
|
// Only track files for now.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let backup_path = tx.removed_backup_path(rel);
|
||||||
|
if let Some(parent) = backup_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::copy(&dest_path, &backup_path)
|
||||||
|
.with_context(|| format!("Failed to backup removed file: {}", rel))?;
|
||||||
|
fs::remove_file(&dest_path)
|
||||||
|
.with_context(|| format!("Failed to remove obsolete file: {}", rel))?;
|
||||||
|
tx.removed.push(rel.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_licenses_copies_common_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let src_dir = tmp.path().join("src");
|
||||||
|
let destdir = tmp.path().join("dest");
|
||||||
|
std::fs::create_dir_all(&src_dir).unwrap();
|
||||||
|
std::fs::create_dir_all(&destdir).unwrap();
|
||||||
|
|
||||||
|
std::fs::write(src_dir.join("LICENSE"), "license text").unwrap();
|
||||||
|
std::fs::write(src_dir.join("COPYING.md"), "copying text").unwrap();
|
||||||
|
std::fs::write(src_dir.join("README"), "not a license").unwrap();
|
||||||
|
|
||||||
|
let copied = add_licenses(&src_dir, &destdir, "foo").unwrap();
|
||||||
|
assert_eq!(copied, 2);
|
||||||
|
|
||||||
|
let lic_dir = destdir.join("usr/share/licenses/foo");
|
||||||
|
assert!(lic_dir.join("LICENSE").exists());
|
||||||
|
assert!(lic_dir.join("COPYING.md").exists());
|
||||||
|
assert!(!lic_dir.join("README").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_atomic_update_and_rollback_restores_state() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let rootfs = tmp.path().join("root");
|
||||||
|
let destdir = tmp.path().join("dest");
|
||||||
|
let tx_base = tmp.path().join("tx");
|
||||||
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||||||
|
std::fs::create_dir_all(&destdir).unwrap();
|
||||||
|
|
||||||
|
// Existing installed files
|
||||||
|
std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap();
|
||||||
|
std::fs::write(rootfs.join("usr/bin/foo"), "old").unwrap();
|
||||||
|
std::fs::write(rootfs.join("usr/bin/old_only"), "to_remove").unwrap();
|
||||||
|
|
||||||
|
// New staged files
|
||||||
|
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||||
|
std::fs::write(destdir.join("usr/bin/foo"), "new").unwrap();
|
||||||
|
std::fs::write(destdir.join("usr/bin/new_only"), "added").unwrap();
|
||||||
|
|
||||||
|
let remove_paths = vec!["usr/bin/old_only".to_string()];
|
||||||
|
let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths).unwrap();
|
||||||
|
|
||||||
|
// After install: updated + new present, obsolete removed
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
|
||||||
|
"new"
|
||||||
|
);
|
||||||
|
assert!(rootfs.join("usr/bin/new_only").exists());
|
||||||
|
assert!(!rootfs.join("usr/bin/old_only").exists());
|
||||||
|
|
||||||
|
// Roll back should restore old state
|
||||||
|
tx.rollback().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
|
||||||
|
"old"
|
||||||
|
);
|
||||||
|
assert!(!rootfs.join("usr/bin/new_only").exists());
|
||||||
|
assert!(rootfs.join("usr/bin/old_only").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_atomic_commit_removes_tx_dir() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let rootfs = tmp.path().join("root");
|
||||||
|
let destdir = tmp.path().join("dest");
|
||||||
|
let tx_base = tmp.path().join("tx");
|
||||||
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||||||
|
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||||
|
std::fs::write(destdir.join("usr/bin/foo"), "x").unwrap();
|
||||||
|
|
||||||
|
let tx = install_atomic(&destdir, &rootfs, &tx_base, &[]).unwrap();
|
||||||
|
let tx_dir = tx.tx_dir.clone();
|
||||||
|
assert!(tx_dir.exists());
|
||||||
|
tx.commit().unwrap();
|
||||||
|
assert!(!tx_dir.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_install_atomic_symlink_to_directory() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let rootfs = tmp.path().join("root");
|
||||||
|
let destdir = tmp.path().join("dest");
|
||||||
|
let tx_base = tmp.path().join("tx");
|
||||||
|
std::fs::create_dir_all(&rootfs).unwrap();
|
||||||
|
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||||
|
// Create a symlink bin -> usr/bin in destdir
|
||||||
|
std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap();
|
||||||
|
|
||||||
|
let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[]).unwrap();
|
||||||
|
|
||||||
|
// Verify rootfs/bin is a symlink, not a directory
|
||||||
|
let meta = rootfs
|
||||||
|
.join("bin")
|
||||||
|
.symlink_metadata()
|
||||||
|
.expect("bin should exist");
|
||||||
|
assert!(meta.file_type().is_symlink(), "bin should be a symlink");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_link(rootfs.join("bin")).unwrap(),
|
||||||
|
std::path::PathBuf::from("usr/bin")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_walkdir_symlink_behavior() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let dir = tmp.path();
|
||||||
|
std::fs::create_dir_all(dir.join("target")).unwrap();
|
||||||
|
std::os::unix::fs::symlink("target", dir.join("link")).unwrap();
|
||||||
|
|
||||||
|
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
if entry.path().ends_with("link") {
|
||||||
|
let ft = entry.file_type();
|
||||||
|
assert!(
|
||||||
|
!ft.is_dir(),
|
||||||
|
"walkdir should NOT report symlink to dir as a directory"
|
||||||
|
);
|
||||||
|
assert!(ft.is_symlink(), "walkdir SHOULD report it as a symlink");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest containing files and directories for a package
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub files: Vec<String>,
|
||||||
|
pub directories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate manifest with both files and directories
|
||||||
|
pub fn generate_manifest_with_dirs(destdir: &Path) -> Result<Manifest> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut directories = Vec::new();
|
||||||
|
|
||||||
|
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||||
|
let rel_path = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(destdir)?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Skip the root (empty path)
|
||||||
|
if rel_path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_type = entry.file_type();
|
||||||
|
|
||||||
|
// Check for symlink first
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
// Track symlinks as files (they get removed the same way)
|
||||||
|
files.push(rel_path);
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
files.push(rel_path);
|
||||||
|
} else if file_type.is_dir() {
|
||||||
|
directories.push(rel_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Manifest { files, directories })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user