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:
2026-02-16 11:35:23 -06:00
parent 94044a48c3
commit 00ca2ebac6
29 changed files with 9385 additions and 69 deletions
Executable
+1
View File
@@ -0,0 +1 @@
target
+167
View File
@@ -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.
Generated Executable
+2938
View File
File diff suppressed because it is too large Load Diff
Executable
+43
View File
@@ -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
+53 -69
View File
@@ -1,93 +1,77 @@
# 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
* [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
```bash
cargo build --release
```
## 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/)
* [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/)
To install from a pre-built package archive:
## 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/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
- `install <SPEC_OR_ARCHIVE>`: Build and install a package.
- `remove <PACKAGE>`: Remove an installed package.
- `build <SPEC>`: Build a package and create an archive without installing.
- `info <PACKAGE_OR_SPEC>`: Show information about a package.
- `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
Choose a self-explaining name for your project.
[dependencies]
build = ["gcc", "make"]
runtime = ["libc"]
```
## Description
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.
## Configuration
## Badges
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.
Depot can be configured via `/etc/depot.d/` (or relative to the rootfs).
## Visuals
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.
## 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.
- `/etc/depot.d/build.toml`: System-wide build overrides and flag appends.
- `/etc/depot.d/package.toml`: Package-specific overrides.
+209
View File
@@ -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)
}
+42
View File
@@ -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(())
}
+179
View File
@@ -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)
}
+103
View File
@@ -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(())
}
+146
View File
@@ -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)
}
+101
View File
@@ -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()))
);
}
}
+179
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+7
View File
@@ -0,0 +1,7 @@
//! Package specification parsing
mod packager;
mod spec;
pub use packager::Packager;
pub use spec::*;
+304
View File
@@ -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());
}
}
+712
View File
@@ -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>,
}
+495
View File
@@ -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());
}
}
+156
View File
@@ -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"));
}
}
+122
View File
@@ -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
}
+296
View File
@@ -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);
}
}
+249
View File
@@ -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");
}
}
+485
View File
@@ -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 })
}