feat: initial implementation of Depot package manager
- Implement source-based package management with support for archives and Git - Add multi-system build support (Autotools, CMake, Meson, Rust/Cargo, Custom) - Implement atomic installation logic using transactions and fakeroot - Add dependency resolution for build-time and runtime requirements - Implement package indexing and local repository management - Add comprehensive configuration system with system/package overrides - Include Project guidelines (AGENTS.md) and README.md
This commit is contained in:
Executable
+209
@@ -0,0 +1,209 @@
|
||||
//! GNU Autotools build system (configure && make && make install)
|
||||
|
||||
use crate::cross::CrossConfig;
|
||||
use crate::fakeroot;
|
||||
use crate::package::PackageSpec;
|
||||
use crate::source::hooks;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn build(
|
||||
spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
let flags = &spec.build.flags;
|
||||
|
||||
// Determine actual source directory (support source_subdir)
|
||||
let actual_src = if !flags.source_subdir.is_empty() {
|
||||
src_dir.join(&flags.source_subdir)
|
||||
} else {
|
||||
src_dir.to_path_buf()
|
||||
};
|
||||
|
||||
if !actual_src.exists() {
|
||||
anyhow::bail!(
|
||||
"Source directory not found: {} (source_subdir: {})",
|
||||
actual_src.display(),
|
||||
flags.source_subdir
|
||||
);
|
||||
}
|
||||
|
||||
// Create destdir
|
||||
fs::create_dir_all(destdir)?;
|
||||
|
||||
// Build environment variables
|
||||
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||
|
||||
// Use cross-compilation tools if configured
|
||||
let (cc, ar) = if let Some(cc_cfg) = cross {
|
||||
(cc_cfg.cc.clone(), cc_cfg.ar.clone())
|
||||
} else {
|
||||
(flags.cc.clone(), flags.ar.clone())
|
||||
};
|
||||
|
||||
if !flags.cflags.is_empty() {
|
||||
// Expand shell command substitutions like $($CC -print-resource-dir)
|
||||
let cflags_str = flags.cflags.join(" ");
|
||||
let expanded = expand_shell_commands(&cflags_str, &cc)?;
|
||||
env_vars.push(("CFLAGS", expanded));
|
||||
}
|
||||
if !flags.ldflags.is_empty() {
|
||||
env_vars.push(("LDFLAGS", flags.ldflags.join(" ")));
|
||||
}
|
||||
env_vars.push(("CC", cc.clone()));
|
||||
env_vars.push(("AR", ar));
|
||||
|
||||
// Add cross-compilation environment
|
||||
if let Some(cc_cfg) = cross {
|
||||
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||
env_vars.push(("RANLIB", cc_cfg.ranlib.clone()));
|
||||
env_vars.push(("STRIP", cc_cfg.strip.clone()));
|
||||
env_vars.push(("LD", cc_cfg.ld.clone()));
|
||||
env_vars.push(("NM", cc_cfg.nm.clone()));
|
||||
}
|
||||
|
||||
// Add dynamic loader flag if specified
|
||||
if !flags.libc.is_empty() {
|
||||
let ldflags = if flags.ldflags.is_empty() {
|
||||
format!("-Wl,--dynamic-linker={}", flags.libc)
|
||||
} else {
|
||||
format!(
|
||||
"{} -Wl,--dynamic-linker={}",
|
||||
flags.ldflags.join(" "),
|
||||
flags.libc
|
||||
)
|
||||
};
|
||||
env_vars.push(("LDFLAGS", ldflags));
|
||||
}
|
||||
|
||||
// Run configure
|
||||
println!("Running configure...");
|
||||
let mut configure_cmd = Command::new("./configure");
|
||||
configure_cmd.current_dir(&actual_src);
|
||||
|
||||
crate::builder::prepare_command(&mut configure_cmd, &env_vars);
|
||||
|
||||
configure_cmd.arg(format!("--prefix={}", flags.prefix));
|
||||
|
||||
if !flags.chost.is_empty() {
|
||||
configure_cmd.arg(format!("--host={}", flags.chost));
|
||||
}
|
||||
if !flags.cbuild.is_empty() {
|
||||
configure_cmd.arg(format!("--build={}", flags.cbuild));
|
||||
}
|
||||
|
||||
// Add cross-compilation flags
|
||||
if let Some(cc_cfg) = cross {
|
||||
configure_cmd.arg(format!("--host={}", cc_cfg.host_triple()));
|
||||
if let Ok(build) = CrossConfig::build_triple() {
|
||||
configure_cmd.arg(format!("--build={}", build));
|
||||
}
|
||||
}
|
||||
|
||||
for arg in &flags.configure {
|
||||
configure_cmd.arg(arg);
|
||||
}
|
||||
|
||||
let status = configure_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run configure in {}", actual_src.display()))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("configure failed with status: {}", status);
|
||||
}
|
||||
|
||||
// Run make
|
||||
println!("Running make...");
|
||||
let mut make_cmd = Command::new("make");
|
||||
make_cmd.current_dir(&actual_src);
|
||||
make_cmd.arg("-j").arg(num_cpus().to_string());
|
||||
|
||||
crate::builder::prepare_command(&mut make_cmd, &env_vars);
|
||||
|
||||
let status = make_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run make in {}", actual_src.display()))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("make failed with status: {}", status);
|
||||
}
|
||||
|
||||
// Run post-compile hooks (after make, before make install)
|
||||
hooks::run_post_compile_commands(spec, &actual_src, destdir)?;
|
||||
|
||||
// Run make install with fakeroot if not root
|
||||
println!(
|
||||
"Running make install{}...",
|
||||
if fakeroot::is_root() {
|
||||
""
|
||||
} else {
|
||||
" (with internal fakeroot for build)"
|
||||
}
|
||||
);
|
||||
|
||||
let mut install_cmd = fakeroot::wrap_install_command("make", destdir);
|
||||
install_cmd.current_dir(&actual_src);
|
||||
install_cmd.arg("install");
|
||||
|
||||
let mut install_env = env_vars.clone();
|
||||
install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||
crate::builder::prepare_command(&mut install_cmd, &install_env);
|
||||
|
||||
let status = install_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run make install for {}", spec.package.name))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("make install failed with status: {}", status);
|
||||
}
|
||||
|
||||
// Run post-install hooks (after make install)
|
||||
hooks::run_post_install_commands(spec, &actual_src, destdir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Expand shell command substitutions like $($CC -print-resource-dir) in a string
|
||||
fn expand_shell_commands(input: &str, cc: &str) -> Result<String> {
|
||||
let mut result = input.to_string();
|
||||
|
||||
// Find and expand $(...) patterns
|
||||
while let Some(start) = result.find("$(") {
|
||||
let rest = &result[start + 2..];
|
||||
if let Some(end) = rest.find(')') {
|
||||
let cmd = &rest[..end];
|
||||
// Replace $CC with actual compiler
|
||||
let cmd = cmd.replace("$CC", cc);
|
||||
|
||||
// Execute the command via shell
|
||||
let output = Command::new("sh").arg("-c").arg(&cmd).output();
|
||||
|
||||
let replacement = match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
_ => {
|
||||
// Silently skip failed commands (e.g., gcc doesn't support -print-resource-dir)
|
||||
eprintln!("Warning: shell command '{}' failed, skipping", cmd);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
|
||||
} else {
|
||||
break; // Malformed, no closing paren
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Binary package "build" system — used when package supplies a prebuilt binary installer
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::os::unix::fs as unix_fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
use crate::package::PackageSpec;
|
||||
use crate::cross::CrossConfig;
|
||||
|
||||
/// For binary packages we simply copy the extracted files into DESTDIR (preserving
|
||||
/// directory structure). This is useful for .deb packages where extract step
|
||||
/// already unpacked the data payload into the source directory.
|
||||
pub fn build(
|
||||
_spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
_cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
println!("Binary install: copying files from {} to {} (pkg type={})", src_dir.display(), destdir.display(), _spec.build.flags.binary_type);
|
||||
fs::create_dir_all(destdir).with_context(|| format!("Failed to create destdir: {}", destdir.display()))?;
|
||||
|
||||
for entry in WalkDir::new(src_dir) {
|
||||
let entry = entry?;
|
||||
let rel = entry.path().strip_prefix(src_dir).unwrap();
|
||||
let target = destdir.join(rel);
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target)?;
|
||||
} else if entry.file_type().is_symlink() {
|
||||
let link_target = fs::read_link(entry.path())?;
|
||||
if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; }
|
||||
// overwrite existing links/files
|
||||
if target.exists() { let _ = fs::remove_file(&target); }
|
||||
unix_fs::symlink(link_target, &target)?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; }
|
||||
fs::copy(entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
//! CMake build system
|
||||
|
||||
use crate::cross::CrossConfig;
|
||||
use crate::fakeroot;
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn build(
|
||||
spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
let flags = &spec.build.flags;
|
||||
|
||||
// Determine actual source directory (support source_subdir)
|
||||
let actual_src = if !flags.source_subdir.is_empty() {
|
||||
src_dir.join(&flags.source_subdir)
|
||||
} else {
|
||||
src_dir.to_path_buf()
|
||||
};
|
||||
|
||||
if !actual_src.exists() {
|
||||
anyhow::bail!(
|
||||
"Source directory not found: {} (source_subdir: {})",
|
||||
actual_src.display(),
|
||||
flags.source_subdir
|
||||
);
|
||||
}
|
||||
|
||||
let build_dir = actual_src.join("build");
|
||||
|
||||
// Create directories
|
||||
fs::create_dir_all(&build_dir)?;
|
||||
fs::create_dir_all(destdir)?;
|
||||
|
||||
// Environment variables
|
||||
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||
|
||||
// Use cross-compilation tools if configured
|
||||
let cc = if let Some(cc_cfg) = cross {
|
||||
cc_cfg.cc.clone()
|
||||
} else {
|
||||
flags.cc.clone()
|
||||
};
|
||||
|
||||
if !flags.cflags.is_empty() {
|
||||
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
|
||||
}
|
||||
if !flags.chost.is_empty() {
|
||||
env_vars.push(("CHOST", flags.chost.clone()));
|
||||
}
|
||||
if !flags.cbuild.is_empty() {
|
||||
env_vars.push(("CBUILD", flags.cbuild.clone()));
|
||||
}
|
||||
if !flags.ldflags.is_empty() {
|
||||
let ldflags = if flags.libc.is_empty() {
|
||||
flags.ldflags.join(" ")
|
||||
} else {
|
||||
format!(
|
||||
"{} -Wl,--dynamic-linker={}",
|
||||
flags.ldflags.join(" "),
|
||||
flags.libc
|
||||
)
|
||||
};
|
||||
env_vars.push(("LDFLAGS", ldflags));
|
||||
}
|
||||
env_vars.push(("CC", cc));
|
||||
|
||||
// Add cross-compilation env
|
||||
if let Some(cc_cfg) = cross {
|
||||
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||
env_vars.push(("AR", cc_cfg.ar.clone()));
|
||||
}
|
||||
|
||||
// Extract prefix from configure flags (cmake-style -DCMAKE_INSTALL_PREFIX=)
|
||||
let prefix = flags
|
||||
.configure
|
||||
.iter()
|
||||
.find(|s| s.contains("CMAKE_INSTALL_PREFIX="))
|
||||
.and_then(|s| s.split('=').nth(1))
|
||||
.unwrap_or(&flags.prefix);
|
||||
|
||||
// Generate toolchain file if cross-compiling
|
||||
let toolchain_file = if let Some(cc_cfg) = cross {
|
||||
Some(cc_cfg.generate_cmake_toolchain(&build_dir)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Run cmake configure
|
||||
println!("Running cmake configure...");
|
||||
let mut cmake_cmd = Command::new("cmake");
|
||||
cmake_cmd.current_dir(&build_dir);
|
||||
cmake_cmd.arg("-S").arg(&actual_src);
|
||||
cmake_cmd.arg("-B").arg(&build_dir);
|
||||
cmake_cmd.arg(format!("-DCMAKE_INSTALL_PREFIX={}", prefix));
|
||||
cmake_cmd.arg("-DCMAKE_BUILD_TYPE=Release");
|
||||
|
||||
// Add toolchain file for cross-compilation
|
||||
if let Some(ref tf) = toolchain_file {
|
||||
cmake_cmd.arg(format!("-DCMAKE_TOOLCHAIN_FILE={}", tf.display()));
|
||||
}
|
||||
|
||||
// Add custom configure flags from spec (supports cross-compilation overrides)
|
||||
for flag in &flags.configure {
|
||||
// Expand environment variables in the flag
|
||||
let expanded = expand_env_vars(flag);
|
||||
cmake_cmd.arg(&expanded);
|
||||
}
|
||||
|
||||
crate::builder::prepare_command(&mut cmake_cmd, &env_vars);
|
||||
|
||||
let status = cmake_cmd.status().context("Failed to run cmake")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("cmake configure failed");
|
||||
}
|
||||
|
||||
// Run cmake build
|
||||
println!("Running cmake build...");
|
||||
let mut build_cmd = Command::new("cmake");
|
||||
build_cmd.arg("--build").arg(&build_dir);
|
||||
build_cmd.arg("-j").arg(num_cpus().to_string());
|
||||
|
||||
crate::builder::prepare_command(&mut build_cmd, &env_vars);
|
||||
|
||||
let status = build_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run cmake build for {}", spec.package.name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("cmake build failed");
|
||||
}
|
||||
|
||||
// Run cmake install with fakeroot if not root
|
||||
println!(
|
||||
"Running cmake install{}...",
|
||||
if fakeroot::is_root() {
|
||||
""
|
||||
} else {
|
||||
" (with fakeroot)"
|
||||
}
|
||||
);
|
||||
|
||||
let mut install_cmd = fakeroot::wrap_install_command("cmake", destdir);
|
||||
install_cmd.arg("--install").arg(&build_dir);
|
||||
|
||||
let mut install_env = env_vars.clone();
|
||||
install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||
crate::builder::prepare_command(&mut install_cmd, &install_env);
|
||||
|
||||
let status = install_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run cmake install for {}", spec.package.name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("cmake install failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand environment variables in a string (e.g., $DEPOT_SYSROOT)
|
||||
fn expand_env_vars(input: &str) -> String {
|
||||
let mut result = input.to_string();
|
||||
// Simple expansion for $VAR and ${VAR} patterns
|
||||
for (key, value) in std::env::vars() {
|
||||
result = result.replace(&format!("${}", key), &value);
|
||||
result = result.replace(&format!("${{{}}}", key), &value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
//! Custom build scripts
|
||||
|
||||
use crate::cross::CrossConfig;
|
||||
use crate::fakeroot;
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn build(
|
||||
spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
let flags = &spec.build.flags;
|
||||
|
||||
// Create destdir
|
||||
fs::create_dir_all(destdir)?;
|
||||
|
||||
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||
|
||||
// For custom builds, look for a build.sh script in the source directory
|
||||
let build_script = src_dir.join("build.sh");
|
||||
|
||||
if !build_script.exists() {
|
||||
anyhow::bail!(
|
||||
"Custom build type requires build.sh in source directory: {}",
|
||||
src_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Running custom build script{}...",
|
||||
if fakeroot::is_root() {
|
||||
""
|
||||
} else {
|
||||
" (with fakeroot)"
|
||||
}
|
||||
);
|
||||
|
||||
let mut cmd = fakeroot::wrap_install_command("bash", destdir);
|
||||
cmd.current_dir(src_dir);
|
||||
cmd.arg(&build_script);
|
||||
|
||||
if !flags.cflags.is_empty() {
|
||||
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
|
||||
}
|
||||
if !flags.ldflags.is_empty() {
|
||||
let ldflags = if flags.libc.is_empty() {
|
||||
flags.ldflags.join(" ")
|
||||
} else {
|
||||
format!(
|
||||
"{} -Wl,--dynamic-linker={}",
|
||||
flags.ldflags.join(" "),
|
||||
flags.libc
|
||||
)
|
||||
};
|
||||
env_vars.push(("LDFLAGS", ldflags));
|
||||
}
|
||||
|
||||
env_vars.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||
env_vars.push(("PREFIX", spec.build.flags.prefix.clone()));
|
||||
env_vars.push((
|
||||
"NYAPM_SPECDIR",
|
||||
spec.spec_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
|
||||
if !flags.chost.is_empty() {
|
||||
env_vars.push(("CHOST", flags.chost.clone()));
|
||||
}
|
||||
if !flags.cbuild.is_empty() {
|
||||
env_vars.push(("CBUILD", flags.cbuild.clone()));
|
||||
}
|
||||
|
||||
// Use cross-compilation tools if configured
|
||||
if let Some(cc_cfg) = cross {
|
||||
env_vars.push(("CC", cc_cfg.cc.clone()));
|
||||
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||
env_vars.push(("AR", cc_cfg.ar.clone()));
|
||||
env_vars.push(("RANLIB", cc_cfg.ranlib.clone()));
|
||||
env_vars.push(("STRIP", cc_cfg.strip.clone()));
|
||||
env_vars.push(("LD", cc_cfg.ld.clone()));
|
||||
env_vars.push(("NM", cc_cfg.nm.clone()));
|
||||
env_vars.push(("CROSS_PREFIX", cc_cfg.prefix.clone()));
|
||||
env_vars.push(("CROSS_COMPILE", format!("{}-", cc_cfg.prefix)));
|
||||
} else {
|
||||
env_vars.push(("CC", flags.cc.clone()));
|
||||
env_vars.push(("AR", flags.ar.clone()));
|
||||
}
|
||||
|
||||
crate::builder::prepare_command(&mut cmd, &env_vars);
|
||||
|
||||
let status = cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run build script: {}", build_script.display()))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Custom build script failed with status: {}", status);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
//! Meson build system
|
||||
|
||||
use crate::cross::CrossConfig;
|
||||
use crate::fakeroot;
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn build(
|
||||
spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
let flags = &spec.build.flags;
|
||||
let build_dir = src_dir.join("builddir");
|
||||
|
||||
// Create directories
|
||||
fs::create_dir_all(&build_dir)?;
|
||||
fs::create_dir_all(destdir)?;
|
||||
|
||||
// Environment variables
|
||||
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||
|
||||
// Use cross-compilation tools if configured
|
||||
let cc = if let Some(cc_cfg) = cross {
|
||||
cc_cfg.cc.clone()
|
||||
} else {
|
||||
flags.cc.clone()
|
||||
};
|
||||
|
||||
if !flags.cflags.is_empty() {
|
||||
env_vars.push(("CFLAGS", flags.cflags.join(" ")));
|
||||
}
|
||||
if !flags.chost.is_empty() {
|
||||
env_vars.push(("CHOST", flags.chost.clone()));
|
||||
}
|
||||
if !flags.cbuild.is_empty() {
|
||||
env_vars.push(("CBUILD", flags.cbuild.clone()));
|
||||
}
|
||||
if !flags.ldflags.is_empty() {
|
||||
let ldflags = if flags.libc.is_empty() {
|
||||
flags.ldflags.join(" ")
|
||||
} else {
|
||||
format!(
|
||||
"{} -Wl,--dynamic-linker={}",
|
||||
flags.ldflags.join(" "),
|
||||
flags.libc
|
||||
)
|
||||
};
|
||||
env_vars.push(("LDFLAGS", ldflags));
|
||||
}
|
||||
env_vars.push(("CC", cc));
|
||||
|
||||
// Add cross-compilation env
|
||||
if let Some(cc_cfg) = cross {
|
||||
env_vars.push(("CXX", cc_cfg.cxx.clone()));
|
||||
env_vars.push(("AR", cc_cfg.ar.clone()));
|
||||
}
|
||||
|
||||
// Extract prefix from configure flags
|
||||
let prefix = flags
|
||||
.configure
|
||||
.iter()
|
||||
.find(|s| s.starts_with("--prefix="))
|
||||
.map(|s| s.trim_start_matches("--prefix="))
|
||||
.unwrap_or(&flags.prefix);
|
||||
|
||||
// Generate cross file if cross-compiling
|
||||
let cross_file = if let Some(cc_cfg) = cross {
|
||||
Some(cc_cfg.generate_meson_cross_file(&build_dir)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Run meson setup
|
||||
println!("Running meson setup...");
|
||||
let mut meson_cmd = Command::new("meson");
|
||||
meson_cmd.current_dir(src_dir);
|
||||
meson_cmd.arg("setup");
|
||||
meson_cmd.arg(&build_dir);
|
||||
meson_cmd.arg(format!("--prefix={}", prefix));
|
||||
meson_cmd.arg("--buildtype=release");
|
||||
|
||||
// Add cross file for cross-compilation
|
||||
if let Some(ref cf) = cross_file {
|
||||
meson_cmd.arg(format!("--cross-file={}", cf.display()));
|
||||
}
|
||||
|
||||
crate::builder::prepare_command(&mut meson_cmd, &env_vars);
|
||||
|
||||
let status = meson_cmd.status().context("Failed to run meson setup")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("meson setup failed");
|
||||
}
|
||||
|
||||
// Run ninja build
|
||||
println!("Running ninja...");
|
||||
let mut ninja_cmd = Command::new("ninja");
|
||||
ninja_cmd.current_dir(&build_dir);
|
||||
ninja_cmd.arg("-j").arg(num_cpus().to_string());
|
||||
|
||||
crate::builder::prepare_command(&mut ninja_cmd, &env_vars);
|
||||
|
||||
let status = ninja_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run ninja for {}", spec.package.name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("ninja build failed");
|
||||
}
|
||||
|
||||
// Run meson install with fakeroot if not root
|
||||
println!(
|
||||
"Running meson install{}...",
|
||||
if fakeroot::is_root() {
|
||||
""
|
||||
} else {
|
||||
" (with fakeroot)"
|
||||
}
|
||||
);
|
||||
|
||||
let mut install_cmd = fakeroot::wrap_install_command("meson", destdir);
|
||||
install_cmd.arg("install");
|
||||
install_cmd.arg("-C").arg(&build_dir);
|
||||
|
||||
let mut install_env = env_vars.clone();
|
||||
install_env.push(("DESTDIR", destdir.to_string_lossy().into_owned()));
|
||||
crate::builder::prepare_command(&mut install_cmd, &install_env);
|
||||
|
||||
let status = install_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run meson install for {}", spec.package.name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("meson install failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
//! Build system abstraction
|
||||
|
||||
mod autotools;
|
||||
mod bin;
|
||||
mod cmake;
|
||||
mod custom;
|
||||
mod meson;
|
||||
mod rust;
|
||||
|
||||
use crate::cross::CrossConfig;
|
||||
use crate::package::{BuildType, PackageSpec};
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Prepare a Command with a hermetic environment and some essential variables preserved.
|
||||
pub fn prepare_command(cmd: &mut Command, env_vars: &[(&str, String)]) {
|
||||
cmd.env_clear();
|
||||
// Preserve essential environment variables
|
||||
for var in &["PATH", "LANG", "HOME", "SHELL", "DESTDIR"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
// Set requested environment variables
|
||||
for (key, val) in env_vars {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a package using the appropriate build system
|
||||
pub fn build(
|
||||
spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
if let Some(cc) = cross {
|
||||
println!(
|
||||
"Cross-compiling for {} with {:?}...",
|
||||
cc.prefix, spec.build.build_type
|
||||
);
|
||||
} else {
|
||||
println!("Building with {:?}...", spec.build.build_type);
|
||||
}
|
||||
|
||||
// Clean destdir to prevent stale files/directories (e.g., directories where symlinks should be)
|
||||
if destdir.exists() {
|
||||
std::fs::remove_dir_all(destdir)?;
|
||||
}
|
||||
|
||||
match spec.build.build_type {
|
||||
BuildType::Autotools => autotools::build(spec, src_dir, destdir, cross),
|
||||
BuildType::CMake => cmake::build(spec, src_dir, destdir, cross),
|
||||
BuildType::Meson => meson::build(spec, src_dir, destdir, cross),
|
||||
BuildType::Custom => custom::build(spec, src_dir, destdir, cross),
|
||||
BuildType::Rust => rust::build(spec, src_dir, destdir, cross),
|
||||
BuildType::Bin => bin::build(spec, src_dir, destdir, cross),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_prepare_command() {
|
||||
let mut cmd = Command::new("ls");
|
||||
// Set an env var that should be cleared
|
||||
cmd.env("FORBIDDEN", "value");
|
||||
// Set PATH manually in the current process to ensure it's picked up if it exists
|
||||
unsafe {
|
||||
std::env::set_var("PATH", "/usr/bin");
|
||||
std::env::set_var("HOME", "/home/test");
|
||||
}
|
||||
|
||||
prepare_command(&mut cmd, &[("MYVAR", "myval".to_string())]);
|
||||
|
||||
let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect();
|
||||
assert!(envs.get(std::ffi::OsStr::new("PATH")).is_some());
|
||||
assert!(envs.get(std::ffi::OsStr::new("HOME")).is_some());
|
||||
assert!(envs.get(std::ffi::OsStr::new("FORBIDDEN")).is_none());
|
||||
assert_eq!(
|
||||
envs.get(std::ffi::OsStr::new("MYVAR")),
|
||||
Some(&Some(std::ffi::OsString::from("myval").as_os_str()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepare_command_preserves_destdir() {
|
||||
let mut cmd = std::process::Command::new("ls");
|
||||
unsafe {
|
||||
std::env::set_var("DESTDIR", "/tmp/dest");
|
||||
}
|
||||
prepare_command(&mut cmd, &[]);
|
||||
let envs: std::collections::HashMap<_, _> = cmd.get_envs().collect();
|
||||
assert_eq!(
|
||||
envs.get(std::ffi::OsStr::new("DESTDIR")),
|
||||
Some(&Some(std::ffi::OsString::from("/tmp/dest").as_os_str()))
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
//! Rust/Cargo build system
|
||||
|
||||
use crate::cross::CrossConfig;
|
||||
use crate::package::PackageSpec;
|
||||
use crate::source::hooks;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub fn build(
|
||||
spec: &PackageSpec,
|
||||
src_dir: &Path,
|
||||
destdir: &Path,
|
||||
cross: Option<&CrossConfig>,
|
||||
) -> Result<()> {
|
||||
let flags = &spec.build.flags;
|
||||
|
||||
// Create destdir
|
||||
fs::create_dir_all(destdir)?;
|
||||
|
||||
// Isolate from parent workspace by adding empty [workspace] if not present
|
||||
// This prevents "believes it's in a workspace when it's not" errors
|
||||
let cargo_toml = src_dir.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
let contents = fs::read_to_string(&cargo_toml)
|
||||
.with_context(|| format!("Failed to read {}", cargo_toml.display()))?;
|
||||
if !contents.contains("[workspace]") {
|
||||
let isolated = format!("{}\n\n[workspace]\n", contents);
|
||||
fs::write(&cargo_toml, isolated).with_context(|| {
|
||||
format!("Failed to isolate Cargo.toml: {}", cargo_toml.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine profile
|
||||
let is_release = flags.profile == "release";
|
||||
let profile_dir = if is_release { "release" } else { "debug" };
|
||||
|
||||
// Determine target triple
|
||||
let target = if !flags.target.is_empty() {
|
||||
Some(flags.target.clone())
|
||||
} else {
|
||||
cross.map(|cc_cfg| cc_cfg.prefix.clone())
|
||||
};
|
||||
|
||||
// Build environment
|
||||
let mut env_vars: Vec<(&str, String)> = vec![];
|
||||
|
||||
// RUSTFLAGS
|
||||
if !flags.rustflags.is_empty() {
|
||||
env_vars.push(("RUSTFLAGS", flags.rustflags.join(" ")));
|
||||
}
|
||||
|
||||
// If cross-compiling, set linker via CARGO_TARGET_*_LINKER
|
||||
if let Some(cc_cfg) = cross {
|
||||
// Convert target triple to uppercase with underscores for env var
|
||||
let target_env = target
|
||||
.as_ref()
|
||||
.unwrap_or(&cc_cfg.prefix)
|
||||
.to_uppercase()
|
||||
.replace('-', "_");
|
||||
let linker_var = format!("CARGO_TARGET_{}_LINKER", target_env);
|
||||
env_vars.push((linker_var.leak(), cc_cfg.cc.clone()));
|
||||
env_vars.push(("CC".to_string().leak(), cc_cfg.cc.clone()));
|
||||
env_vars.push(("AR".to_string().leak(), cc_cfg.ar.clone()));
|
||||
}
|
||||
|
||||
// Set default rustup toolchain if not already set
|
||||
if std::env::var("RUSTUP_TOOLCHAIN").is_err() {
|
||||
env_vars.push(("RUSTUP_TOOLCHAIN", "stable".to_string()));
|
||||
}
|
||||
|
||||
// Run cargo build
|
||||
println!(
|
||||
"Running cargo build ({})...",
|
||||
if is_release { "release" } else { "debug" }
|
||||
);
|
||||
let mut cargo_cmd = Command::new("cargo");
|
||||
cargo_cmd.current_dir(src_dir);
|
||||
cargo_cmd.arg("build");
|
||||
|
||||
if is_release {
|
||||
cargo_cmd.arg("--release");
|
||||
}
|
||||
|
||||
// Add target if specified
|
||||
if let Some(ref t) = target {
|
||||
cargo_cmd.arg("--target").arg(t);
|
||||
}
|
||||
|
||||
// Add additional cargo args
|
||||
for arg in &flags.cargs {
|
||||
cargo_cmd.arg(arg);
|
||||
}
|
||||
|
||||
// Set environment
|
||||
crate::builder::prepare_command(&mut cargo_cmd, &env_vars);
|
||||
|
||||
let status = cargo_cmd
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run cargo build for {}", spec.package.name))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("cargo build failed");
|
||||
}
|
||||
|
||||
// Run post-compile hooks
|
||||
hooks::run_post_compile_commands(spec, src_dir, destdir)?;
|
||||
|
||||
// Install binaries to destdir
|
||||
println!("Installing binaries to DESTDIR...");
|
||||
|
||||
// Determine target directory
|
||||
let target_dir = if let Some(ref t) = target {
|
||||
src_dir.join("target").join(t).join(profile_dir)
|
||||
} else {
|
||||
src_dir.join("target").join(profile_dir)
|
||||
};
|
||||
|
||||
// Use bindir from flags (default: /usr/bin)
|
||||
let bin_dir = destdir.join(flags.bindir.trim_start_matches('/'));
|
||||
fs::create_dir_all(&bin_dir)?;
|
||||
|
||||
// Find and copy executable files
|
||||
if target_dir.exists() {
|
||||
for entry in fs::read_dir(&target_dir)
|
||||
.with_context(|| format!("Failed to read target directory: {}", target_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories and non-executable files
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's an executable (no extension on Linux, or special check)
|
||||
let file_name = path.file_name().unwrap().to_string_lossy();
|
||||
|
||||
// Skip common non-binary files
|
||||
if file_name.ends_with(".d")
|
||||
|| file_name.ends_with(".rlib")
|
||||
|| file_name.ends_with(".rmeta")
|
||||
|| file_name.contains(".so")
|
||||
|| file_name.starts_with("lib")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if file is executable
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if path
|
||||
.metadata()
|
||||
.ok()
|
||||
.filter(|m| m.permissions().mode() & 0o111 != 0 && m.is_file())
|
||||
.is_some()
|
||||
{
|
||||
let dest = bin_dir.join(&*file_name);
|
||||
println!(" Installing: {}", file_name);
|
||||
fs::copy(&path, &dest).with_context(|| {
|
||||
format!("Failed to copy {} to {}", path.display(), dest.display())
|
||||
})?;
|
||||
|
||||
// Preserve executable permission
|
||||
let mut perms = fs::metadata(&dest)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&dest, perms)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run post-install hooks
|
||||
hooks::run_post_install_commands(spec, src_dir, destdir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Executable
+371
@@ -0,0 +1,371 @@
|
||||
//! Global configuration for Depot
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Global configuration settings
|
||||
pub struct Config {
|
||||
/// Directory for cached source tarballs
|
||||
pub cache_dir: PathBuf,
|
||||
/// Directory for building packages
|
||||
pub build_dir: PathBuf,
|
||||
/// Directory for package database
|
||||
pub db_dir: PathBuf,
|
||||
/// System-level build overrides from /etc/depot.d/build.toml
|
||||
pub build_overrides: toml::Value,
|
||||
/// System-level package overrides from /etc/depot.d/package.toml
|
||||
pub package_overrides: toml::Value,
|
||||
/// Appends found in system TOML files (key -> values to append)
|
||||
pub appends: HashMap<String, Vec<toml::Value>>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Create config with paths relative to the given rootfs
|
||||
pub fn for_rootfs(rootfs: &Path) -> Self {
|
||||
let abs_rootfs = if rootfs.exists() {
|
||||
rootfs.canonicalize().unwrap_or_else(|_| {
|
||||
std::env::current_dir()
|
||||
.map(|cwd| cwd.join(rootfs))
|
||||
.unwrap_or_else(|_| rootfs.to_path_buf())
|
||||
})
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map(|cwd| cwd.join(rootfs))
|
||||
.unwrap_or_else(|_| rootfs.to_path_buf())
|
||||
};
|
||||
|
||||
let is_system_root = abs_rootfs == Path::new("/") || abs_rootfs.as_os_str() == "/";
|
||||
let is_root = crate::fakeroot::is_root();
|
||||
|
||||
let (cache_dir, build_dir, db_dir) = if is_system_root && !is_root {
|
||||
let home = std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"));
|
||||
(
|
||||
home.join(".cache/depot/sources"),
|
||||
home.join(".cache/depot/build"),
|
||||
home.join(".local/share/depot"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
abs_rootfs.join("var/cache/depot/sources"),
|
||||
abs_rootfs.join("var/cache/depot/build"),
|
||||
abs_rootfs.join("var/lib/depot"),
|
||||
)
|
||||
};
|
||||
|
||||
let mut config = Self {
|
||||
cache_dir,
|
||||
build_dir,
|
||||
db_dir,
|
||||
build_overrides: toml::Value::Table(toml::map::Map::new()),
|
||||
package_overrides: toml::Value::Table(toml::map::Map::new()),
|
||||
appends: HashMap::new(),
|
||||
};
|
||||
|
||||
if let Err(e) = config.load_system(&abs_rootfs) {
|
||||
eprintln!("Warning: Failed to load system config: {}", e);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Load system-level and user-level overrides
|
||||
pub fn load_system(&mut self, rootfs: &Path) -> Result<()> {
|
||||
let mut config_paths = vec![rootfs.join("etc/depot.toml")];
|
||||
|
||||
// Add user-level config paths if we are not root or if HOME is set
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home_path = PathBuf::from(home);
|
||||
config_paths.push(home_path.join(".config/depot.toml"));
|
||||
config_paths.push(home_path.join(".local/share/depot.toml"));
|
||||
}
|
||||
|
||||
for path in config_paths {
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config: {}", path.display()))?;
|
||||
let (val, appends) = self.preprocess_toml(&content)?;
|
||||
|
||||
// If it has a [build] section, merge it into build_overrides
|
||||
if let Some(build) = val.get("build") {
|
||||
merge_toml_values(&mut self.build_overrides, build);
|
||||
}
|
||||
// If it has a [package] section, merge it into package_overrides
|
||||
if let Some(pkg) = val.get("package") {
|
||||
merge_toml_values(&mut self.package_overrides, pkg);
|
||||
}
|
||||
|
||||
for (k, v) in appends {
|
||||
self.appends.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep existing etc/depot.d/ support for backward compatibility/modular config
|
||||
let build_path = rootfs.join("etc/depot.d/build.toml");
|
||||
if build_path.exists() {
|
||||
let content = fs::read_to_string(&build_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read system build config: {}",
|
||||
build_path.display()
|
||||
)
|
||||
})?;
|
||||
let (val, appends) = self.preprocess_toml(&content)?;
|
||||
merge_toml_values(&mut self.build_overrides, &val);
|
||||
for (k, v) in appends {
|
||||
self.appends.insert(format!("build.{}", k), v);
|
||||
}
|
||||
}
|
||||
|
||||
let package_path = rootfs.join("etc/depot.d/package.toml");
|
||||
if package_path.exists() {
|
||||
let content = fs::read_to_string(&package_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read system package config: {}",
|
||||
package_path.display()
|
||||
)
|
||||
})?;
|
||||
let (val, appends) = self.preprocess_toml(&content)?;
|
||||
merge_toml_values(&mut self.package_overrides, &val);
|
||||
for (k, v) in appends {
|
||||
self.appends.insert(format!("package.{}", k), v);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_toml_values(base: &mut toml::Value, over: &toml::Value) {
|
||||
if let (Some(base_table), Some(over_table)) = (base.as_table_mut(), over.as_table()) {
|
||||
for (k, v) in over_table {
|
||||
if v.is_table() && base_table.contains_key(k) && base_table[k].is_table() {
|
||||
merge_toml_values(&mut base_table[k], v);
|
||||
} else {
|
||||
base_table.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Preprocess TOML to support `key += value` syntax.
|
||||
/// Returns the base toml::Value and a map of append operations.
|
||||
fn preprocess_toml(
|
||||
&self,
|
||||
input: &str,
|
||||
) -> Result<(toml::Value, HashMap<String, Vec<toml::Value>>)> {
|
||||
let mut base_text = String::new();
|
||||
let mut appends = HashMap::new();
|
||||
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
base_text.push_str(line);
|
||||
base_text.push('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(plus_idx) = trimmed.find("+=") {
|
||||
let key = trimmed[..plus_idx].trim().to_string();
|
||||
let val_str = trimmed[plus_idx + 2..].trim();
|
||||
let val: toml::Value = toml::from_str::<toml::Value>(&format!("v = {}", val_str))
|
||||
.context("Failed to parse append value")?
|
||||
.get("v")
|
||||
.cloned()
|
||||
.unwrap();
|
||||
|
||||
appends.entry(key).or_insert_with(Vec::new).push(val);
|
||||
} else {
|
||||
base_text.push_str(line);
|
||||
base_text.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
let base_val: toml::Value = toml::from_str::<toml::Value>(&base_text)?;
|
||||
Ok((base_val, appends))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self::for_rootfs(Path::new("/"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_for_rootfs() {
|
||||
let root = PathBuf::from("/tmp/test_root");
|
||||
let config = Config::for_rootfs(&root);
|
||||
|
||||
// Canonicalization might happen, so let's just check ends_with or construct reliably
|
||||
assert!(
|
||||
config
|
||||
.cache_dir
|
||||
.to_string_lossy()
|
||||
.contains("var/cache/depot/sources")
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.build_dir
|
||||
.to_string_lossy()
|
||||
.contains("var/cache/depot/build")
|
||||
);
|
||||
assert!(config.db_dir.to_string_lossy().contains("var/lib/depot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preprocess_toml() {
|
||||
let config = Config::for_rootfs(Path::new("/tmp/nonexistent"));
|
||||
let input = r#"
|
||||
[flags]
|
||||
cc = "clang"
|
||||
cflags = ["-O2"]
|
||||
|
||||
# An append operation
|
||||
cflags += ["-DDEBUG"]
|
||||
ldflags += "-L/usr/local/lib"
|
||||
"#;
|
||||
let (base, appends) = config.preprocess_toml(input).unwrap();
|
||||
|
||||
// Base value should have the non-append parts
|
||||
assert_eq!(
|
||||
base.get("flags")
|
||||
.and_then(|f| f.get("cc"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("clang")
|
||||
);
|
||||
assert_eq!(
|
||||
base.get("flags")
|
||||
.and_then(|f| f.get("cflags"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len()),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
// Appends should be captured
|
||||
assert!(appends.contains_key("cflags"));
|
||||
assert_eq!(appends.get("cflags").unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
appends.get("cflags").unwrap()[0].as_array().unwrap()[0].as_str(),
|
||||
Some("-DDEBUG")
|
||||
);
|
||||
|
||||
assert!(appends.contains_key("ldflags"));
|
||||
assert_eq!(
|
||||
appends.get("ldflags").unwrap()[0].as_str(),
|
||||
Some("-L/usr/local/lib")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_system() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let etc = root.join("etc/depot.d");
|
||||
fs::create_dir_all(&etc).unwrap();
|
||||
|
||||
fs::write(
|
||||
etc.join("build.toml"),
|
||||
r#"
|
||||
[flags]
|
||||
cflags = ["-O2"]
|
||||
cflags += ["-g"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = Config::for_rootfs(root);
|
||||
assert_eq!(
|
||||
config
|
||||
.build_overrides
|
||||
.get("flags")
|
||||
.and_then(|f| f.get("cflags"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len()),
|
||||
Some(1)
|
||||
);
|
||||
assert!(config.appends.contains_key("build.cflags"));
|
||||
assert_eq!(
|
||||
config.appends.get("build.cflags").unwrap()[0]
|
||||
.as_array()
|
||||
.unwrap()[0]
|
||||
.as_str(),
|
||||
Some("-g")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_non_root_fallback() {
|
||||
// We can't easily mock is_root() here without more complex infrastructure,
|
||||
// but we can at least check if the logic for abs_rootfs == "/" triggers
|
||||
// the HOME join if we were non-root.
|
||||
let config = Config::for_rootfs(Path::new("/"));
|
||||
|
||||
if !crate::fakeroot::is_root() {
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
assert!(config.cache_dir.to_string_lossy().starts_with(&home));
|
||||
assert!(
|
||||
config
|
||||
.cache_dir
|
||||
.to_string_lossy()
|
||||
.contains(".cache/depot/sources")
|
||||
);
|
||||
} else {
|
||||
// If running as root (e.g. in some CI), it should use /var
|
||||
assert_eq!(config.cache_dir, PathBuf::from("/var/cache/depot/sources"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_depot_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let etc = root.join("etc");
|
||||
fs::create_dir_all(&etc).unwrap();
|
||||
|
||||
fs::write(
|
||||
etc.join("depot.toml"),
|
||||
r#"
|
||||
[build]
|
||||
prefix = "/opt/depot"
|
||||
cc = "clang"
|
||||
|
||||
[build.flags]
|
||||
cflags = ["-O3"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = Config::for_rootfs(root);
|
||||
// Config construction calls load_system automatically
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.build_overrides
|
||||
.get("prefix")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("/opt/depot")
|
||||
);
|
||||
assert_eq!(
|
||||
config.build_overrides.get("cc").and_then(|v| v.as_str()),
|
||||
Some("clang")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.build_overrides
|
||||
.get("flags")
|
||||
.and_then(|f| f.get("cflags"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len()),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
//! Cross-compilation support
|
||||
//!
|
||||
//! Provides configuration for cross-compiling packages with a target triplet prefix.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Cross-compilation configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrossConfig {
|
||||
/// Target triplet prefix (e.g., "x86_64-linux-musl")
|
||||
pub prefix: String,
|
||||
/// C compiler
|
||||
pub cc: String,
|
||||
/// C++ compiler
|
||||
pub cxx: String,
|
||||
/// Archiver
|
||||
pub ar: String,
|
||||
/// Ranlib
|
||||
pub ranlib: String,
|
||||
/// Strip
|
||||
pub strip: String,
|
||||
/// Linker (ld)
|
||||
pub ld: String,
|
||||
/// nm
|
||||
pub nm: String,
|
||||
/// objcopy
|
||||
pub objcopy: String,
|
||||
/// objdump
|
||||
pub objdump: String,
|
||||
/// readelf
|
||||
pub readelf: String,
|
||||
}
|
||||
|
||||
impl CrossConfig {
|
||||
/// Create a cross-compilation config by discovering tools in PATH
|
||||
pub fn from_prefix(prefix: &str) -> Result<Self> {
|
||||
let cc = find_tool(prefix, &["gcc", "clang"], true)?;
|
||||
let cxx = find_tool(prefix, &["g++", "clang++"], false)
|
||||
.unwrap_or_else(|_| format!("{}-g++", prefix));
|
||||
let ar = find_tool(prefix, &["ar", "llvm-ar"], true)?;
|
||||
let ranlib = find_tool(prefix, &["ranlib", "llvm-ranlib"], false)
|
||||
.unwrap_or_else(|_| format!("{}-ranlib", prefix));
|
||||
let strip = find_tool(prefix, &["strip", "llvm-strip"], false)
|
||||
.unwrap_or_else(|_| format!("{}-strip", prefix));
|
||||
let ld = find_tool(prefix, &["ld", "ld.lld"], false)
|
||||
.unwrap_or_else(|_| format!("{}-ld", prefix));
|
||||
let nm = find_tool(prefix, &["nm", "llvm-nm"], false)
|
||||
.unwrap_or_else(|_| format!("{}-nm", prefix));
|
||||
let objcopy = find_tool(prefix, &["objcopy", "llvm-objcopy"], false)
|
||||
.unwrap_or_else(|_| format!("{}-objcopy", prefix));
|
||||
let objdump = find_tool(prefix, &["objdump", "llvm-objdump"], false)
|
||||
.unwrap_or_else(|_| format!("{}-objdump", prefix));
|
||||
let readelf = find_tool(prefix, &["readelf", "llvm-readelf"], false)
|
||||
.unwrap_or_else(|_| format!("{}-readelf", prefix));
|
||||
|
||||
println!("Cross-compilation tools discovered:");
|
||||
println!(" CC: {}", cc);
|
||||
println!(" CXX: {}", cxx);
|
||||
println!(" AR: {}", ar);
|
||||
println!(" RANLIB: {}", ranlib);
|
||||
println!(" STRIP: {}", strip);
|
||||
|
||||
Ok(Self {
|
||||
prefix: prefix.to_string(),
|
||||
cc,
|
||||
cxx,
|
||||
ar,
|
||||
ranlib,
|
||||
strip,
|
||||
ld,
|
||||
nm,
|
||||
objcopy,
|
||||
objdump,
|
||||
readelf,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the host triple for autotools --host flag
|
||||
pub fn host_triple(&self) -> &str {
|
||||
&self.prefix
|
||||
}
|
||||
|
||||
/// Get the build machine triple (native compiler)
|
||||
pub fn build_triple() -> Result<String> {
|
||||
let output = Command::new("gcc")
|
||||
.arg("-dumpmachine")
|
||||
.output()
|
||||
.or_else(|_| Command::new("cc").arg("-dumpmachine").output())
|
||||
.context("Failed to determine build machine triple")?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Generate a CMake toolchain file for cross-compilation
|
||||
pub fn generate_cmake_toolchain(&self, build_dir: &Path) -> Result<PathBuf> {
|
||||
let toolchain_path = build_dir.join("cross-toolchain.cmake");
|
||||
|
||||
let content = format!(
|
||||
r#"# CMake toolchain file for cross-compilation
|
||||
# Generated by nyapm for target: {}
|
||||
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR {})
|
||||
|
||||
set(CMAKE_C_COMPILER {})
|
||||
set(CMAKE_CXX_COMPILER {})
|
||||
set(CMAKE_AR {} CACHE FILEPATH "Archiver")
|
||||
set(CMAKE_RANLIB {} CACHE FILEPATH "Ranlib")
|
||||
set(CMAKE_STRIP {} CACHE FILEPATH "Strip")
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
"#,
|
||||
self.prefix,
|
||||
self.target_arch(),
|
||||
self.cc,
|
||||
self.cxx,
|
||||
self.ar,
|
||||
self.ranlib,
|
||||
self.strip,
|
||||
);
|
||||
|
||||
fs::create_dir_all(build_dir)?;
|
||||
let mut file = fs::File::create(&toolchain_path)?;
|
||||
file.write_all(content.as_bytes())?;
|
||||
|
||||
Ok(toolchain_path)
|
||||
}
|
||||
|
||||
/// Generate a Meson cross file for cross-compilation
|
||||
pub fn generate_meson_cross_file(&self, build_dir: &Path) -> Result<PathBuf> {
|
||||
let cross_path = build_dir.join("cross-file.ini");
|
||||
|
||||
let content = format!(
|
||||
r#"# Meson cross file for cross-compilation
|
||||
# Generated by nyapm for target: {}
|
||||
|
||||
[binaries]
|
||||
c = '{}'
|
||||
cpp = '{}'
|
||||
ar = '{}'
|
||||
strip = '{}'
|
||||
ld = '{}'
|
||||
nm = '{}'
|
||||
objcopy = '{}'
|
||||
objdump = '{}'
|
||||
readelf = '{}'
|
||||
|
||||
[host_machine]
|
||||
system = 'linux'
|
||||
cpu_family = '{}'
|
||||
cpu = '{}'
|
||||
endian = 'little'
|
||||
"#,
|
||||
self.prefix,
|
||||
self.cc,
|
||||
self.cxx,
|
||||
self.ar,
|
||||
self.strip,
|
||||
self.ld,
|
||||
self.nm,
|
||||
self.objcopy,
|
||||
self.objdump,
|
||||
self.readelf,
|
||||
self.cpu_family(),
|
||||
self.target_arch(),
|
||||
);
|
||||
|
||||
fs::create_dir_all(build_dir)?;
|
||||
let mut file = fs::File::create(&cross_path)?;
|
||||
file.write_all(content.as_bytes())?;
|
||||
|
||||
Ok(cross_path)
|
||||
}
|
||||
|
||||
/// Extract target architecture from prefix
|
||||
fn target_arch(&self) -> &str {
|
||||
self.prefix.split('-').next().unwrap_or("unknown")
|
||||
}
|
||||
|
||||
/// Get CPU family for Meson
|
||||
fn cpu_family(&self) -> &str {
|
||||
let arch = self.target_arch();
|
||||
match arch {
|
||||
"x86_64" | "amd64" => "x86_64",
|
||||
"i686" | "i586" | "i486" | "i386" => "x86",
|
||||
"aarch64" | "arm64" => "aarch64",
|
||||
"arm" | "armv7" | "armv7l" => "arm",
|
||||
"riscv64" => "riscv64",
|
||||
"riscv32" => "riscv32",
|
||||
"powerpc64" | "ppc64" => "ppc64",
|
||||
"powerpc" | "ppc" => "ppc",
|
||||
"mips64" => "mips64",
|
||||
"mips" => "mips",
|
||||
_ => arch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a cross-compilation tool in PATH
|
||||
fn find_tool(prefix: &str, suffixes: &[&str], required: bool) -> Result<String> {
|
||||
for suffix in suffixes {
|
||||
let tool_name = format!("{}-{}", prefix, suffix);
|
||||
if tool_exists(&tool_name) {
|
||||
return Ok(tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
if required {
|
||||
anyhow::bail!(
|
||||
"Could not find cross tool: {}-{{{}}}",
|
||||
prefix,
|
||||
suffixes.join("|")
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("Tool not found"))
|
||||
}
|
||||
|
||||
/// Check if a tool exists in PATH
|
||||
fn tool_exists(name: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Executable
+581
@@ -0,0 +1,581 @@
|
||||
//! SQLite-based package database
|
||||
|
||||
pub mod repo;
|
||||
|
||||
use crate::package::PackageSpec;
|
||||
use crate::staging;
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, params};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Initialize database and register a package
|
||||
pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> Result<()> {
|
||||
// Create parent directory (auto-create db dir if missing)
|
||||
if let Some(parent) = db_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut conn = Connection::open(db_path)?;
|
||||
init_db(&conn)?;
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
// Generate manifest with files and directories
|
||||
let manifest = staging::generate_manifest_with_dirs(destdir)?;
|
||||
|
||||
// Insert/update package without changing its primary key (UPSERT keeps the existing row).
|
||||
tx.execute(
|
||||
"INSERT INTO packages (name, version, revision, description, homepage, license)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
version=excluded.version,
|
||||
revision=excluded.revision,
|
||||
description=excluded.description,
|
||||
homepage=excluded.homepage,
|
||||
license=excluded.license",
|
||||
params![
|
||||
spec.package.name,
|
||||
spec.package.version,
|
||||
spec.package.revision,
|
||||
spec.package.description,
|
||||
spec.package.homepage,
|
||||
spec.package.license,
|
||||
],
|
||||
)?;
|
||||
|
||||
let pkg_id: i64 = tx.query_row(
|
||||
"SELECT id FROM packages WHERE name = ?1",
|
||||
params![spec.package.name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Replace provides + file + directory lists for this package.
|
||||
tx.execute(
|
||||
"DELETE FROM provides WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
tx.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM directories WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
|
||||
// Insert provides
|
||||
for provides in &spec.alternatives.provides {
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO provides (package_id, provides_name) VALUES (?1, ?2)",
|
||||
params![pkg_id, provides],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Insert files
|
||||
for file in &manifest.files {
|
||||
// Enforce single-owner semantics for file paths.
|
||||
tx.execute(
|
||||
"INSERT INTO files (package_id, path) VALUES (?1, ?2)",
|
||||
params![pkg_id, file],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Insert directories (can be shared by multiple packages)
|
||||
for dir in &manifest.directories {
|
||||
tx.execute(
|
||||
"INSERT INTO directories (package_id, path) VALUES (?1, ?2)",
|
||||
params![pkg_id, dir],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
println!(
|
||||
"Registered {} files and {} directories in database",
|
||||
manifest.files.len(),
|
||||
manifest.directories.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the list of files owned by an installed package.
|
||||
pub fn get_package_files(db_path: &Path, name: &str) -> Result<Vec<String>> {
|
||||
if !db_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
let pkg_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM packages WHERE name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context(format!("Package '{}' not found", name))?;
|
||||
|
||||
let mut stmt = conn.prepare("SELECT path FROM files WHERE package_id = ?1")?;
|
||||
let files: Vec<String> = stmt
|
||||
.query_map(params![pkg_id], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Remove a package from the database and filesystem
|
||||
pub fn remove_package(db_path: &Path, name: &str, rootfs: &Path) -> Result<()> {
|
||||
if !db_path.exists() {
|
||||
anyhow::bail!("Package database not found");
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
|
||||
// Get package ID
|
||||
let pkg_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM packages WHERE name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context(format!("Package '{}' not found", name))?;
|
||||
|
||||
// Get file list
|
||||
let mut stmt = conn.prepare("SELECT path FROM files WHERE package_id = ?1")?;
|
||||
let files: Vec<String> = stmt
|
||||
.query_map(params![pkg_id], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Get directory list (sorted deepest-first for proper removal order)
|
||||
let mut stmt = conn.prepare("SELECT path FROM directories WHERE package_id = ?1")?;
|
||||
let mut directories: Vec<String> = stmt
|
||||
.query_map(params![pkg_id], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
directories.sort_by_key(|b| std::cmp::Reverse(b.matches('/').count()));
|
||||
|
||||
// Remove files
|
||||
let mut removal_errors: Vec<String> = Vec::new();
|
||||
for file in &files {
|
||||
let path = rootfs.join(file);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
println!(" Removed file: {}", file);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Already gone, keep going.
|
||||
}
|
||||
Err(e) => {
|
||||
removal_errors.push(format!("{}: {}", file, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove directories (only if empty and not owned by another package)
|
||||
let mut dirs_removed = 0;
|
||||
for dir in &directories {
|
||||
let path = rootfs.join(dir);
|
||||
|
||||
// Skip if outside rootfs
|
||||
if !path.starts_with(rootfs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if directory is owned by another package
|
||||
let other_owners: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM directories WHERE path = ?1 AND package_id != ?2",
|
||||
params![dir, pkg_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
if other_owners > 0 {
|
||||
println!(" Keeping directory (owned by other package): {}", dir);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to remove (will fail if not empty, which is fine)
|
||||
match fs::remove_dir(&path) {
|
||||
Ok(()) => {
|
||||
println!(" Removed directory: {}", dir);
|
||||
dirs_removed += 1;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Already gone
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(39) || e.raw_os_error() == Some(66) => {
|
||||
// ENOTEMPTY (39 on Linux, 66 on macOS) - directory not empty
|
||||
println!(" Keeping directory (not empty): {}", dir);
|
||||
}
|
||||
Err(_) => {
|
||||
// Other errors (permission, etc.) - just skip silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
conn.execute("DELETE FROM files WHERE package_id = ?1", params![pkg_id])?;
|
||||
conn.execute(
|
||||
"DELETE FROM directories WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM provides WHERE package_id = ?1",
|
||||
params![pkg_id],
|
||||
)?;
|
||||
conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?;
|
||||
|
||||
println!(
|
||||
"Removed {} files and {} directories",
|
||||
files.len(),
|
||||
dirs_removed
|
||||
);
|
||||
|
||||
if !removal_errors.is_empty() {
|
||||
eprintln!("Warning: failed to remove some paths:");
|
||||
for err in removal_errors {
|
||||
eprintln!(" {}", err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show information about an installed package
|
||||
pub fn show_package_info(db_path: &Path, name: &str) -> Result<()> {
|
||||
if !db_path.exists() {
|
||||
anyhow::bail!("Package database not found");
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
|
||||
let (version, revision, description, homepage, license): (String, u32, String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT version, revision, description, homepage, license FROM packages WHERE name = ?1",
|
||||
params![name],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
)
|
||||
.context(format!("Package '{}' not found", name))?;
|
||||
|
||||
println!("Package: {} v{}-{}", name, version, revision);
|
||||
println!("Description: {}", description);
|
||||
println!("Homepage: {}", homepage);
|
||||
println!("License: {}", license);
|
||||
|
||||
// Count files
|
||||
let file_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM files f JOIN packages p ON f.package_id = p.id WHERE p.name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
println!("Files: {}", file_count);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all installed packages
|
||||
pub fn list_packages(db_path: &Path) -> Result<()> {
|
||||
if !db_path.exists() {
|
||||
println!("No packages installed.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
|
||||
let mut stmt = conn.prepare("SELECT name, version FROM packages ORDER BY name")?;
|
||||
let packages = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
|
||||
println!("{:<30} VERSION", "PACKAGE");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
for pkg in packages {
|
||||
let (name, version) = pkg?;
|
||||
println!("{:<30} {}", name, version);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_db(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT,
|
||||
homepage TEXT,
|
||||
license TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provides (
|
||||
id INTEGER PRIMARY KEY,
|
||||
package_id INTEGER NOT NULL,
|
||||
provides_name TEXT NOT NULL,
|
||||
FOREIGN KEY (package_id) REFERENCES packages(id),
|
||||
UNIQUE(package_id, provides_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
package_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
FOREIGN KEY (package_id) REFERENCES packages(id),
|
||||
UNIQUE(path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS directories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
package_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
FOREIGN KEY (package_id) REFERENCES packages(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_package ON files(package_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_provides_name ON provides(provides_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_directories_package ON directories(package_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate which files need to be removed during an upgrade.
|
||||
/// Returns paths that exist in the old version but NOT in the new version.
|
||||
pub fn calculate_upgrade_paths(
|
||||
db_path: &Path,
|
||||
name: &str,
|
||||
new_files: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
let old_files = get_package_files(db_path, name)?;
|
||||
let mut new_set = std::collections::HashSet::new();
|
||||
for f in new_files {
|
||||
new_set.insert(f);
|
||||
}
|
||||
|
||||
let remove_paths: Vec<String> = old_files
|
||||
.into_iter()
|
||||
.filter(|p| !new_set.contains(p))
|
||||
.collect();
|
||||
|
||||
Ok(remove_paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::package::{
|
||||
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn mk_spec(name: &str, version: &str) -> PackageSpec {
|
||||
PackageSpec {
|
||||
package: PackageInfo {
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
revision: 1,
|
||||
description: "d".into(),
|
||||
homepage: "h".into(),
|
||||
license: "MIT".into(),
|
||||
},
|
||||
alternatives: Alternatives {
|
||||
provides: vec![format!("{}-virtual", name)],
|
||||
replaces: Vec::new(),
|
||||
},
|
||||
manual_sources: Vec::new(),
|
||||
source: vec![Source {
|
||||
url: "https://example.com/foo.tar.gz".into(),
|
||||
sha256: "skip".into(),
|
||||
extract_dir: "foo".into(),
|
||||
patches: Vec::new(),
|
||||
post_extract: Vec::new(),
|
||||
}],
|
||||
build: Build {
|
||||
build_type: BuildType::Custom,
|
||||
flags: BuildFlags::default(),
|
||||
},
|
||||
dependencies: Dependencies::default(),
|
||||
spec_dir: PathBuf::from("."),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_package_updates_in_place_and_replaces_file_list() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("packages.db");
|
||||
|
||||
let spec_v1 = mk_spec("foo", "1.0");
|
||||
let dest1 = tmp.path().join("dest1");
|
||||
std::fs::create_dir_all(dest1.join("usr/bin")).unwrap();
|
||||
std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap();
|
||||
|
||||
register_package(&db_path, &spec_v1, &dest1).unwrap();
|
||||
|
||||
// Capture package id
|
||||
let conn = Connection::open(&db_path).unwrap();
|
||||
let id1: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM packages WHERE name = ?1",
|
||||
params!["foo"],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Update with different file set
|
||||
let spec_v2 = mk_spec("foo", "2.0");
|
||||
let dest2 = tmp.path().join("dest2");
|
||||
std::fs::create_dir_all(dest2.join("usr/bin")).unwrap();
|
||||
std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap();
|
||||
std::fs::write(dest2.join("usr/bin/new_only"), "x").unwrap();
|
||||
|
||||
register_package(&db_path, &spec_v2, &dest2).unwrap();
|
||||
|
||||
let id2: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM packages WHERE name = ?1",
|
||||
params!["foo"],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
|
||||
let files = get_package_files(&db_path, "foo").unwrap();
|
||||
assert!(files.contains(&"usr/bin/foo".to_string()));
|
||||
assert!(files.contains(&"usr/bin/new_only".to_string()));
|
||||
|
||||
let version = get_package_version(&db_path, "foo").unwrap();
|
||||
assert_eq!(version.as_deref(), Some("2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_package_tolerates_missing_files_and_cleans_db() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("packages.db");
|
||||
let rootfs = tmp.path().join("root");
|
||||
std::fs::create_dir_all(&rootfs).unwrap();
|
||||
|
||||
let spec = mk_spec("foo", "1.0");
|
||||
let dest = tmp.path().join("dest");
|
||||
std::fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||
std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap();
|
||||
register_package(&db_path, &spec, &dest).unwrap();
|
||||
|
||||
// Create the installed file in rootfs (one real)
|
||||
std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap();
|
||||
std::fs::write(rootfs.join("usr/bin/foo"), "bin").unwrap();
|
||||
|
||||
// Inject an extra missing file into DB to ensure we tolerate it.
|
||||
let conn = Connection::open(&db_path).unwrap();
|
||||
let pkg_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM packages WHERE name = ?1",
|
||||
params!["foo"],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO files (package_id, path) VALUES (?1, ?2)",
|
||||
params![pkg_id, "usr/bin/does_not_exist"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
remove_package(&db_path, "foo", &rootfs).unwrap();
|
||||
assert!(get_package_version(&db_path, "foo").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_upgrade_removes_orphaned_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("packages.db");
|
||||
let rootfs = tmp.path().join("root");
|
||||
let tx_base = tmp.path().join("tx");
|
||||
std::fs::create_dir_all(&rootfs).unwrap();
|
||||
|
||||
// 1. Install v1: usr/bin/foo, usr/bin/shared_dir/old_file
|
||||
let spec_v1 = mk_spec("foo", "1.0");
|
||||
let dest1 = tmp.path().join("dest1");
|
||||
std::fs::create_dir_all(dest1.join("usr/bin/shared_dir")).unwrap();
|
||||
std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap();
|
||||
std::fs::write(dest1.join("usr/bin/shared_dir/old_file"), "old").unwrap();
|
||||
|
||||
register_package(&db_path, &spec_v1, &dest1).unwrap();
|
||||
let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[]).unwrap();
|
||||
|
||||
assert!(rootfs.join("usr/bin/foo").exists());
|
||||
assert!(rootfs.join("usr/bin/shared_dir/old_file").exists());
|
||||
|
||||
// 2. Prepare v2: usr/bin/foo (updated), usr/bin/new_file
|
||||
// (shared_dir/old_file is removed from spec)
|
||||
let spec_v2 = mk_spec("foo", "2.0");
|
||||
let dest2 = tmp.path().join("dest2");
|
||||
std::fs::create_dir_all(dest2.join("usr/bin")).unwrap();
|
||||
std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap();
|
||||
std::fs::write(dest2.join("usr/bin/new_file"), "new").unwrap();
|
||||
|
||||
let manifest2 = crate::staging::generate_manifest_with_dirs(&dest2).unwrap();
|
||||
let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2.files).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
remove_paths,
|
||||
vec!["usr/bin/shared_dir/old_file".to_string()]
|
||||
);
|
||||
|
||||
let tx = crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths).unwrap();
|
||||
register_package(&db_path, &spec_v2, &dest2).unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
// 3. Verify filesystem
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
|
||||
"v2"
|
||||
);
|
||||
assert!(rootfs.join("usr/bin/new_file").exists());
|
||||
assert!(!rootfs.join("usr/bin/shared_dir/old_file").exists());
|
||||
|
||||
// Check DB
|
||||
let files = get_package_files(&db_path, "foo").unwrap();
|
||||
assert!(files.contains(&"usr/bin/foo".to_string()));
|
||||
assert!(files.contains(&"usr/bin/new_file".to_string()));
|
||||
assert!(!files.contains(&"usr/bin/shared_dir/old_file".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get set of all installed package names
|
||||
pub fn get_installed_packages(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
let mut stmt = conn.prepare("SELECT name FROM packages")?;
|
||||
let names: HashSet<String> = stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Get set of all provided package names (alternatives)
|
||||
pub fn get_all_provides(db_path: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
let mut stmt = conn.prepare("SELECT provides_name FROM provides")?;
|
||||
let names: HashSet<String> = stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Get version of a specific installed package
|
||||
pub fn get_package_version(db_path: &Path, name: &str) -> Result<Option<String>> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let version: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT version FROM packages WHERE name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
Ok(version)
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
//! Repository management and SQLite database generation
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, params};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use zstd::stream::write::Encoder;
|
||||
|
||||
pub struct RepoManager {
|
||||
pub repo_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RepoManager {
|
||||
pub fn new(repo_dir: PathBuf) -> Self {
|
||||
Self { repo_dir }
|
||||
}
|
||||
|
||||
/// Create a compressed SQLite repository database from a directory of packages
|
||||
pub fn create_repo_db(&self) -> Result<PathBuf> {
|
||||
let db_path = self.repo_dir.join("repo.db");
|
||||
let compressed_db_path = self.repo_dir.join("repo.db.zst");
|
||||
|
||||
// Remove existing DB if it exists
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path)?;
|
||||
}
|
||||
|
||||
let mut conn = Connection::open(&db_path)
|
||||
.with_context(|| format!("Failed to create repo database at {}", db_path.display()))?;
|
||||
|
||||
self.init_repo_schema(&mut conn)?;
|
||||
|
||||
// Find all .depot.pkg.tar.zst files in repo_dir
|
||||
for entry in fs::read_dir(&self.repo_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.to_string_lossy().ends_with(".depot.pkg.tar.zst") {
|
||||
self.index_package(&mut conn, &path)?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.close().map_err(|(_, e)| e)?;
|
||||
|
||||
// Compress the database
|
||||
self.compress_db(&db_path, &compressed_db_path)?;
|
||||
|
||||
// Remove the uncompressed DB
|
||||
fs::remove_file(&db_path)?;
|
||||
|
||||
Ok(compressed_db_path)
|
||||
}
|
||||
|
||||
fn init_repo_schema(&self, conn: &mut Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE packages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
description TEXT,
|
||||
homepage TEXT,
|
||||
license TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
sha256 TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE provides (
|
||||
package_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
FOREIGN KEY(package_id) REFERENCES packages(id)
|
||||
);
|
||||
CREATE INDEX idx_packages_name ON packages(name);
|
||||
CREATE INDEX idx_provides_name ON provides(name);",
|
||||
)
|
||||
.context("Failed to initialize repo schema")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn index_package(&self, conn: &mut Connection, pkg_path: &Path) -> Result<()> {
|
||||
println!("Indexing package {}...", pkg_path.display());
|
||||
|
||||
let filename = pkg_path.file_name().unwrap().to_string_lossy();
|
||||
let size = pkg_path.metadata()?.len();
|
||||
let sha256 = self.calculate_sha256(pkg_path)?;
|
||||
|
||||
// Read .metadata.toml from archive
|
||||
let file = fs::File::open(pkg_path)?;
|
||||
let zstd_decoder = zstd::stream::read::Decoder::new(file)?;
|
||||
let mut archive = tar::Archive::new(zstd_decoder);
|
||||
|
||||
let mut name = String::new();
|
||||
let mut version = String::new();
|
||||
let mut revision = 1;
|
||||
let mut description = None;
|
||||
let mut homepage = None;
|
||||
let mut license = None;
|
||||
let mut provides = Vec::new();
|
||||
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
let path = entry.path()?;
|
||||
if path.to_string_lossy() == ".metadata.toml" {
|
||||
let mut content = String::new();
|
||||
use std::io::Read;
|
||||
entry.read_to_string(&mut content)?;
|
||||
let metadata: toml::Value = toml::from_str(&content).with_context(|| {
|
||||
format!("Failed to parse .metadata.toml in {}", pkg_path.display())
|
||||
})?;
|
||||
|
||||
name = metadata
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
version = metadata
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
revision = metadata
|
||||
.get("revision")
|
||||
.and_then(|v| v.as_integer())
|
||||
.unwrap_or(1) as u32;
|
||||
description = metadata
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
homepage = metadata
|
||||
.get("homepage")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
license = metadata
|
||||
.get("license")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
if let Some(provides_arr) = metadata.get("provides").and_then(|v| v.as_array()) {
|
||||
provides = provides_arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if name.is_empty() {
|
||||
// Fallback for packages WITHOUT metadata (e.g. legacy or during transition)
|
||||
let name_parts: Vec<&str> = filename.split('-').collect();
|
||||
if name_parts.len() < 4 {
|
||||
anyhow::bail!(
|
||||
"Invalid package filename and no .metadata.toml: {}",
|
||||
filename
|
||||
);
|
||||
}
|
||||
name = name_parts[0].to_string();
|
||||
version = name_parts[1].to_string();
|
||||
revision = name_parts[2].parse().unwrap_or(1);
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
conn.execute(
|
||||
"INSERT INTO packages (name, version, revision, description, homepage, license, filename, size, sha256)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
name,
|
||||
version,
|
||||
revision as i64,
|
||||
description,
|
||||
homepage,
|
||||
license,
|
||||
filename,
|
||||
size as i64,
|
||||
sha256
|
||||
],
|
||||
)?;
|
||||
|
||||
let package_id = conn.last_insert_rowid();
|
||||
|
||||
// Insert into provides
|
||||
for provide in provides {
|
||||
conn.execute(
|
||||
"INSERT INTO provides (package_id, name) VALUES (?1, ?2)",
|
||||
params![package_id, provide],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn calculate_sha256(&self, path: &Path) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
std::io::copy(&mut file, &mut hasher)?;
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn compress_db(&self, source: &Path, dest: &Path) -> Result<()> {
|
||||
let mut input = fs::File::open(source)?;
|
||||
let output = fs::File::create(dest)?;
|
||||
let mut encoder = Encoder::new(output, 19)?; // High compression for repo DB
|
||||
encoder.multithread(num_cpus() as u32)?;
|
||||
std::io::copy(&mut input, &mut encoder)?;
|
||||
encoder.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_schema() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let manager = RepoManager::new(PathBuf::from("."));
|
||||
manager.init_repo_schema(&mut conn).unwrap();
|
||||
|
||||
// Check if table exists
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='packages'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_dir = tmp.path();
|
||||
let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst");
|
||||
|
||||
// Create a valid .tar.zst with .metadata.toml
|
||||
let file = fs::File::create(&pkg_path).unwrap();
|
||||
let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap();
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
|
||||
let metadata = r#"
|
||||
name = "test"
|
||||
version = "1.0"
|
||||
revision = 1
|
||||
description = "test description"
|
||||
homepage = "https://example.com"
|
||||
license = "MIT"
|
||||
provides = ["test-feature"]
|
||||
|
||||
[dependencies]
|
||||
build = []
|
||||
runtime = []
|
||||
"#;
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_path(".metadata.toml").unwrap();
|
||||
header.set_size(metadata.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append(&header, metadata.as_bytes()).unwrap();
|
||||
|
||||
let encoder = tar.into_inner().unwrap();
|
||||
encoder.finish().unwrap();
|
||||
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let manager = RepoManager::new(repo_dir.to_path_buf());
|
||||
manager.init_repo_schema(&mut conn).unwrap();
|
||||
manager.index_package(&mut conn, &pkg_path).unwrap();
|
||||
|
||||
let (name, version, revision, desc, home, lic): (
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT name, version, revision, description, homepage, license FROM packages",
|
||||
[],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
r.get(3)?,
|
||||
r.get(4)?,
|
||||
r.get(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(name, "test");
|
||||
assert_eq!(version, "1.0");
|
||||
assert_eq!(revision, 1);
|
||||
assert_eq!(desc, Some("test description".to_string()));
|
||||
assert_eq!(home, Some("https://example.com".to_string()));
|
||||
assert_eq!(lic, Some("MIT".to_string()));
|
||||
|
||||
let provides_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM provides", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(provides_count, 1);
|
||||
}
|
||||
}
|
||||
Executable
+263
@@ -0,0 +1,263 @@
|
||||
//! Dependency resolution for packages
|
||||
//!
|
||||
//! Supports versioned dependencies with operators:
|
||||
//! - `package` - any version
|
||||
//! - `package#1.2.3` - exactly version 1.2.3
|
||||
//! - `package>1.2.3` - greater than 1.2.3
|
||||
//! - `package<1.2.3` - less than 1.2.3
|
||||
//! - `package>=1.2.3` - greater than or equal to 1.2.3
|
||||
//! - `package<=1.2.3` - less than or equal to 1.2.3
|
||||
|
||||
use crate::db;
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
/// Version comparison operator
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum VersionOp {
|
||||
Exact, // #
|
||||
Gt, // >
|
||||
Lt, // <
|
||||
Gte, // >=
|
||||
Lte, // <=
|
||||
Any, // no operator
|
||||
}
|
||||
|
||||
/// Parsed dependency with optional version constraint
|
||||
struct ParsedDep<'a> {
|
||||
name: &'a str,
|
||||
version: Option<&'a str>,
|
||||
op: VersionOp,
|
||||
}
|
||||
|
||||
/// Parse a dependency string into name, version, and operator
|
||||
fn parse_dep(dep: &str) -> ParsedDep<'_> {
|
||||
// Try operators in order of specificity (>= before >, etc.)
|
||||
if let Some((name, ver)) = dep.split_once(">=") {
|
||||
return ParsedDep {
|
||||
name,
|
||||
version: Some(ver),
|
||||
op: VersionOp::Gte,
|
||||
};
|
||||
}
|
||||
if let Some((name, ver)) = dep.split_once("<=") {
|
||||
return ParsedDep {
|
||||
name,
|
||||
version: Some(ver),
|
||||
op: VersionOp::Lte,
|
||||
};
|
||||
}
|
||||
if let Some((name, ver)) = dep.split_once('>') {
|
||||
return ParsedDep {
|
||||
name,
|
||||
version: Some(ver),
|
||||
op: VersionOp::Gt,
|
||||
};
|
||||
}
|
||||
if let Some((name, ver)) = dep.split_once('<') {
|
||||
return ParsedDep {
|
||||
name,
|
||||
version: Some(ver),
|
||||
op: VersionOp::Lt,
|
||||
};
|
||||
}
|
||||
if let Some((name, ver)) = dep.split_once('#') {
|
||||
return ParsedDep {
|
||||
name,
|
||||
version: Some(ver),
|
||||
op: VersionOp::Exact,
|
||||
};
|
||||
}
|
||||
|
||||
ParsedDep {
|
||||
name: dep,
|
||||
version: None,
|
||||
op: VersionOp::Any,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two version strings using semver if possible, fallback to string compare
|
||||
fn compare_versions(installed: &str, required: &str, op: VersionOp) -> bool {
|
||||
// Try semver comparison first
|
||||
if let (Ok(inst), Ok(req)) = (
|
||||
semver::Version::parse(installed),
|
||||
semver::Version::parse(required),
|
||||
) {
|
||||
return match op {
|
||||
VersionOp::Exact => inst == req,
|
||||
VersionOp::Gt => inst > req,
|
||||
VersionOp::Lt => inst < req,
|
||||
VersionOp::Gte => inst >= req,
|
||||
VersionOp::Lte => inst <= req,
|
||||
VersionOp::Any => true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to string comparison for non-semver versions
|
||||
match op {
|
||||
VersionOp::Exact => installed == required,
|
||||
VersionOp::Gt => installed > required,
|
||||
VersionOp::Lt => installed < required,
|
||||
VersionOp::Gte => installed >= required,
|
||||
VersionOp::Lte => installed <= required,
|
||||
VersionOp::Any => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a versioned dependency is satisfied
|
||||
fn is_dep_satisfied(
|
||||
dep: &str,
|
||||
installed: &std::collections::HashSet<String>,
|
||||
provides: &std::collections::HashSet<String>,
|
||||
db_path: &Path,
|
||||
) -> Result<bool> {
|
||||
let parsed = parse_dep(dep);
|
||||
|
||||
// Check if package is installed or provided
|
||||
if !installed.contains(parsed.name) && !provides.contains(parsed.name) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// If no version required, we're good
|
||||
let Some(required) = parsed.version else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
// Check version matches
|
||||
if let Some(installed_version) = db::get_package_version(db_path, parsed.name)? {
|
||||
Ok(compare_versions(&installed_version, required, parsed.op))
|
||||
} else {
|
||||
// Package might be provided by an alternative, accept it
|
||||
Ok(provides.contains(parsed.name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if all build dependencies are satisfied
|
||||
pub fn check_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
|
||||
let mut missing = Vec::new();
|
||||
|
||||
if !db_path.exists() {
|
||||
return Ok(spec.dependencies.build.clone());
|
||||
}
|
||||
|
||||
let installed = db::get_installed_packages(db_path)?;
|
||||
let provides = db::get_all_provides(db_path)?;
|
||||
|
||||
for dep in &spec.dependencies.build {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Check if all runtime dependencies are satisfied
|
||||
pub fn check_runtime_deps(spec: &PackageSpec, db_path: &Path) -> Result<Vec<String>> {
|
||||
let mut missing = Vec::new();
|
||||
|
||||
if !db_path.exists() {
|
||||
return Ok(spec.dependencies.runtime.clone());
|
||||
}
|
||||
|
||||
let installed = db::get_installed_packages(db_path)?;
|
||||
let provides = db::get_all_provides(db_path)?;
|
||||
|
||||
for dep in &spec.dependencies.runtime {
|
||||
if !is_dep_satisfied(dep, &installed, &provides, db_path)? {
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Print dependency status
|
||||
pub fn print_dep_status(spec: &PackageSpec, db_path: &Path) -> Result<()> {
|
||||
let missing_build = check_build_deps(spec, db_path)?;
|
||||
let missing_runtime = check_runtime_deps(spec, db_path)?;
|
||||
|
||||
if !spec.dependencies.build.is_empty() {
|
||||
println!("Build dependencies: {}", spec.dependencies.build.join(", "));
|
||||
if !missing_build.is_empty() {
|
||||
println!(" Missing: {}", missing_build.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
if !spec.dependencies.runtime.is_empty() {
|
||||
println!(
|
||||
"Runtime dependencies: {}",
|
||||
spec.dependencies.runtime.join(", ")
|
||||
);
|
||||
if !missing_runtime.is_empty() {
|
||||
println!(" Missing: {}", missing_runtime.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify all build dependencies are installed, error if not
|
||||
pub fn require_build_deps(spec: &PackageSpec, db_path: &Path) -> Result<()> {
|
||||
let missing = check_build_deps(spec, db_path)?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Missing build dependencies: {}\nInstall them first with: nyapm install <package>",
|
||||
missing.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_dep() {
|
||||
let cases = vec![
|
||||
("package", "package", None, VersionOp::Any),
|
||||
("pkg#1.0.0", "pkg", Some("1.0.0"), VersionOp::Exact),
|
||||
("pkg>1.0", "pkg", Some("1.0"), VersionOp::Gt),
|
||||
("pkg<2.0", "pkg", Some("2.0"), VersionOp::Lt),
|
||||
("pkg>=1.5", "pkg", Some("1.5"), VersionOp::Gte),
|
||||
("pkg<=2.5", "pkg", Some("2.5"), VersionOp::Lte),
|
||||
];
|
||||
|
||||
for (input, name, ver, op) in cases {
|
||||
let parsed = parse_dep(input);
|
||||
assert_eq!(parsed.name, name, "Failed name for {}", input);
|
||||
assert_eq!(parsed.version, ver, "Failed version for {}", input);
|
||||
assert_eq!(parsed.op, op, "Failed op for {}", input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_versions_semver() {
|
||||
assert!(compare_versions("1.0.0", "1.0.0", VersionOp::Exact));
|
||||
assert!(!compare_versions("1.0.1", "1.0.0", VersionOp::Exact));
|
||||
|
||||
assert!(compare_versions("1.1.0", "1.0.0", VersionOp::Gt));
|
||||
assert!(!compare_versions("1.0.0", "1.0.0", VersionOp::Gt));
|
||||
|
||||
assert!(compare_versions("0.9.0", "1.0.0", VersionOp::Lt));
|
||||
assert!(!compare_versions("1.0.0", "1.0.0", VersionOp::Lt));
|
||||
|
||||
assert!(compare_versions("1.0.0", "1.0.0", VersionOp::Gte));
|
||||
assert!(compare_versions("1.1.0", "1.0.0", VersionOp::Gte));
|
||||
|
||||
assert!(compare_versions("1.0.0", "1.0.0", VersionOp::Lte));
|
||||
assert!(compare_versions("0.9.0", "1.0.0", VersionOp::Lte));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_versions_fallback() {
|
||||
// String comparison fallback
|
||||
assert!(compare_versions("b", "a", VersionOp::Gt));
|
||||
assert!(compare_versions("a", "b", VersionOp::Lt));
|
||||
assert!(compare_versions("foo", "foo", VersionOp::Exact));
|
||||
}
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
//! Fakeroot support for running commands as pseudo-root
|
||||
//! Uses the system fakeroot command when not running as root
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if we're running as root
|
||||
pub fn is_root() -> bool {
|
||||
nix::unistd::geteuid().is_root()
|
||||
}
|
||||
|
||||
/// Wrap a command for fakeroot execution
|
||||
/// For make install, we use the system fakeroot command
|
||||
pub fn wrap_install_command(program: &str, destdir: &Path) -> Command {
|
||||
if is_root() {
|
||||
Command::new(program)
|
||||
} else {
|
||||
// Use system fakeroot command which handles LD_PRELOAD internally
|
||||
let mut cmd = Command::new("fakeroot");
|
||||
cmd.arg("--");
|
||||
cmd.arg(program);
|
||||
// Fakeroot will ensure file ownership appears as root
|
||||
cmd.env("DESTDIR", destdir);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
//! Package index for fast lookups
|
||||
//!
|
||||
//! Caches package name -> spec path and provides -> spec path mappings.
|
||||
|
||||
use crate::package::PackageSpec;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cached package index for O(1) lookups
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PackageIndex {
|
||||
/// Package name -> spec path
|
||||
by_name: HashMap<String, PathBuf>,
|
||||
/// Provided name -> spec paths (can be multiple)
|
||||
by_provides: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl PackageIndex {
|
||||
/// Build index by scanning packages/*/*.toml
|
||||
pub fn build() -> Self {
|
||||
let mut index = Self::default();
|
||||
let packages_dir = PathBuf::from("packages");
|
||||
|
||||
if !packages_dir.exists() {
|
||||
return index;
|
||||
}
|
||||
|
||||
// Scan all package directories
|
||||
if let Ok(entries) = fs::read_dir(&packages_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let dir = entry.path();
|
||||
|
||||
// Find all .toml files in this directory
|
||||
if let Ok(files) = fs::read_dir(&dir) {
|
||||
for file in files.flatten() {
|
||||
let path = file.path();
|
||||
if path.extension().map(|e| e == "toml").unwrap_or(false) {
|
||||
// Try to parse the spec
|
||||
if let Ok(spec) = PackageSpec::from_file(&path) {
|
||||
// Index by name
|
||||
index
|
||||
.by_name
|
||||
.insert(spec.package.name.clone(), path.clone());
|
||||
|
||||
// Index by provides
|
||||
for provided in &spec.alternatives.provides {
|
||||
index
|
||||
.by_provides
|
||||
.entry(provided.clone())
|
||||
.or_default()
|
||||
.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"Indexed {} packages ({} provides)",
|
||||
index.by_name.len(),
|
||||
index.by_provides.len()
|
||||
);
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
/// Find a spec by package name or provides
|
||||
pub fn find(&self, name: &str) -> Option<PathBuf> {
|
||||
// First try by name
|
||||
if let Some(path) = self.by_name.get(name) {
|
||||
return Some(path.clone());
|
||||
}
|
||||
|
||||
// Then try by provides
|
||||
if let Some(paths) = self.by_provides.get(name) {
|
||||
if paths.len() > 1 {
|
||||
eprintln!(
|
||||
"Warning: Multiple packages provide '{}': {:?}",
|
||||
name,
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
return paths.first().cloned();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
Executable
+514
@@ -0,0 +1,514 @@
|
||||
//! Depot - Not Your Average Package Manager
|
||||
//! A source-based package manager for Linux
|
||||
|
||||
mod builder;
|
||||
mod config;
|
||||
mod cross;
|
||||
mod db;
|
||||
mod deps;
|
||||
mod fakeroot;
|
||||
mod index;
|
||||
mod package;
|
||||
mod source;
|
||||
mod staging;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "Depot")]
|
||||
#[command(about = "Depot - Source-based package manager for Linux", long_about = None)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
/// Custom root filesystem path
|
||||
#[arg(long, short = 'r', default_value = "/", global = true)]
|
||||
rootfs: PathBuf,
|
||||
|
||||
/// Skip dependency checks
|
||||
#[arg(long, global = true)]
|
||||
no_deps: bool,
|
||||
|
||||
/// Cross-compilation prefix (e.g., x86_64-linux-musl, aarch64-linux-gnu)
|
||||
#[arg(long, global = true)]
|
||||
cross_prefix: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Build and install a package from a spec file
|
||||
Install {
|
||||
/// Path to package spec (.toml) or package archive (.tar.zst)
|
||||
#[arg(value_name = "SPEC_OR_ARCHIVE")]
|
||||
spec_or_archive: PathBuf,
|
||||
|
||||
/// Explicitly specify path to package spec (.toml file)
|
||||
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
|
||||
spec: Option<PathBuf>,
|
||||
},
|
||||
/// Remove an installed package
|
||||
Remove {
|
||||
/// Package name to remove
|
||||
package: String,
|
||||
},
|
||||
/// Build a package without installing
|
||||
Build {
|
||||
/// Path to package spec (.toml file)
|
||||
#[arg(value_name = "SPEC")]
|
||||
spec_pos: Option<PathBuf>,
|
||||
|
||||
/// Explicitly specify path to package spec (.toml file)
|
||||
#[arg(short, long = "spec", visible_alias = "package", alias = "p")]
|
||||
spec: Option<PathBuf>,
|
||||
},
|
||||
/// Show information about a package
|
||||
Info {
|
||||
/// Path to package spec or installed package name
|
||||
package: String,
|
||||
},
|
||||
/// List installed packages
|
||||
List,
|
||||
/// Repository management
|
||||
Repo {
|
||||
#[command(subcommand)]
|
||||
command: RepoCommands,
|
||||
},
|
||||
/// Show current configuration
|
||||
Config,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum RepoCommands {
|
||||
/// Create a repository database from a directory of packages
|
||||
Create {
|
||||
/// Directory containing .depot.pkg.tar.zst files
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Install {
|
||||
spec_or_archive,
|
||||
spec,
|
||||
} => {
|
||||
let spec_path = spec.unwrap_or(spec_or_archive);
|
||||
println!("Installing package from: {}", spec_path.display());
|
||||
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
|
||||
let (pkg_spec, staging_dir): (package::PackageSpec, Option<tempfile::TempDir>) =
|
||||
if spec_path.to_string_lossy().ends_with(".tar.zst") {
|
||||
// Install from archive
|
||||
println!("Detected package archive: {}", spec_path.display());
|
||||
let tmp_dir = tempfile::TempDir::new()?;
|
||||
let extract_dir = tmp_dir.path().to_path_buf();
|
||||
|
||||
// Extract metadata.toml first to get spec
|
||||
let file = fs::File::open(&spec_path)?;
|
||||
let zstd_decoder = zstd::stream::read::Decoder::new(file)?;
|
||||
let mut archive = tar::Archive::new(zstd_decoder);
|
||||
|
||||
let mut metadata_content = String::new();
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
if entry.path()?.to_string_lossy() == ".metadata.toml" {
|
||||
use std::io::Read;
|
||||
entry.read_to_string(&mut metadata_content)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if metadata_content.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Package archive does not contain .metadata.toml: {}",
|
||||
spec_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// We need to parse the metadata.toml but we don't have a direct "from_metadata"
|
||||
// Let's implement a minimal reconstruction or use the metadata to fill a spec.
|
||||
// Actually, PackageSpec needs a lot of fields.
|
||||
// Let's extract the WHOLE archive to a temporary staging dir and use it.
|
||||
let file = fs::File::open(&spec_path)?;
|
||||
let zstd_decoder = zstd::stream::read::Decoder::new(file)?;
|
||||
let mut archive = tar::Archive::new(zstd_decoder);
|
||||
archive.unpack(&extract_dir)?;
|
||||
|
||||
let metadata: toml::Value = toml::from_str(&metadata_content)?;
|
||||
|
||||
// Create a minimal spec from metadata
|
||||
let mut spec = package::PackageSpec {
|
||||
package: package::PackageInfo {
|
||||
name: metadata
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
version: metadata
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
revision: metadata
|
||||
.get("revision")
|
||||
.and_then(|v| v.as_integer())
|
||||
.unwrap_or(1) as u32,
|
||||
description: metadata
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
homepage: metadata
|
||||
.get("homepage")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
license: metadata
|
||||
.get("license")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
},
|
||||
alternatives: package::Alternatives::default(),
|
||||
manual_sources: Vec::new(),
|
||||
source: Vec::new(),
|
||||
build: package::Build {
|
||||
build_type: package::BuildType::Bin,
|
||||
flags: package::BuildFlags::default(),
|
||||
},
|
||||
dependencies: package::Dependencies {
|
||||
build: Vec::new(),
|
||||
runtime: if let Some(deps) = metadata
|
||||
.get("dependencies")
|
||||
.and_then(|v| v.get("runtime"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
deps.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
},
|
||||
spec_dir: PathBuf::from("."),
|
||||
};
|
||||
|
||||
if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) {
|
||||
spec.alternatives.provides = provides
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
|
||||
(spec, Some(tmp_dir))
|
||||
} else {
|
||||
// Install from spec (normal build)
|
||||
let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?;
|
||||
pkg_spec.apply_config(&config);
|
||||
|
||||
// ... existing build logic ...
|
||||
// Jump to the part where build actually happens.
|
||||
// To keep the code clean, I'll move the build/stage logic into a helper or similar?
|
||||
// Actually, I'll just structure it so we can skip build if staging_dir is Some.
|
||||
(pkg_spec, None)
|
||||
};
|
||||
|
||||
println!(
|
||||
"Package: {} v{}-{}",
|
||||
pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision
|
||||
);
|
||||
|
||||
// Get config relative to rootfs
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
|
||||
// Ensure database directory exists
|
||||
std::fs::create_dir_all(&config.db_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create database directory: {}",
|
||||
config.db_dir.display()
|
||||
)
|
||||
})?;
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
|
||||
// Check dependencies and prompt for auto-install if needed
|
||||
if !cli.no_deps {
|
||||
deps::print_dep_status(&pkg_spec, &db_path)?;
|
||||
|
||||
// Collect all missing dependencies (build + runtime)
|
||||
let mut missing = deps::check_build_deps(&pkg_spec, &db_path)?;
|
||||
let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?;
|
||||
|
||||
for dep in missing_runtime {
|
||||
if !missing.contains(&dep) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
// Check for dependency cycles via DEPOT_DEPCHAIN env var
|
||||
let dep_chain = std::env::var("DEPOT_DEPCHAIN").unwrap_or_default();
|
||||
let chain_set: std::collections::HashSet<&str> =
|
||||
dep_chain.split(',').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if chain_set.contains(pkg_spec.package.name.as_str()) {
|
||||
anyhow::bail!(
|
||||
"Dependency cycle detected! {} is already in chain: {}",
|
||||
pkg_spec.package.name,
|
||||
dep_chain
|
||||
);
|
||||
}
|
||||
|
||||
println!("\nMissing dependencies: {}", missing.join(", "));
|
||||
println!("Do you want to attempt to install them? [Y/n] ");
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let input = input.trim().to_lowercase();
|
||||
|
||||
if input == "y" || input == "yes" || input.is_empty() {
|
||||
// Build package index for fast lookups
|
||||
let pkg_index = index::PackageIndex::build();
|
||||
|
||||
// Build new dep chain
|
||||
let new_chain = if dep_chain.is_empty() {
|
||||
pkg_spec.package.name.clone()
|
||||
} else {
|
||||
format!("{},{}", dep_chain, pkg_spec.package.name)
|
||||
};
|
||||
|
||||
// Attempt to install missing deps
|
||||
for dep in missing {
|
||||
// Use package index for O(1) lookup
|
||||
let candidate = pkg_index.find(&dep);
|
||||
|
||||
if let Some(dep_spec_path) = candidate {
|
||||
println!("Installing dependency: {}...", dep);
|
||||
|
||||
let mut cmd = std::process::Command::new(std::env::current_exe()?);
|
||||
cmd.arg("-r").arg(&cli.rootfs);
|
||||
|
||||
if cli.no_deps {
|
||||
cmd.arg("--no-deps");
|
||||
}
|
||||
if let Some(ref p) = cli.cross_prefix {
|
||||
cmd.arg("--cross-prefix").arg(p);
|
||||
}
|
||||
|
||||
cmd.arg("install").arg(&dep_spec_path);
|
||||
cmd.env("DEPOT_DEPCHAIN", &new_chain);
|
||||
|
||||
let status = cmd.status()?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to install dependency: {}", dep);
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Could not find package spec for dependency: {}",
|
||||
dep
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce build dependencies (runtime deps are warnings only if not installed/prompt declined)
|
||||
deps::require_build_deps(&pkg_spec, &db_path)?;
|
||||
}
|
||||
|
||||
// Ensure database directory exists
|
||||
std::fs::create_dir_all(&config.db_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create database directory: {}",
|
||||
config.db_dir.display()
|
||||
)
|
||||
})?;
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
|
||||
let destdir = if let Some(dir) = &staging_dir {
|
||||
dir.path().to_path_buf()
|
||||
} else {
|
||||
// 1-2. Fetch + extract sources (supports archives and git URL#rev)
|
||||
let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?;
|
||||
|
||||
// 3. Build
|
||||
let destdir = config
|
||||
.build_dir
|
||||
.join("destdir")
|
||||
.join(&pkg_spec.package.name);
|
||||
|
||||
// Build with optional cross-compilation
|
||||
let cross_config = cli
|
||||
.cross_prefix
|
||||
.as_ref()
|
||||
.map(|p| cross::CrossConfig::from_prefix(p))
|
||||
.transpose()?;
|
||||
builder::build(&pkg_spec, &src_dir, &destdir, cross_config.as_ref())?;
|
||||
|
||||
// 3.1 Copy license files into staged tree
|
||||
staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?;
|
||||
|
||||
destdir
|
||||
};
|
||||
|
||||
// 4. Stage (clean .la files, etc.)
|
||||
staging::process(&destdir, &pkg_spec)?;
|
||||
|
||||
// 5. Install/update to rootfs (atomic)
|
||||
let new_files = staging::generate_manifest_with_dirs(&destdir)?;
|
||||
|
||||
let remove_paths =
|
||||
db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_files.files)?;
|
||||
|
||||
let tx_base = config.build_dir.join("tx");
|
||||
let tx = staging::install_atomic(&destdir, &cli.rootfs, &tx_base, &remove_paths)?;
|
||||
|
||||
// 6. Register in database (rollback install on DB error)
|
||||
if let Err(e) = db::register_package(&db_path, &pkg_spec, &destdir) {
|
||||
let _ = tx.rollback();
|
||||
return Err(e);
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
// 7. Check runtime dependencies (warn only)
|
||||
if !cli.no_deps {
|
||||
let missing_runtime = deps::check_runtime_deps(&pkg_spec, &db_path)?;
|
||||
if !missing_runtime.is_empty() {
|
||||
eprintln!(
|
||||
"\x1b[33mWarning: Missing runtime dependencies: {}\x1b[0m",
|
||||
missing_runtime.join(", ")
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[33mContinuing without runtime deps; binaries may not run correctly.\x1b[0m"
|
||||
);
|
||||
eprintln!("\x1b[33mUse --no-deps to suppress this warning.\x1b[0m");
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"Successfully installed {} v{}",
|
||||
pkg_spec.package.name, pkg_spec.package.version
|
||||
);
|
||||
}
|
||||
Commands::Remove { package } => {
|
||||
println!("Removing package: {}", package);
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
db::remove_package(&db_path, &package, &cli.rootfs)?;
|
||||
println!("Successfully removed {}", package);
|
||||
}
|
||||
Commands::Build { spec_pos, spec } => {
|
||||
if crate::fakeroot::is_root() {
|
||||
anyhow::bail!(
|
||||
"The 'build' command must be run as a non-root user to ensure a clean build environment."
|
||||
);
|
||||
}
|
||||
let spec_path = spec.or(spec_pos).context("No spec file provided")?;
|
||||
println!("Building package from: {}", spec_path.display());
|
||||
let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?;
|
||||
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
|
||||
// Apply system overrides
|
||||
pkg_spec.apply_config(&config);
|
||||
|
||||
// Ensure database directory exists
|
||||
std::fs::create_dir_all(&config.db_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create database directory: {}",
|
||||
config.db_dir.display()
|
||||
)
|
||||
})?;
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
|
||||
// Check build dependencies
|
||||
if !cli.no_deps {
|
||||
deps::print_dep_status(&pkg_spec, &db_path)?;
|
||||
deps::require_build_deps(&pkg_spec, &db_path)?;
|
||||
}
|
||||
|
||||
let src_dir = source::prepare(&pkg_spec, &config.cache_dir, &config.build_dir)?;
|
||||
|
||||
let destdir = config
|
||||
.build_dir
|
||||
.join("destdir")
|
||||
.join(&pkg_spec.package.name);
|
||||
// Build with optional cross-compilation
|
||||
let cross_config = cli
|
||||
.cross_prefix
|
||||
.as_ref()
|
||||
.map(|p| cross::CrossConfig::from_prefix(p))
|
||||
.transpose()?;
|
||||
builder::build(&pkg_spec, &src_dir, &destdir, cross_config.as_ref())?;
|
||||
|
||||
staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?;
|
||||
|
||||
staging::process(&destdir, &pkg_spec)?;
|
||||
|
||||
// Create package archive
|
||||
let packager = package::Packager::new(pkg_spec, destdir.clone(), config);
|
||||
let arch = cli
|
||||
.cross_prefix
|
||||
.as_deref()
|
||||
.unwrap_or(std::env::consts::ARCH);
|
||||
let pkg_file = packager.create_package(Path::new("."), arch)?;
|
||||
|
||||
println!("Build complete. Package created: {}", pkg_file.display());
|
||||
}
|
||||
Commands::Info { package } => {
|
||||
// Try as file first, then as installed package name
|
||||
let path = PathBuf::from(&package);
|
||||
if path.exists() {
|
||||
let pkg_spec = package::PackageSpec::from_file(&path)?;
|
||||
println!("{}", pkg_spec);
|
||||
|
||||
// Also show dependency status
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
deps::print_dep_status(&pkg_spec, &db_path)?;
|
||||
} else {
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
db::show_package_info(&db_path, &package)?;
|
||||
}
|
||||
}
|
||||
Commands::List => {
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
let db_path = config.db_dir.join("packages.db");
|
||||
db::list_packages(&db_path)?;
|
||||
}
|
||||
Commands::Repo { command } => match command {
|
||||
RepoCommands::Create { dir } => {
|
||||
let repo = db::repo::RepoManager::new(dir);
|
||||
let db_path = repo.create_repo_db()?;
|
||||
println!("Created repository database: {}", db_path.display());
|
||||
}
|
||||
},
|
||||
Commands::Config => {
|
||||
let config = config::Config::for_rootfs(&cli.rootfs);
|
||||
println!("Cache Directory: {}", config.cache_dir.display());
|
||||
println!("Build Directory: {}", config.build_dir.display());
|
||||
println!("Database Directory: {}", config.db_dir.display());
|
||||
println!("\nBuild Overrides: {}", config.build_overrides);
|
||||
println!("Package Overrides: {}", config.package_overrides);
|
||||
if !config.appends.is_empty() {
|
||||
println!("\nAppends:");
|
||||
for (k, v) in &config.appends {
|
||||
println!(" {} = {:?}", k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
//! Package specification parsing
|
||||
|
||||
mod packager;
|
||||
mod spec;
|
||||
|
||||
pub use packager::Packager;
|
||||
pub use spec::*;
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Package creation and archive management
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tar::Builder;
|
||||
use zstd::stream::write::Encoder;
|
||||
|
||||
pub struct Packager {
|
||||
pub spec: PackageSpec,
|
||||
pub destdir: PathBuf,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
impl Packager {
|
||||
pub fn new(spec: PackageSpec, destdir: PathBuf, config: Config) -> Self {
|
||||
Self {
|
||||
spec,
|
||||
destdir,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a package archive (.depot.pkg.tar.zst) from the destdir
|
||||
pub fn create_package(&self, output_dir: &Path, arch: &str) -> Result<PathBuf> {
|
||||
let filename = self.spec.package_filename(arch);
|
||||
let output_path = output_dir.join(&filename);
|
||||
|
||||
println!("Creating package {}...", filename);
|
||||
|
||||
// Generate .files.yaml
|
||||
self.generate_files_yaml()?;
|
||||
|
||||
// Generate .metadata.toml
|
||||
self.generate_metadata_toml()?;
|
||||
|
||||
// Create tar.zst
|
||||
let file = fs::File::create(&output_path)
|
||||
.with_context(|| format!("Failed to create output file: {}", output_path.display()))?;
|
||||
|
||||
// Respect zstd level from config (default to 3 if not specified)
|
||||
let level = self
|
||||
.config
|
||||
.package_overrides
|
||||
.get("compression_level")
|
||||
.and_then(|v| v.as_integer())
|
||||
.unwrap_or(3) as i32;
|
||||
|
||||
let mut encoder = Encoder::new(file, level)?;
|
||||
let _ = encoder.multithread(num_cpus() as u32);
|
||||
|
||||
let mut tar = Builder::new(encoder);
|
||||
|
||||
// Manual walk to ensure symlinks aren't followed (preserving them as links)
|
||||
for entry in walkdir::WalkDir::new(&self.destdir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
let rel_path = path.strip_prefix(&self.destdir)?;
|
||||
|
||||
// Skip the root of the destdir
|
||||
if rel_path.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_type = entry.file_type();
|
||||
if file_type.is_dir() {
|
||||
tar.append_dir(rel_path, path)?;
|
||||
} else if file_type.is_symlink() {
|
||||
// For symlinks, we need to read the link and append to tar correctly
|
||||
let target = fs::read_link(path)?;
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_metadata_in_mode(
|
||||
&fs::symlink_metadata(path)?,
|
||||
tar::HeaderMode::Deterministic,
|
||||
);
|
||||
tar.append_link(&mut header, rel_path, target)?;
|
||||
} else {
|
||||
// Files
|
||||
let mut file = fs::File::open(path)?;
|
||||
tar.append_file(rel_path, &mut file)?;
|
||||
}
|
||||
}
|
||||
|
||||
let encoder = tar.into_inner()?;
|
||||
encoder.finish()?;
|
||||
|
||||
println!("Created package: {}", output_path.display());
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
fn generate_metadata_toml(&self) -> Result<()> {
|
||||
let metadata_path = self.destdir.join(".metadata.toml");
|
||||
|
||||
// Construct a simple metadata structure
|
||||
let mut map = toml::map::Map::new();
|
||||
map.insert(
|
||||
"name".to_string(),
|
||||
toml::Value::String(self.spec.package.name.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"version".to_string(),
|
||||
toml::Value::String(self.spec.package.version.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"revision".to_string(),
|
||||
toml::Value::Integer(self.spec.package.revision as i64),
|
||||
);
|
||||
map.insert(
|
||||
"description".to_string(),
|
||||
toml::Value::String(self.spec.package.description.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"homepage".to_string(),
|
||||
toml::Value::String(self.spec.package.homepage.clone()),
|
||||
);
|
||||
map.insert(
|
||||
"license".to_string(),
|
||||
toml::Value::String(self.spec.package.license.clone()),
|
||||
);
|
||||
|
||||
// Add provides
|
||||
map.insert(
|
||||
"provides".to_string(),
|
||||
toml::Value::Array(
|
||||
self.spec
|
||||
.alternatives
|
||||
.provides
|
||||
.iter()
|
||||
.map(|s| toml::Value::String(s.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
// Add dependencies if useful for repo indexing
|
||||
let mut build_deps = toml::map::Map::new();
|
||||
build_deps.insert(
|
||||
"build".to_string(),
|
||||
toml::Value::Array(
|
||||
self.spec
|
||||
.dependencies
|
||||
.build
|
||||
.iter()
|
||||
.map(|s| toml::Value::String(s.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
build_deps.insert(
|
||||
"runtime".to_string(),
|
||||
toml::Value::Array(
|
||||
self.spec
|
||||
.dependencies
|
||||
.runtime
|
||||
.iter()
|
||||
.map(|s| toml::Value::String(s.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
map.insert("dependencies".to_string(), toml::Value::Table(build_deps));
|
||||
|
||||
let toml_str = toml::to_string(&toml::Value::Table(map))
|
||||
.context("Failed to serialize metadata to TOML")?;
|
||||
|
||||
fs::write(&metadata_path, toml_str)
|
||||
.with_context(|| format!("Failed to write metadata: {}", metadata_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_files_yaml(&self) -> Result<()> {
|
||||
let files_path = self.destdir.join(".files.yaml");
|
||||
let mut files = Vec::new();
|
||||
|
||||
// Recursively list all files in destdir
|
||||
self.collect_files(&self.destdir, &self.destdir, &mut files)?;
|
||||
|
||||
let mut out_str = String::new();
|
||||
{
|
||||
let mut emitter = yaml_rust2::YamlEmitter::new(&mut out_str);
|
||||
let yaml_vec: Vec<yaml_rust2::Yaml> =
|
||||
files.into_iter().map(yaml_rust2::Yaml::String).collect();
|
||||
emitter
|
||||
.dump(&yaml_rust2::Yaml::Array(yaml_vec))
|
||||
.context("Failed to emit YAML")?;
|
||||
}
|
||||
|
||||
fs::write(&files_path, out_str)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_files(&self, base: &Path, current: &Path, files: &mut Vec<String>) -> Result<()> {
|
||||
for entry in fs::read_dir(current)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type()?;
|
||||
let relative = path.strip_prefix(base)?.to_string_lossy().to_string();
|
||||
|
||||
if file_type.is_dir() {
|
||||
self.collect_files(base, &path, files)?;
|
||||
} else {
|
||||
files.push(relative);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn num_cpus() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::package::{Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo};
|
||||
|
||||
fn mk_packager(destdir: PathBuf) -> Packager {
|
||||
Packager::new(
|
||||
PackageSpec {
|
||||
package: PackageInfo {
|
||||
name: "test".into(),
|
||||
version: "1.0".into(),
|
||||
revision: 1,
|
||||
description: "d".into(),
|
||||
homepage: "h".into(),
|
||||
license: "MIT".into(),
|
||||
},
|
||||
alternatives: Alternatives::default(),
|
||||
manual_sources: Vec::new(),
|
||||
source: Vec::new(),
|
||||
build: Build {
|
||||
build_type: BuildType::Custom,
|
||||
flags: BuildFlags::default(),
|
||||
},
|
||||
dependencies: Dependencies::default(),
|
||||
spec_dir: PathBuf::from("."),
|
||||
},
|
||||
destdir,
|
||||
Config::for_rootfs(Path::new("/tmp/nonexistent")),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path();
|
||||
fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||
fs::write(dest.join("usr/bin/foo"), "x").unwrap();
|
||||
fs::create_dir_all(dest.join("etc")).unwrap();
|
||||
fs::write(dest.join("etc/config"), "y").unwrap();
|
||||
|
||||
let packager = mk_packager(dest.to_path_buf());
|
||||
let mut files = Vec::new();
|
||||
packager.collect_files(dest, dest, &mut files).unwrap();
|
||||
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files.contains(&"usr/bin/foo".to_string()));
|
||||
assert!(files.contains(&"etc/config".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_files_yaml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path();
|
||||
fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
||||
fs::write(dest.join("usr/bin/foo"), "x").unwrap();
|
||||
|
||||
let packager = mk_packager(dest.to_path_buf());
|
||||
packager.generate_files_yaml().unwrap();
|
||||
|
||||
let yaml_path = dest.join(".files.yaml");
|
||||
assert!(yaml_path.exists());
|
||||
let yaml_content = fs::read_to_string(yaml_path).unwrap();
|
||||
assert!(yaml_content.contains("usr/bin/foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_metadata_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dest = tmp.path();
|
||||
|
||||
let packager = mk_packager(dest.to_path_buf());
|
||||
packager.generate_metadata_toml().unwrap();
|
||||
|
||||
let meta_path = dest.join(".metadata.toml");
|
||||
assert!(meta_path.exists());
|
||||
let content = fs::read_to_string(meta_path).unwrap();
|
||||
let val: toml::Value = toml::from_str(&content).unwrap();
|
||||
|
||||
assert_eq!(val.get("name").and_then(|v| v.as_str()), Some("test"));
|
||||
assert_eq!(val.get("version").and_then(|v| v.as_str()), Some("1.0"));
|
||||
assert_eq!(val.get("revision").and_then(|v| v.as_integer()), Some(1));
|
||||
assert_eq!(val.get("license").and_then(|v| v.as_str()), Some("MIT"));
|
||||
|
||||
let deps = val.get("dependencies").unwrap();
|
||||
assert!(deps.get("build").unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
Executable
+712
@@ -0,0 +1,712 @@
|
||||
//! Package specification structures and TOML parsing
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Complete package specification from TOML
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PackageSpec {
|
||||
pub package: PackageInfo,
|
||||
#[serde(default)]
|
||||
pub alternatives: Alternatives,
|
||||
/// Manual (local) sources to copy before fetching remote sources.
|
||||
#[serde(default)]
|
||||
pub manual_sources: Vec<ManualSource>,
|
||||
#[serde(default, deserialize_with = "deserialize_sources")]
|
||||
pub source: Vec<Source>,
|
||||
pub build: Build,
|
||||
#[serde(default)]
|
||||
pub dependencies: Dependencies,
|
||||
|
||||
/// Directory containing the spec file (used to resolve relative paths such as patches).
|
||||
#[serde(skip)]
|
||||
pub spec_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PackageSpec {
|
||||
/// Load package spec from a TOML file
|
||||
pub fn from_file(path: &Path) -> Result<Self> {
|
||||
// Canonicalize path to ensure spec_dir is absolute
|
||||
let abs_path = path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("Failed to resolve path: {}", path.display()))?;
|
||||
|
||||
let content = fs::read_to_string(&abs_path)
|
||||
.with_context(|| format!("Failed to read package spec: {}", abs_path.display()))?;
|
||||
let mut spec: PackageSpec = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse package spec: {}", abs_path.display()))?;
|
||||
spec.spec_dir = abs_path
|
||||
.parent()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
// Require at least one source (remote or manual)
|
||||
if spec.source.is_empty() && spec.manual_sources.is_empty() {
|
||||
anyhow::bail!("Package must have at least one source or manual_sources entry");
|
||||
}
|
||||
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// Expand variables like $name and $version in a string
|
||||
pub fn expand_vars(&self, input: &str) -> String {
|
||||
let specdir = self.spec_dir.to_string_lossy();
|
||||
input
|
||||
.replace("$name", &self.package.name)
|
||||
.replace("$version", &self.package.version)
|
||||
.replace("$specdir", &specdir)
|
||||
.replace("$NYAPM_SPECDIR", &specdir)
|
||||
}
|
||||
|
||||
pub fn sources(&self) -> &[Source] {
|
||||
&self.source
|
||||
}
|
||||
|
||||
/// Apply system configuration overrides and appends
|
||||
pub fn apply_config(&mut self, config: &crate::config::Config) {
|
||||
// Apply build overrides from /etc/depot.d/build.toml
|
||||
self.apply_toml_overrides(&config.build_overrides, "build");
|
||||
|
||||
// Apply appends from /etc/depot.d/build.toml (e.g. build.flags.cflags += ["-O3"])
|
||||
for (key, values) in &config.appends {
|
||||
if let Some(subkey) = key.strip_prefix("build.flags.") {
|
||||
self.apply_append(subkey, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_toml_overrides(&mut self, overrides: &toml::Value, _prefix: &str) {
|
||||
// Support both [build.flags] and top-level [build] fields
|
||||
if let Some(table) = overrides.as_table() {
|
||||
self.apply_flags_table(table);
|
||||
}
|
||||
if let Some(table) = overrides.get("flags").and_then(|f| f.as_table()) {
|
||||
self.apply_flags_table(table);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_flags_table(&mut self, table: &toml::map::Map<String, toml::Value>) {
|
||||
for (k, v) in table {
|
||||
match k.as_str() {
|
||||
"cflags" => {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build.flags.cflags = arr
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.cflags = vec![s.to_string()];
|
||||
}
|
||||
}
|
||||
"ldflags" => {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build.flags.ldflags = arr
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.ldflags = vec![s.to_string()];
|
||||
}
|
||||
}
|
||||
"cc" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
self.build.flags.cc = s.to_string();
|
||||
}
|
||||
}
|
||||
"ar" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
self.build.flags.ar = s.to_string();
|
||||
}
|
||||
}
|
||||
"prefix" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
self.build.flags.prefix = s.to_string();
|
||||
}
|
||||
}
|
||||
"chost" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
self.build.flags.chost = s.to_string();
|
||||
}
|
||||
}
|
||||
"cbuild" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
self.build.flags.cbuild = s.to_string();
|
||||
}
|
||||
}
|
||||
// Add more fields as needed
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_append(&mut self, key: &str, values: &[toml::Value]) {
|
||||
match key {
|
||||
"cflags" => {
|
||||
for v in values {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build
|
||||
.flags
|
||||
.cflags
|
||||
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.cflags.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"ldflags" => {
|
||||
for v in values {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build
|
||||
.flags
|
||||
.ldflags
|
||||
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.ldflags.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"configure" => {
|
||||
for v in values {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build
|
||||
.flags
|
||||
.configure
|
||||
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.configure.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"cargs" => {
|
||||
for v in values {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build
|
||||
.flags
|
||||
.cargs
|
||||
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.cargs.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"rustflags" => {
|
||||
for v in values {
|
||||
if let Some(arr) = v.as_array() {
|
||||
self.build
|
||||
.flags
|
||||
.rustflags
|
||||
.extend(arr.iter().filter_map(|x| x.as_str()).map(String::from));
|
||||
} else if let Some(s) = v.as_str() {
|
||||
self.build.flags.rustflags.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"cc" => {
|
||||
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||
self.build.flags.cc = s.to_string();
|
||||
}
|
||||
}
|
||||
"ar" => {
|
||||
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||
self.build.flags.ar = s.to_string();
|
||||
}
|
||||
}
|
||||
"prefix" => {
|
||||
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||
self.build.flags.prefix = s.to_string();
|
||||
}
|
||||
}
|
||||
"chost" => {
|
||||
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||
self.build.flags.chost = s.to_string();
|
||||
}
|
||||
}
|
||||
"cbuild" => {
|
||||
if let Some(s) = values.last().and_then(|v| v.as_str()) {
|
||||
self.build.flags.cbuild = s.to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spec_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_single_source_table() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("pkg.toml");
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[package]
|
||||
name = "foo"
|
||||
version = "1.0"
|
||||
description = "d"
|
||||
homepage = "h"
|
||||
license = "MIT"
|
||||
|
||||
[source]
|
||||
url = "https://example.com/foo-$version.tar.gz"
|
||||
sha256 = "skip"
|
||||
extract_dir = "foo-$version"
|
||||
|
||||
[build]
|
||||
type = "custom"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let spec = PackageSpec::from_file(&path).unwrap();
|
||||
assert_eq!(spec.package.name, "foo");
|
||||
assert_eq!(spec.sources().len(), 1);
|
||||
assert_eq!(
|
||||
spec.expand_vars(&spec.sources()[0].url),
|
||||
"https://example.com/foo-1.0.tar.gz"
|
||||
);
|
||||
assert!(spec.sources()[0].patches.is_empty());
|
||||
assert!(spec.sources()[0].post_extract.is_empty());
|
||||
assert_eq!(spec.spec_dir, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_source_array() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("pkg.toml");
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[package]
|
||||
name = "foo"
|
||||
version = "1.0"
|
||||
description = "d"
|
||||
homepage = "h"
|
||||
license = "MIT"
|
||||
|
||||
[[source]]
|
||||
url = "https://example.com/foo.tar.gz"
|
||||
sha256 = "skip"
|
||||
extract_dir = "foo"
|
||||
|
||||
[[source]]
|
||||
url = "https://example.com/bar.tar.gz"
|
||||
sha256 = "skip"
|
||||
extract_dir = "bar"
|
||||
|
||||
[build]
|
||||
type = "custom"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let spec = PackageSpec::from_file(&path).unwrap();
|
||||
assert_eq!(spec.sources().len(), 2);
|
||||
assert_eq!(spec.sources()[0].extract_dir, "foo");
|
||||
assert_eq!(spec.sources()[1].extract_dir, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("pkg.toml");
|
||||
|
||||
// `source = []` is not accepted (must have at least one entry)
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[package]
|
||||
name = "foo"
|
||||
version = "1.0"
|
||||
description = "d"
|
||||
homepage = "h"
|
||||
license = "MIT"
|
||||
|
||||
source = []
|
||||
|
||||
[build]
|
||||
type = "custom"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(PackageSpec::from_file(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_config() {
|
||||
let mut spec = mk_spec("foo", "1.0");
|
||||
let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent"));
|
||||
|
||||
// Mock some overrides and appends
|
||||
config.build_overrides = toml::from_str(
|
||||
r#"
|
||||
[flags]
|
||||
cc = "my-cc"
|
||||
cflags = ["-O2"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
config.appends.insert(
|
||||
"build.flags.cflags".to_string(),
|
||||
vec![toml::Value::String("-g".to_string())],
|
||||
);
|
||||
config.appends.insert(
|
||||
"build.flags.rustflags".to_string(),
|
||||
vec![toml::Value::Array(vec![
|
||||
toml::Value::String("-C".to_string()),
|
||||
toml::Value::String("opt-level=3".to_string()),
|
||||
])],
|
||||
);
|
||||
|
||||
spec.apply_config(&config);
|
||||
|
||||
assert_eq!(spec.build.flags.cc, "my-cc");
|
||||
assert!(spec.build.flags.cflags.contains(&"-O2".to_string()));
|
||||
assert!(spec.build.flags.cflags.contains(&"-g".to_string()));
|
||||
assert!(spec.build.flags.rustflags.contains(&"-C".to_string()));
|
||||
assert!(
|
||||
spec.build
|
||||
.flags
|
||||
.rustflags
|
||||
.contains(&"opt-level=3".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chost_cbuild_overrides() {
|
||||
let mut spec = mk_spec("foo", "1.0");
|
||||
let config = crate::config::Config {
|
||||
cache_dir: "/tmp".into(),
|
||||
build_dir: "/tmp".into(),
|
||||
db_dir: "/tmp".into(),
|
||||
build_overrides: toml::from_str(
|
||||
r#"
|
||||
chost = "x86_64-sfg-linux-gnu"
|
||||
cbuild = "x86_64-pc-linux-gnu"
|
||||
"#,
|
||||
)
|
||||
.unwrap(),
|
||||
package_overrides: toml::Value::Table(toml::map::Map::new()),
|
||||
appends: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
spec.apply_config(&config);
|
||||
assert_eq!(spec.build.flags.chost, "x86_64-sfg-linux-gnu");
|
||||
assert_eq!(spec.build.flags.cbuild, "x86_64-pc-linux-gnu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_filename() {
|
||||
let mut spec = mk_spec("foo", "1.0");
|
||||
spec.package.revision = 2;
|
||||
assert_eq!(
|
||||
spec.package_filename("x86_64"),
|
||||
"foo-1.0-2-x86_64.depot.pkg.tar.zst"
|
||||
);
|
||||
}
|
||||
|
||||
fn mk_spec(name: &str, version: &str) -> PackageSpec {
|
||||
PackageSpec {
|
||||
package: PackageInfo {
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
revision: 1,
|
||||
description: "d".into(),
|
||||
homepage: "h".into(),
|
||||
license: "MIT".into(),
|
||||
},
|
||||
alternatives: Alternatives::default(),
|
||||
manual_sources: Vec::new(),
|
||||
source: vec![Source {
|
||||
url: "h".into(),
|
||||
sha256: "s".into(),
|
||||
extract_dir: "e".into(),
|
||||
patches: Vec::new(),
|
||||
post_extract: Vec::new(),
|
||||
}],
|
||||
build: Build {
|
||||
build_type: BuildType::Custom,
|
||||
flags: BuildFlags::default(),
|
||||
},
|
||||
dependencies: Dependencies::default(),
|
||||
spec_dir: PathBuf::from("."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PackageSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
"Package: {} v{}",
|
||||
self.package.name, self.package.version
|
||||
)?;
|
||||
writeln!(f, "Description: {}", self.package.description)?;
|
||||
writeln!(f, "Homepage: {}", self.package.homepage)?;
|
||||
writeln!(f, "License: {}", self.package.license)?;
|
||||
writeln!(f, "Sources: {}", self.source.len())?;
|
||||
writeln!(f, "Build Type: {:?}", self.build.build_type)?;
|
||||
if !self.alternatives.provides.is_empty() {
|
||||
writeln!(f, "Provides: {}", self.alternatives.provides.join(", "))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Package metadata
|
||||
#[derive(Debug, Deserialize, serde::Serialize, Clone)]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
/// Maintenance revision of the package (defaults to 1)
|
||||
#[serde(default = "default_revision")]
|
||||
pub revision: u32,
|
||||
pub description: String,
|
||||
pub homepage: String,
|
||||
pub license: String,
|
||||
}
|
||||
|
||||
fn default_revision() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl PackageSpec {
|
||||
/// Generate the standard package filename: <name>-<version>-<revision>-<arch>.depot.pkg.tar.zst
|
||||
pub fn package_filename(&self, arch: &str) -> String {
|
||||
format!(
|
||||
"{}-{}-{}-{}.depot.pkg.tar.zst",
|
||||
self.package.name, self.package.version, self.package.revision, arch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Package alternatives (provides/replaces)
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Alternatives {
|
||||
#[serde(default)]
|
||||
pub provides: Vec<String>,
|
||||
/// Reserved for future package replacement feature
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub replaces: Vec<String>,
|
||||
}
|
||||
|
||||
/// Source tarball information
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Source {
|
||||
pub url: String,
|
||||
/// SHA256 checksum or "skip" to bypass
|
||||
pub sha256: String,
|
||||
/// Directory name after extraction (supports $name, $version)
|
||||
pub extract_dir: String,
|
||||
|
||||
/// Patch files or URLs to apply after extraction.
|
||||
///
|
||||
/// Example:
|
||||
/// patches = ["fix-build.patch", "<https://example.com/patches/foo.patch>"]
|
||||
#[serde(default)]
|
||||
pub patches: Vec<String>,
|
||||
|
||||
/// Commands to run after extraction (and after patches), executed in the source directory.
|
||||
///
|
||||
/// Example:
|
||||
/// post_extract = ["autoreconf -fi"]
|
||||
#[serde(default)]
|
||||
pub post_extract: Vec<String>,
|
||||
}
|
||||
|
||||
/// Manual (local) source file to copy before fetching remote sources.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ManualSource {
|
||||
/// Filename in the spec directory
|
||||
pub file: String,
|
||||
/// SHA256 checksum (optional, use "skip" to bypass verification)
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
/// Destination path relative to build work directory (default: same as file)
|
||||
#[serde(default)]
|
||||
pub dest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrManySources {
|
||||
One(Source),
|
||||
Many(Vec<Source>),
|
||||
}
|
||||
|
||||
fn deserialize_sources<'de, D>(deserializer: D) -> std::result::Result<Vec<Source>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
// Try to deserialize; if the field is missing/null, return empty vec
|
||||
let parsed = Option::<OneOrManySources>::deserialize(deserializer)?;
|
||||
match parsed {
|
||||
Some(OneOrManySources::One(s)) => Ok(vec![s]),
|
||||
Some(OneOrManySources::Many(v)) => Ok(v),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build configuration
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Build {
|
||||
#[serde(rename = "type")]
|
||||
pub build_type: BuildType,
|
||||
#[serde(default)]
|
||||
pub flags: BuildFlags,
|
||||
}
|
||||
|
||||
/// Supported build systems
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BuildType {
|
||||
Autotools,
|
||||
CMake,
|
||||
Meson,
|
||||
Custom,
|
||||
Rust,
|
||||
/// Binary distribution installer (e.g., .deb, .rpm)
|
||||
Bin,
|
||||
}
|
||||
|
||||
/// Build flags and toolchain configuration
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct BuildFlags {
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_array")]
|
||||
pub cflags: Vec<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_array")]
|
||||
pub ldflags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub configure: Vec<String>,
|
||||
/// C compiler
|
||||
#[serde(default = "default_cc")]
|
||||
pub cc: String,
|
||||
/// Archiver
|
||||
#[serde(default = "default_ar")]
|
||||
pub ar: String,
|
||||
/// Dynamic loader path
|
||||
#[serde(default)]
|
||||
pub libc: String,
|
||||
/// Root filesystem for installation (per-package override)
|
||||
#[serde(default = "default_rootfs")]
|
||||
#[allow(dead_code)]
|
||||
pub rootfs: String,
|
||||
/// Commands to run after compile (after make, before make install)
|
||||
#[serde(default)]
|
||||
pub post_compile: Vec<String>,
|
||||
/// Commands to run after install (after make install)
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
|
||||
/// Installation prefix (default: /usr)
|
||||
#[serde(default = "default_prefix")]
|
||||
pub prefix: String,
|
||||
|
||||
/// Target architecture triple (CHOST equivalent)
|
||||
#[serde(default)]
|
||||
pub chost: String,
|
||||
|
||||
/// Build architecture triple (CBUILD equivalent)
|
||||
#[serde(default)]
|
||||
pub cbuild: String,
|
||||
|
||||
// Rust-specific fields
|
||||
/// Rust build profile: "debug" or "release" (default: release)
|
||||
#[serde(default = "default_profile")]
|
||||
pub profile: String,
|
||||
/// Rust target triple (e.g., x86_64-unknown-linux-musl). Optional.
|
||||
#[serde(default)]
|
||||
pub target: String,
|
||||
/// RUSTFLAGS environment variable
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_array")]
|
||||
pub rustflags: Vec<String>,
|
||||
/// Additional cargo arguments (short name)
|
||||
#[serde(default)]
|
||||
pub cargs: Vec<String>,
|
||||
/// Binary installation directory relative to DESTDIR (default: /usr/bin)
|
||||
#[serde(default = "default_bindir")]
|
||||
pub bindir: String,
|
||||
|
||||
/// Subdirectory within extracted source to use as the actual source root.
|
||||
/// Useful for monorepos like llvm-project where you want to build just one component.
|
||||
#[serde(default)]
|
||||
pub source_subdir: String,
|
||||
/// Binary package type when using BuildType::Bin (e.g. "deb")
|
||||
#[serde(default)]
|
||||
pub binary_type: String,
|
||||
}
|
||||
|
||||
fn deserialize_string_or_array<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrArray {
|
||||
String(String),
|
||||
Array(Vec<String>),
|
||||
}
|
||||
|
||||
match Option::<StringOrArray>::deserialize(deserializer)? {
|
||||
Some(StringOrArray::String(s)) => Ok(s.split_whitespace().map(String::from).collect()),
|
||||
Some(StringOrArray::Array(a)) => Ok(a),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_cc() -> String {
|
||||
// Prefer clang if available (supports -print-resource-dir and other useful flags)
|
||||
if std::process::Command::new("which")
|
||||
.arg("clang")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return "clang".to_string();
|
||||
}
|
||||
"gcc".to_string()
|
||||
}
|
||||
|
||||
fn default_ar() -> String {
|
||||
"ar".to_string()
|
||||
}
|
||||
|
||||
fn default_rootfs() -> String {
|
||||
"/".to_string()
|
||||
}
|
||||
|
||||
fn default_profile() -> String {
|
||||
"release".to_string()
|
||||
}
|
||||
|
||||
fn default_bindir() -> String {
|
||||
"/usr/bin".to_string()
|
||||
}
|
||||
|
||||
fn default_prefix() -> String {
|
||||
"/usr".to_string()
|
||||
}
|
||||
|
||||
/// Package dependencies
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Dependencies {
|
||||
#[serde(default)]
|
||||
pub build: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub runtime: Vec<String>,
|
||||
}
|
||||
Executable
+495
@@ -0,0 +1,495 @@
|
||||
//! Archive extraction support
|
||||
|
||||
use crate::package::{PackageSpec, Source};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use flate2::read::GzDecoder;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::os::unix::fs as unix_fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use zstd::stream::read::Decoder as ZstdDecoder;
|
||||
|
||||
/// Extract an archive source to the build directory.
|
||||
pub fn extract_archive(
|
||||
archive_path: &Path,
|
||||
spec: &PackageSpec,
|
||||
source: &Source,
|
||||
build_dir: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let extract_dir_name = spec.expand_vars(&source.extract_dir);
|
||||
let extract_path = build_dir.join(&extract_dir_name);
|
||||
|
||||
// Create build directory
|
||||
fs::create_dir_all(build_dir)
|
||||
.with_context(|| format!("Failed to create build dir: {}", build_dir.display()))?;
|
||||
|
||||
// Remove existing extraction if present
|
||||
if extract_path.exists() {
|
||||
fs::remove_dir_all(&extract_path)?;
|
||||
}
|
||||
|
||||
let filename = archive_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
println!("Extracting: {}", filename);
|
||||
|
||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
extract_tar_gz(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||
extract_tar_xz(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") {
|
||||
extract_tar_bz2(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tzst") {
|
||||
extract_tar_zst(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".zip") {
|
||||
extract_zip(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".tar") {
|
||||
extract_tar(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".deb") {
|
||||
extract_deb(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".rpm") {
|
||||
extract_rpm(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".gz") {
|
||||
extract_gz_file(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".xz") {
|
||||
extract_xz_file(archive_path, build_dir)?;
|
||||
} else if filename.ends_with(".zst") {
|
||||
extract_zst_file(archive_path, build_dir)?;
|
||||
} else {
|
||||
bail!("Unsupported archive format: {}", filename);
|
||||
}
|
||||
|
||||
if !extract_path.exists() {
|
||||
bail!(
|
||||
"Expected extraction directory not found: {}",
|
||||
extract_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("Extracted to: {}", extract_path.display());
|
||||
Ok(extract_path)
|
||||
}
|
||||
|
||||
fn extract_tar_gz(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_xz(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let decoder = xz2::read::XzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_bz2(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let decoder = bzip2::read::BzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
archive.extract(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_zst(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let decoder = ZstdDecoder::new(file)?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_gz_file(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let mut decoder = GzDecoder::new(file);
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("output");
|
||||
|
||||
let out_name = if let Some(stripped) = filename.strip_suffix(".gz") {
|
||||
stripped
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
|
||||
fs::create_dir_all(dest)?;
|
||||
let mut out = File::create(dest.join(out_name))?;
|
||||
std::io::copy(&mut decoder, &mut out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_xz_file(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let mut decoder = xz2::read::XzDecoder::new(file);
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("output");
|
||||
|
||||
let out_name = if let Some(stripped) = filename.strip_suffix(".xz") {
|
||||
stripped
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
|
||||
fs::create_dir_all(dest)?;
|
||||
let mut out = File::create(dest.join(out_name))?;
|
||||
std::io::copy(&mut decoder, &mut out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_zst_file(path: &Path, dest: &Path) -> Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let mut decoder = ZstdDecoder::new(file)?;
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("output");
|
||||
|
||||
let out_name = if let Some(stripped) = filename.strip_suffix(".zst") {
|
||||
stripped
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
|
||||
fs::create_dir_all(dest)?;
|
||||
let mut out = File::create(dest.join(out_name))?;
|
||||
std::io::copy(&mut decoder, &mut out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_deb(path: &Path, dest: &Path) -> Result<()> {
|
||||
// Debian packages are ar archives containing (among others) a data.tar.* member
|
||||
let file = File::open(path)?;
|
||||
let mut ar = ar::Archive::new(file);
|
||||
|
||||
// Iterate members and look for data.tar, data.tar.gz, data.tar.xz, data.tar.zst
|
||||
while let Some(entry_result) = ar.next_entry() {
|
||||
let entry = entry_result?;
|
||||
let id = String::from_utf8_lossy(entry.header().identifier()).to_string();
|
||||
let lower = id.to_ascii_lowercase();
|
||||
if lower.starts_with("data.tar") {
|
||||
// Determine compression
|
||||
if lower.ends_with(".gz") {
|
||||
let decoder = GzDecoder::new(entry);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
return Ok(());
|
||||
} else if lower.ends_with(".xz") {
|
||||
let decoder = xz2::read::XzDecoder::new(entry);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
return Ok(());
|
||||
} else if lower.ends_with(".zst") {
|
||||
let decoder = ZstdDecoder::new(entry)?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)?;
|
||||
return Ok(());
|
||||
} else {
|
||||
// plain tar
|
||||
let mut archive = tar::Archive::new(entry);
|
||||
archive.unpack(dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No data member found
|
||||
anyhow::bail!("No data.tar.* member found in deb: {}", path.display());
|
||||
}
|
||||
|
||||
fn extract_cpio_newc_from_reader<R: Read>(mut r: R, dest: &Path) -> Result<()> {
|
||||
use std::str;
|
||||
loop {
|
||||
// read 6-byte magic
|
||||
let mut magic = [0u8; 6];
|
||||
if let Err(e) = r.read_exact(&mut magic) {
|
||||
// EOF
|
||||
return Err(e).with_context(|| "Failed reading cpio magic")?;
|
||||
}
|
||||
if &magic != b"070701" {
|
||||
anyhow::bail!("Unsupported cpio magic: {:?}", &magic);
|
||||
}
|
||||
|
||||
// read remaining 104 bytes of header (total header is 110)
|
||||
let mut rest = [0u8; 104];
|
||||
r.read_exact(&mut rest)?;
|
||||
let header_str = str::from_utf8(&rest).context("Invalid cpio header encoding")?;
|
||||
// parse 13 hex fields of 8 chars each
|
||||
if header_str.len() < 8 * 13 {
|
||||
anyhow::bail!("Truncated cpio header");
|
||||
}
|
||||
let mut fields = [0u64; 13];
|
||||
for i in 0..13 {
|
||||
let part = &header_str[i * 8..i * 8 + 8];
|
||||
fields[i] = u64::from_str_radix(part, 16)
|
||||
.with_context(|| format!("Invalid header field hex: {}", part))?;
|
||||
}
|
||||
|
||||
let namesize = fields[11] as usize;
|
||||
let filesize = fields[6] as usize;
|
||||
|
||||
// read name
|
||||
let mut name_buf = vec![0u8; namesize];
|
||||
r.read_exact(&mut name_buf)?;
|
||||
let name = match name_buf.iter().position(|&b| b == 0) {
|
||||
Some(p) => String::from_utf8_lossy(&name_buf[..p]).to_string(),
|
||||
None => String::from_utf8_lossy(&name_buf).to_string(),
|
||||
};
|
||||
|
||||
// skip header padding to 4 bytes
|
||||
let header_total = 110 + namesize;
|
||||
let header_pad = (4 - (header_total % 4)) % 4;
|
||||
if header_pad > 0 {
|
||||
let mut tmp = vec![0u8; header_pad];
|
||||
r.read_exact(&mut tmp)?;
|
||||
}
|
||||
|
||||
if name == "TRAILER!!!" {
|
||||
break;
|
||||
}
|
||||
|
||||
let mode = fields[1];
|
||||
let file_path = dest.join(&name);
|
||||
|
||||
if (mode & 0o170000) == 0o040000 {
|
||||
// directory
|
||||
fs::create_dir_all(&file_path)?;
|
||||
} else if (mode & 0o170000) == 0o120000 {
|
||||
// symlink
|
||||
let mut link_buf = vec![0u8; filesize];
|
||||
r.read_exact(&mut link_buf)?;
|
||||
let target = String::from_utf8_lossy(&link_buf).to_string();
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = fs::remove_file(&file_path);
|
||||
unix_fs::symlink(target, &file_path)?;
|
||||
} else {
|
||||
// regular file
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out = File::create(&file_path)?;
|
||||
let mut remaining = filesize;
|
||||
let mut buf = [0u8; 8192];
|
||||
while remaining > 0 {
|
||||
let to_read = std::cmp::min(remaining, buf.len());
|
||||
let n = r.read(&mut buf[..to_read])?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
out.write_all(&buf[..n])?;
|
||||
remaining -= n;
|
||||
}
|
||||
// set permissions
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(
|
||||
&file_path,
|
||||
fs::Permissions::from_mode((mode & 0o7777) as u32),
|
||||
)?;
|
||||
}
|
||||
|
||||
// skip file data padding
|
||||
let data_pad = (4 - (filesize % 4)) % 4;
|
||||
if data_pad > 0 {
|
||||
let mut tmp = vec![0u8; data_pad];
|
||||
r.read_exact(&mut tmp)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_rpm(path: &Path, dest: &Path) -> Result<()> {
|
||||
// Read entire file and search for compression/cpio magic
|
||||
let data = std::fs::read(path)?;
|
||||
|
||||
// search for known signatures
|
||||
let gz_sig = b"\x1f\x8b";
|
||||
let xz_sig = b"\xfd7zXZ\x00";
|
||||
let zst_sig = b"\x28\xb5\x2f\xfd";
|
||||
let cpio_sig = b"070701";
|
||||
|
||||
if let Some(pos) = find_subslice(&data, gz_sig) {
|
||||
let cursor = Cursor::new(&data[pos..]);
|
||||
let decoder = GzDecoder::new(cursor);
|
||||
extract_cpio_newc_from_reader(decoder, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(pos) = find_subslice(&data, xz_sig) {
|
||||
let cursor = Cursor::new(&data[pos..]);
|
||||
let decoder = xz2::read::XzDecoder::new(cursor);
|
||||
extract_cpio_newc_from_reader(decoder, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(pos) = find_subslice(&data, zst_sig) {
|
||||
let cursor = Cursor::new(&data[pos..]);
|
||||
let decoder = ZstdDecoder::new(cursor)?;
|
||||
extract_cpio_newc_from_reader(decoder, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(pos) = find_subslice(&data, cpio_sig) {
|
||||
let cursor = Cursor::new(&data[pos..]);
|
||||
extract_cpio_newc_from_reader(cursor, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"No recognizable cpio payload found in rpm: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_extract_deb_roundtrip() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let deb_path = tmp.path().join("test.deb");
|
||||
let extract_dir = tmp.path().join("out-deb");
|
||||
|
||||
// create a small tar.gz payload with one file
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let gz = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default());
|
||||
let mut tar = tar::Builder::new(gz);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
let data = b"hello-deb";
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, "usr/bin/hello-deb", &data[..])
|
||||
.unwrap();
|
||||
tar.finish().unwrap();
|
||||
}
|
||||
|
||||
// write ar archive with member data.tar.gz (use temp files because ar::Builder expects File)
|
||||
{
|
||||
let db = tmp.path().join("debian-binary");
|
||||
let ct = tmp.path().join("control.tar.gz");
|
||||
let dt = tmp.path().join("data.tar.gz");
|
||||
std::fs::write(&db, b"2.0\n").unwrap();
|
||||
std::fs::write(&ct, b"").unwrap();
|
||||
std::fs::write(&dt, &tar_buf[..]).unwrap();
|
||||
|
||||
let mut f = File::create(&deb_path).unwrap();
|
||||
let mut builder = ar::Builder::new(&mut f);
|
||||
let mut dbf = File::open(&db).unwrap();
|
||||
let mut ctf = File::open(&ct).unwrap();
|
||||
let mut dtf = File::open(&dt).unwrap();
|
||||
builder.append_file(b"debian-binary", &mut dbf).unwrap();
|
||||
builder.append_file(b"control.tar.gz", &mut ctf).unwrap();
|
||||
builder.append_file(b"data.tar.gz", &mut dtf).unwrap();
|
||||
}
|
||||
|
||||
fs::create_dir_all(&extract_dir).unwrap();
|
||||
extract_deb(&deb_path, &extract_dir).unwrap();
|
||||
assert!(extract_dir.join("usr/bin/hello-deb").exists());
|
||||
}
|
||||
|
||||
fn write_cpio_newc_one_file(w: &mut Vec<u8>, name: &str, data: &[u8]) {
|
||||
// magic + 13 fields of 8 hex chars each
|
||||
fn h8(v: u64) -> String {
|
||||
format!("{:08x}", v)
|
||||
}
|
||||
let namesize = name.len() + 1;
|
||||
let filesize = data.len();
|
||||
let mut header = Vec::new();
|
||||
header.extend_from_slice(b"070701");
|
||||
// ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, check
|
||||
header.extend_from_slice(h8(0).as_bytes()); // ino
|
||||
header.extend_from_slice(h8(0o100644).as_bytes()); // mode regular file with perms
|
||||
header.extend_from_slice(h8(0).as_bytes()); // uid
|
||||
header.extend_from_slice(h8(0).as_bytes()); // gid
|
||||
header.extend_from_slice(h8(1).as_bytes()); // nlink
|
||||
header.extend_from_slice(h8(0).as_bytes()); // mtime
|
||||
header.extend_from_slice(h8(filesize as u64).as_bytes());
|
||||
header.extend_from_slice(h8(0).as_bytes()); // devmajor
|
||||
header.extend_from_slice(h8(0).as_bytes()); // devminor
|
||||
header.extend_from_slice(h8(0).as_bytes()); // rdevmajor
|
||||
header.extend_from_slice(h8(0).as_bytes()); // rdevminor
|
||||
header.extend_from_slice(h8(namesize as u64).as_bytes());
|
||||
header.extend_from_slice(h8(0).as_bytes()); // check
|
||||
w.extend_from_slice(&header);
|
||||
w.extend_from_slice(name.as_bytes());
|
||||
w.push(0);
|
||||
// pad to 4
|
||||
let pad = (4 - ((110 + namesize) % 4)) % 4;
|
||||
for _ in 0..pad {
|
||||
w.push(0);
|
||||
}
|
||||
// file data
|
||||
w.extend_from_slice(data);
|
||||
let dpad = (4 - (filesize % 4)) % 4;
|
||||
for _ in 0..dpad {
|
||||
w.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cpio_trailer(w: &mut Vec<u8>) {
|
||||
write_cpio_newc_one_file(w, "TRAILER!!!", &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_rpm_roundtrip() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let rpm_path = tmp.path().join("test.rpm");
|
||||
let extract_dir = tmp.path().join("out-rpm");
|
||||
|
||||
// build cpio newc stream with one file
|
||||
let mut cpio = Vec::new();
|
||||
write_cpio_newc_one_file(&mut cpio, "usr/bin/hello-rpm", b"hello-rpm");
|
||||
write_cpio_trailer(&mut cpio);
|
||||
|
||||
// gzip compress
|
||||
let mut gz = Vec::new();
|
||||
{
|
||||
let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
|
||||
enc.write_all(&cpio).unwrap();
|
||||
enc.finish().unwrap();
|
||||
}
|
||||
|
||||
// write fake rpm: some header bytes then gz payload
|
||||
{
|
||||
let mut f = File::create(&rpm_path).unwrap();
|
||||
f.write_all(b"RPMHEAD").unwrap();
|
||||
f.write_all(&gz).unwrap();
|
||||
}
|
||||
|
||||
fs::create_dir_all(&extract_dir).unwrap();
|
||||
extract_rpm(&rpm_path, &extract_dir).unwrap();
|
||||
assert!(extract_dir.join("usr/bin/hello-rpm").exists());
|
||||
}
|
||||
}
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
//! Source tarball fetching with checksum verification
|
||||
|
||||
use crate::package::{PackageSpec, Source};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use url::Url;
|
||||
|
||||
/// Fetch an archive source tarball, returning path to downloaded file.
|
||||
pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> Result<PathBuf> {
|
||||
let url = spec.expand_vars(&source.url);
|
||||
let filename = derive_filename_from_url(&url);
|
||||
let dest_path = cache_dir.join(&filename);
|
||||
|
||||
// Create cache directory if needed
|
||||
fs::create_dir_all(cache_dir)
|
||||
.with_context(|| format!("Failed to create cache dir: {}", cache_dir.display()))?;
|
||||
|
||||
// Check if already cached and verified
|
||||
if dest_path.exists() && verify_checksum(&dest_path, &source.sha256)? {
|
||||
println!("Using cached source: {}", dest_path.display());
|
||||
return Ok(dest_path);
|
||||
}
|
||||
|
||||
println!("Fetching: {}", url);
|
||||
|
||||
// Download with progress bar
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.with_context(|| format!("Failed to fetch: {}", url))?;
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let mut file = File::create(&dest_path)
|
||||
.with_context(|| format!("Failed to create: {}", dest_path.display()))?;
|
||||
|
||||
let mut buffer = [0u8; 8192];
|
||||
let mut downloaded = 0u64;
|
||||
|
||||
loop {
|
||||
let bytes_read = response.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(&buffer[..bytes_read])?;
|
||||
downloaded += bytes_read as u64;
|
||||
pb.set_position(downloaded);
|
||||
}
|
||||
|
||||
pb.finish_with_message("Download complete");
|
||||
|
||||
// Verify checksum
|
||||
if !verify_checksum(&dest_path, &source.sha256)? {
|
||||
fs::remove_file(&dest_path)?;
|
||||
bail!("Checksum verification failed for {}", filename);
|
||||
}
|
||||
|
||||
println!("Checksum verified: {}", filename);
|
||||
Ok(dest_path)
|
||||
}
|
||||
|
||||
/// Verify SHA256 checksum of a file
|
||||
fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
|
||||
// Skip verification if requested
|
||||
if expected.to_lowercase() == "skip" {
|
||||
println!("Checksum verification skipped");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
let bytes_read = file.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
let result = hasher.finalize();
|
||||
let actual = format!("{:x}", result);
|
||||
|
||||
Ok(actual == expected.to_lowercase())
|
||||
}
|
||||
|
||||
/// Derive a stable filename from a URL.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Parse as URL and use the last path segment if it looks like a filename (contains a dot)
|
||||
/// - Otherwise fall back to a stable hash-based name: source-{sha256(url)[..12]}.download
|
||||
fn derive_filename_from_url(url: &str) -> String {
|
||||
// try to parse the URL
|
||||
if let Some(last) = Url::parse(url).ok().and_then(|parsed| {
|
||||
parsed
|
||||
.path_segments()?
|
||||
.rfind(|s| !s.is_empty())
|
||||
.filter(|l| l.contains('.'))
|
||||
.map(|l| l.to_string())
|
||||
}) {
|
||||
return last;
|
||||
}
|
||||
|
||||
// fallback: stable short-hash name
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let h = hasher.finalize();
|
||||
let hex = format!("{:x}", h);
|
||||
format!("source-{}.download", &hex[..12])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filename_from_simple_url() {
|
||||
assert_eq!(
|
||||
derive_filename_from_url("https://example.com/foo-1.2.3.tar.gz"),
|
||||
"foo-1.2.3.tar.gz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_from_url_with_query() {
|
||||
assert_eq!(
|
||||
derive_filename_from_url("https://example.com/foo.tar.gz?raw=1"),
|
||||
"foo.tar.gz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_from_url_without_real_name() {
|
||||
let name = derive_filename_from_url("https://github.com/org/repo/releases/download?id=123");
|
||||
assert!(name.starts_with("source-") && name.ends_with(".download"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_from_non_url_string() {
|
||||
let name = derive_filename_from_url("not-a-url-at-all");
|
||||
assert!(name.starts_with("source-") && name.ends_with(".download"));
|
||||
}
|
||||
}
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
//! Git source support via libgit2 (git2 crate)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use git2::{Cred, FetchOptions, Oid, RemoteCallbacks, Repository};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Checkout a repository URL at a specific revision into `checkout_dir`.
|
||||
///
|
||||
/// The URL is expected to be the base URL (without the `#rev` fragment).
|
||||
/// The revision may be a tag name, branch name, or commit hash.
|
||||
///
|
||||
/// Repositories are mirrored under `git_cache_dir` to avoid repeated network fetches.
|
||||
pub fn checkout(
|
||||
url: &str,
|
||||
rev: &str,
|
||||
checkout_dir: &Path,
|
||||
git_cache_dir: &Path,
|
||||
pkgname: &str,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(git_cache_dir)
|
||||
.with_context(|| format!("Failed to create git cache dir: {}", git_cache_dir.display()))?;
|
||||
|
||||
let mirror_dir = git_cache_dir.join(mirror_key(url));
|
||||
ensure_mirror(url, &mirror_dir, pkgname)?;
|
||||
|
||||
if checkout_dir.exists() {
|
||||
fs::remove_dir_all(checkout_dir)
|
||||
.with_context(|| format!("Failed to remove existing checkout: {}", checkout_dir.display()))?;
|
||||
}
|
||||
|
||||
// Clone from local mirror for speed.
|
||||
let mirror_url = mirror_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid mirror path"))?;
|
||||
|
||||
println!("Cloning git source into {}...", checkout_dir.display());
|
||||
Repository::clone(mirror_url, checkout_dir)
|
||||
.with_context(|| format!("Failed to clone from mirror for {}", url))?;
|
||||
|
||||
let repo = Repository::open(checkout_dir)?;
|
||||
checkout_rev(&repo, rev)
|
||||
.with_context(|| format!("Failed to checkout revision '{}'", rev))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mirror_key(url: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str) -> Result<()> {
|
||||
if !mirror_dir.exists() {
|
||||
println!("Cloning git mirror for {} ({})...", pkgname, url);
|
||||
let mut fo = FetchOptions::new();
|
||||
fo.remote_callbacks(remote_callbacks());
|
||||
let mut builder = git2::build::RepoBuilder::new();
|
||||
builder.fetch_options(fo);
|
||||
builder.bare(true);
|
||||
builder
|
||||
.clone(url, mirror_dir)
|
||||
.with_context(|| format!("Failed to clone git mirror: {}", url))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fetch updates
|
||||
let repo = Repository::open_bare(mirror_dir)
|
||||
.with_context(|| format!("Failed to open git mirror: {}", mirror_dir.display()))?;
|
||||
|
||||
let mut remote = repo
|
||||
.find_remote("origin")
|
||||
.or_else(|_| repo.remote_anonymous(url))
|
||||
.with_context(|| format!("Failed to create remote for {}", url))?;
|
||||
|
||||
let mut fo = FetchOptions::new();
|
||||
fo.remote_callbacks(remote_callbacks());
|
||||
|
||||
// Fetch all remote refs (tags + heads). Empty refspec uses default.
|
||||
remote
|
||||
.fetch(&[] as &[&str], Some(&mut fo), None)
|
||||
.with_context(|| format!("Failed to fetch updates for {}", url))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checkout_rev(repo: &Repository, rev: &str) -> Result<()> {
|
||||
// Try a few reasonable ways to resolve the rev.
|
||||
let obj = repo
|
||||
.revparse_single(rev)
|
||||
.or_else(|_| repo.revparse_single(&format!("refs/tags/{rev}")))
|
||||
.or_else(|_| repo.revparse_single(&format!("refs/heads/{rev}")))
|
||||
.with_context(|| format!("Could not resolve git rev: {}", rev))?;
|
||||
|
||||
// Peel tags to commit if needed.
|
||||
let commit = obj
|
||||
.peel_to_commit()
|
||||
.with_context(|| format!("Could not peel rev to commit: {}", rev))?;
|
||||
|
||||
let oid: Oid = commit.id();
|
||||
repo.set_head_detached(oid)?;
|
||||
repo.checkout_tree(commit.as_object(), None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_callbacks() -> RemoteCallbacks<'static> {
|
||||
let mut callbacks = RemoteCallbacks::new();
|
||||
|
||||
// Try SSH agent / default key locations.
|
||||
callbacks.credentials(|_url, username_from_url, _allowed| {
|
||||
if let Some(name) = username_from_url {
|
||||
Cred::ssh_key_from_agent(name)
|
||||
} else {
|
||||
Cred::default()
|
||||
}
|
||||
});
|
||||
|
||||
callbacks
|
||||
}
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
//! Post-extraction hooks: apply patches and run commands
|
||||
|
||||
use crate::package::{PackageSpec, Source};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Apply patches and run `post_extract` commands in the extracted source tree.
|
||||
pub fn post_extract(
|
||||
spec: &PackageSpec,
|
||||
source: &Source,
|
||||
src_dir: &Path,
|
||||
cache_dir: &Path,
|
||||
) -> Result<()> {
|
||||
apply_patches(spec, source, src_dir, cache_dir)?;
|
||||
run_post_extract_commands(spec, source, src_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_patches(
|
||||
spec: &PackageSpec,
|
||||
source: &Source,
|
||||
src_dir: &Path,
|
||||
cache_dir: &Path,
|
||||
) -> Result<()> {
|
||||
if source.patches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let patch_cache_dir = cache_dir.join("patches").join(&spec.package.name);
|
||||
fs::create_dir_all(&patch_cache_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create patch cache dir: {}",
|
||||
patch_cache_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("Applying {} patch(es)...", source.patches.len());
|
||||
|
||||
for p in &source.patches {
|
||||
let p = spec.expand_vars(p);
|
||||
let patch_path = resolve_patch_path(spec, &p, &patch_cache_dir)?;
|
||||
|
||||
println!(" patch: {}", patch_path.display());
|
||||
|
||||
// Apply with patch(1). Keep it simple: -p1 is the common case.
|
||||
let status = Command::new("patch")
|
||||
.current_dir(src_dir)
|
||||
.arg("-p1")
|
||||
.arg("-i")
|
||||
.arg(&patch_path)
|
||||
.status()
|
||||
.with_context(|| format!("Failed to execute patch for {}", patch_path.display()))?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Patch failed: {}", patch_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path) -> Result<()> {
|
||||
if source.post_extract.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Running {} post-extract command(s)...",
|
||||
source.post_extract.len()
|
||||
);
|
||||
|
||||
for cmd in &source.post_extract {
|
||||
let cmd = spec.expand_vars(cmd);
|
||||
println!(" post_extract: {}", cmd);
|
||||
|
||||
// Use a shell for convenience; this is a package manager, so specs are trusted input.
|
||||
let status = Command::new("sh")
|
||||
.current_dir(src_dir)
|
||||
.env("NYAPM_SPECDIR", &spec.spec_dir)
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run post_extract command: {}", cmd))?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("post_extract command failed: {}", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run post-compile commands (after make, before make install).
|
||||
pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> {
|
||||
let commands = &spec.build.flags.post_compile;
|
||||
if commands.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Running {} post-compile command(s)...", commands.len());
|
||||
|
||||
for cmd in commands {
|
||||
let cmd = spec.expand_vars(cmd);
|
||||
println!(" post_compile: {}", cmd);
|
||||
|
||||
let status = Command::new("sh")
|
||||
.current_dir(src_dir)
|
||||
.env("NYAPM_SPECDIR", &spec.spec_dir)
|
||||
.env("DESTDIR", destdir)
|
||||
.env("CC", &spec.build.flags.cc)
|
||||
.env("AR", &spec.build.flags.ar)
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run post_compile command: {}", cmd))?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("post_compile command failed: {}", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run post-install commands (after make install).
|
||||
pub fn run_post_install_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> {
|
||||
let commands = &spec.build.flags.post_install;
|
||||
if commands.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Running {} post-install command(s)...", commands.len());
|
||||
|
||||
for cmd in commands {
|
||||
let cmd = spec.expand_vars(cmd);
|
||||
println!(" post_install: {}", cmd);
|
||||
|
||||
let status = Command::new("sh")
|
||||
.current_dir(src_dir)
|
||||
.env("NYAPM_SPECDIR", &spec.spec_dir)
|
||||
.env("DESTDIR", destdir)
|
||||
.env("CC", &spec.build.flags.cc)
|
||||
.env("AR", &spec.build.flags.ar)
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run post_install command: {}", cmd))?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("post_install command failed: {}", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_patch_path(spec: &PackageSpec, patch: &str, patch_cache_dir: &Path) -> Result<PathBuf> {
|
||||
if patch.starts_with("http://") || patch.starts_with("https://") {
|
||||
let filename = patch
|
||||
.split('/')
|
||||
.rfind(|s| !s.is_empty())
|
||||
.unwrap_or("patch.patch");
|
||||
let dest = patch_cache_dir.join(filename);
|
||||
if dest.exists() {
|
||||
return Ok(dest);
|
||||
}
|
||||
download(patch, &dest).with_context(|| format!("Failed to download patch: {}", patch))?;
|
||||
return Ok(dest);
|
||||
}
|
||||
|
||||
// Treat as local path, relative to the spec file.
|
||||
let local = spec.spec_dir.join(patch);
|
||||
if !local.exists() {
|
||||
bail!(
|
||||
"Patch not found: {} (resolved to {})",
|
||||
patch,
|
||||
local.display()
|
||||
);
|
||||
}
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
fn download(url: &str, dest: &Path) -> Result<()> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.with_context(|| format!("Failed to fetch: {}", url))?;
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-")
|
||||
);
|
||||
|
||||
let mut file =
|
||||
fs::File::create(dest).with_context(|| format!("Failed to create: {}", dest.display()))?;
|
||||
|
||||
let mut buffer = [0u8; 8192];
|
||||
let mut downloaded = 0u64;
|
||||
|
||||
loop {
|
||||
let bytes_read = response.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(&buffer[..bytes_read])?;
|
||||
downloaded += bytes_read as u64;
|
||||
pb.set_position(downloaded);
|
||||
}
|
||||
|
||||
pb.finish_with_message("Patch download complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_patch_path;
|
||||
use crate::package::{
|
||||
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
|
||||
};
|
||||
|
||||
fn dummy_spec(spec_dir: &std::path::Path) -> PackageSpec {
|
||||
PackageSpec {
|
||||
package: PackageInfo {
|
||||
name: "foo".into(),
|
||||
version: "1.0".into(),
|
||||
revision: 1,
|
||||
description: "d".into(),
|
||||
homepage: "h".into(),
|
||||
license: "MIT".into(),
|
||||
},
|
||||
alternatives: Alternatives::default(),
|
||||
manual_sources: Vec::new(),
|
||||
source: vec![Source {
|
||||
url: "https://example.com/foo.tar.gz".into(),
|
||||
sha256: "skip".into(),
|
||||
extract_dir: "foo".into(),
|
||||
patches: Vec::new(),
|
||||
post_extract: Vec::new(),
|
||||
}],
|
||||
build: Build {
|
||||
build_type: BuildType::Custom,
|
||||
flags: BuildFlags::default(),
|
||||
},
|
||||
dependencies: Dependencies::default(),
|
||||
spec_dir: spec_dir.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_patch_relative_to_spec_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let spec_dir = tmp.path().join("spec");
|
||||
std::fs::create_dir_all(&spec_dir).unwrap();
|
||||
let patch_path = spec_dir.join("fix.patch");
|
||||
std::fs::write(&patch_path, "diff --git a/a b/a\n").unwrap();
|
||||
|
||||
let spec = dummy_spec(&spec_dir);
|
||||
let cache = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
let resolved = resolve_patch_path(&spec, "fix.patch", &cache).unwrap();
|
||||
assert_eq!(resolved, patch_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_patch_url_uses_cached_file_if_present() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let spec_dir = tmp.path().join("spec");
|
||||
std::fs::create_dir_all(&spec_dir).unwrap();
|
||||
let spec = dummy_spec(&spec_dir);
|
||||
|
||||
let cache = tmp.path().join("cache");
|
||||
let patch_cache_dir = cache.join("patches").join("foo");
|
||||
std::fs::create_dir_all(&patch_cache_dir).unwrap();
|
||||
|
||||
// URL-derived filename is the last segment.
|
||||
let cached = patch_cache_dir.join("fix.patch");
|
||||
std::fs::write(&cached, "dummy").unwrap();
|
||||
|
||||
let url = "https://example.com/fix.patch";
|
||||
let resolved = resolve_patch_path(&spec, url, &patch_cache_dir).unwrap();
|
||||
assert_eq!(resolved, cached);
|
||||
}
|
||||
}
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
//! Source fetching and extraction
|
||||
|
||||
mod extractor;
|
||||
mod fetcher;
|
||||
mod git;
|
||||
pub mod hooks;
|
||||
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs as unix_fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Copy manual (local) sources to the build directory before fetching remote sources.
|
||||
///
|
||||
/// Manual sources are checked first, allowing local files like utility source code
|
||||
/// to be available during the build process.
|
||||
pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> {
|
||||
if spec.manual_sources.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(build_dir)?;
|
||||
println!("Copying {} manual source(s)...", spec.manual_sources.len());
|
||||
|
||||
for manual in &spec.manual_sources {
|
||||
let src_path = spec.spec_dir.join(&manual.file);
|
||||
if !src_path.exists() {
|
||||
bail!(
|
||||
"Manual source not found: {} (expected at {})",
|
||||
manual.file,
|
||||
src_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Verify SHA256 if provided (unless "skip")
|
||||
if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") {
|
||||
let actual_hash = compute_sha256(&src_path)?;
|
||||
if actual_hash != *expected_hash {
|
||||
bail!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
manual.file,
|
||||
expected_hash,
|
||||
actual_hash
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine destination
|
||||
let dest_name = manual.dest.as_deref().unwrap_or(&manual.file);
|
||||
let dest_path = build_dir.join(dest_name);
|
||||
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
println!(" {} -> {}", manual.file, dest_path.display());
|
||||
fs::copy(&src_path, &dest_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to copy {} to {}",
|
||||
src_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_sha256(path: &Path) -> Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
let n = file.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Fetch + extract all sources.
|
||||
///
|
||||
/// Returns the primary source directory (the first source entry, or work_dir for manual-only packages).
|
||||
pub fn prepare(spec: &PackageSpec, cache_dir: &Path, build_dir: &Path) -> Result<PathBuf> {
|
||||
// If no remote sources, create work_dir and copy manual sources there
|
||||
if spec.sources().is_empty() {
|
||||
let work_dir = build_dir.join(&spec.package.name);
|
||||
fs::create_dir_all(&work_dir)?;
|
||||
copy_manual_sources(spec, &work_dir)?;
|
||||
return Ok(work_dir);
|
||||
}
|
||||
|
||||
// Copy manual sources first (before any remote fetching)
|
||||
copy_manual_sources(spec, build_dir)?;
|
||||
|
||||
let mut primary: Option<PathBuf> = None;
|
||||
|
||||
for (idx, src) in spec.sources().iter().enumerate() {
|
||||
let src_dir = prepare_one(spec, src, cache_dir, build_dir)
|
||||
.with_context(|| format!("Failed to prepare source #{}", idx + 1))?;
|
||||
if idx == 0 {
|
||||
primary = Some(src_dir);
|
||||
}
|
||||
}
|
||||
|
||||
primary.ok_or_else(|| anyhow::anyhow!("No sources in spec"))
|
||||
}
|
||||
|
||||
fn prepare_one(
|
||||
spec: &PackageSpec,
|
||||
source: &crate::package::Source,
|
||||
cache_dir: &Path,
|
||||
build_dir: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let url = spec.expand_vars(&source.url);
|
||||
let extract_dir_name = spec.expand_vars(&source.extract_dir);
|
||||
|
||||
// Local file:// handling (directories or archives)
|
||||
if let Some(path_str) = url.strip_prefix("file://") {
|
||||
let local_path = PathBuf::from(path_str);
|
||||
if local_path.is_dir() {
|
||||
let dst = build_dir.join(&extract_dir_name);
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(&dst)?;
|
||||
}
|
||||
copy_dir_recursive(&local_path, &dst)?;
|
||||
hooks::post_extract(spec, source, &dst, cache_dir)?;
|
||||
return Ok(dst);
|
||||
} else if local_path.is_file() {
|
||||
let src_dir = extractor::extract_archive(&local_path, spec, source, build_dir)?;
|
||||
hooks::post_extract(spec, source, &src_dir, cache_dir)?;
|
||||
return Ok(src_dir);
|
||||
} else {
|
||||
bail!("Local file source not found: {}", local_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic: if the URL contains '#', treat it as a git URL with a revision.
|
||||
// (except when it clearly looks like an archive URL)
|
||||
if let Some((base, rev)) = split_git_url(&url) {
|
||||
let checkout_dir = build_dir.join(&extract_dir_name);
|
||||
git::checkout(
|
||||
&base,
|
||||
&rev,
|
||||
&checkout_dir,
|
||||
&cache_dir.join("git"),
|
||||
&spec.package.name,
|
||||
)?;
|
||||
hooks::post_extract(spec, source, &checkout_dir, cache_dir)?;
|
||||
return Ok(checkout_dir);
|
||||
}
|
||||
|
||||
let archive_path = fetcher::fetch_archive(spec, source, cache_dir)?;
|
||||
let src_dir = extractor::extract_archive(&archive_path, spec, source, build_dir)?;
|
||||
hooks::post_extract(spec, source, &src_dir, cache_dir)?;
|
||||
Ok(src_dir)
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dst)?;
|
||||
for entry in WalkDir::new(src) {
|
||||
let entry = entry?;
|
||||
let rel = entry.path().strip_prefix(src).unwrap();
|
||||
let target = dst.join(rel);
|
||||
if entry.file_type().is_dir() {
|
||||
fs::create_dir_all(&target)?;
|
||||
} else if entry.file_type().is_symlink() {
|
||||
let target_link = fs::read_link(entry.path())?;
|
||||
unix_fs::symlink(target_link, &target)?;
|
||||
} else {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_git_url(url: &str) -> Option<(String, String)> {
|
||||
// Check for explicit revision with #
|
||||
if let Some((base, rev)) = url.split_once('#') {
|
||||
// Ignore fragment for obvious archives.
|
||||
let lower = base.to_ascii_lowercase();
|
||||
let is_archive = lower.ends_with(".tar.gz")
|
||||
|| lower.ends_with(".tgz")
|
||||
|| lower.ends_with(".tar.xz")
|
||||
|| lower.ends_with(".txz")
|
||||
|| lower.ends_with(".tar.bz2")
|
||||
|| lower.ends_with(".tbz2")
|
||||
|| lower.ends_with(".zip")
|
||||
|| lower.ends_with(".tar");
|
||||
if is_archive {
|
||||
return None;
|
||||
}
|
||||
if rev.trim().is_empty() {
|
||||
// Empty revision after # - use HEAD
|
||||
return Some((base.to_string(), "HEAD".to_string()));
|
||||
}
|
||||
return Some((base.to_string(), rev.to_string()));
|
||||
}
|
||||
|
||||
// Check for bare .git URL without revision - default to HEAD
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.ends_with(".git") {
|
||||
return Some((url.to_string(), "HEAD".to_string()));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_git_url;
|
||||
|
||||
#[test]
|
||||
fn split_git_url_accepts_git_with_rev() {
|
||||
let (base, rev) = split_git_url("https://example.com/repo.git#v1.2.3").unwrap();
|
||||
assert_eq!(base, "https://example.com/repo.git");
|
||||
assert_eq!(rev, "v1.2.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_git_url_accepts_bare_git_url() {
|
||||
let (base, rev) = split_git_url("https://example.com/repo.git").unwrap();
|
||||
assert_eq!(base, "https://example.com/repo.git");
|
||||
assert_eq!(rev, "HEAD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_git_url_rejects_archive_urls() {
|
||||
assert!(split_git_url("https://example.com/foo.tar.gz#deadbeef").is_none());
|
||||
assert!(split_git_url("https://example.com/foo.zip#v1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_git_url_empty_rev_defaults_to_head() {
|
||||
let (base, rev) = split_git_url("https://example.com/repo.git#").unwrap();
|
||||
assert_eq!(base, "https://example.com/repo.git");
|
||||
assert_eq!(rev, "HEAD");
|
||||
}
|
||||
}
|
||||
Executable
+485
@@ -0,0 +1,485 @@
|
||||
//! Staging phase - file collection and cleanup
|
||||
|
||||
use crate::package::PackageSpec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Process staged files - remove .la files, strip binaries, etc.
|
||||
pub fn process(destdir: &Path, _spec: &PackageSpec) -> Result<()> {
|
||||
println!("Processing staged files...");
|
||||
|
||||
let mut removed_count = 0;
|
||||
|
||||
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
|
||||
// Remove libtool .la files
|
||||
if path.extension().map(|e| e == "la").unwrap_or(false) {
|
||||
println!(" Removing: {}", path.display());
|
||||
fs::remove_file(path)?;
|
||||
removed_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if removed_count > 0 {
|
||||
println!("Removed {} .la file(s)", removed_count);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy license files into the staged tree.
|
||||
///
|
||||
/// Copies common license file patterns from the source directory root into:
|
||||
/// `destdir/documentation/licenses/<pkgname>/...`
|
||||
pub fn add_licenses(src_dir: &Path, destdir: &Path, pkgname: &str) -> Result<usize> {
|
||||
let mut copied = 0usize;
|
||||
let dst_base = destdir.join("usr/share/licenses").join(pkgname);
|
||||
|
||||
// Common patterns requested: LICENSE*, COPYING*
|
||||
// (plus a couple of very common variants)
|
||||
let is_license_name = |name: &str| {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
upper.starts_with("LICENSE")
|
||||
|| upper.starts_with("COPYING")
|
||||
|| upper.starts_with("LICENCE")
|
||||
|| upper.starts_with("NOTICE")
|
||||
};
|
||||
|
||||
let entries = match fs::read_dir(src_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(0),
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_license_name(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dst_base)
|
||||
.with_context(|| format!("Failed to create license dir: {}", dst_base.display()))?;
|
||||
|
||||
let dst = dst_base.join(name);
|
||||
fs::copy(&path, &dst)
|
||||
.with_context(|| format!("Failed to copy license {}", path.display()))?;
|
||||
copied += 1;
|
||||
}
|
||||
|
||||
if copied > 0 {
|
||||
println!(
|
||||
"Copied {} license file(s) to {}/",
|
||||
copied,
|
||||
dst_base.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FsTransaction {
|
||||
rootfs: PathBuf,
|
||||
tx_dir: PathBuf,
|
||||
backed_up: Vec<String>,
|
||||
created: Vec<String>,
|
||||
removed: Vec<String>,
|
||||
}
|
||||
|
||||
impl FsTransaction {
|
||||
fn backup_path(&self, rel: &str) -> PathBuf {
|
||||
self.tx_dir.join("backup").join(rel)
|
||||
}
|
||||
|
||||
fn removed_backup_path(&self, rel: &str) -> PathBuf {
|
||||
self.tx_dir.join("removed").join(rel)
|
||||
}
|
||||
|
||||
/// Roll back file operations performed by `install_atomic`.
|
||||
pub fn rollback(&self) -> Result<()> {
|
||||
// Restore removed files
|
||||
for rel in &self.removed {
|
||||
let src = self.removed_backup_path(rel);
|
||||
let dst = self.rootfs.join(rel);
|
||||
if src.exists() {
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Best-effort remove if something exists now.
|
||||
let _ = fs::remove_file(&dst);
|
||||
match fs::rename(&src, &dst) {
|
||||
Ok(()) => {}
|
||||
Err(_) => {
|
||||
fs::copy(&src, &dst)?;
|
||||
fs::remove_file(&src)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore overwritten files
|
||||
for rel in &self.backed_up {
|
||||
let src = self.backup_path(rel);
|
||||
let dst = self.rootfs.join(rel);
|
||||
if src.exists() {
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = fs::remove_file(&dst);
|
||||
match fs::rename(&src, &dst) {
|
||||
Ok(()) => {}
|
||||
Err(_) => {
|
||||
fs::copy(&src, &dst)?;
|
||||
fs::remove_file(&src)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove files that were newly created
|
||||
for rel in &self.created {
|
||||
let dst = self.rootfs.join(rel);
|
||||
let _ = fs::remove_file(dst);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit the transaction (delete backup directory).
|
||||
pub fn commit(self) -> Result<()> {
|
||||
if self.tx_dir.exists() {
|
||||
fs::remove_dir_all(&self.tx_dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Install staged files using a rollback-capable transaction.
|
||||
///
|
||||
/// This is used for both first-time installs and updates. For updates, pass a
|
||||
/// list of relative paths to remove (old manifest minus new manifest).
|
||||
pub fn install_atomic(
|
||||
destdir: &Path,
|
||||
rootfs: &Path,
|
||||
tx_base_dir: &Path,
|
||||
remove_paths: &[String],
|
||||
) -> Result<FsTransaction> {
|
||||
fs::create_dir_all(tx_base_dir)
|
||||
.with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?;
|
||||
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let pid = std::process::id();
|
||||
let tx_dir = tx_base_dir.join(format!("tx-{}-{}", ts, pid));
|
||||
let backup_dir = tx_dir.join("backup");
|
||||
let removed_dir = tx_dir.join("removed");
|
||||
fs::create_dir_all(&backup_dir)?;
|
||||
fs::create_dir_all(&removed_dir)?;
|
||||
|
||||
let mut tx = FsTransaction {
|
||||
rootfs: rootfs.to_path_buf(),
|
||||
tx_dir,
|
||||
backed_up: Vec::new(),
|
||||
created: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
};
|
||||
|
||||
let result: Result<()> = (|| {
|
||||
// First, create all directories from destdir (for packages with only directories)
|
||||
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||
let src_path = entry.path();
|
||||
let file_type = entry.file_type();
|
||||
|
||||
if !file_type.is_dir() || src_path == destdir {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rel_path = src_path
|
||||
.strip_prefix(destdir)
|
||||
.context("Failed to strip destdir prefix")?;
|
||||
|
||||
let dest_path = rootfs.join(rel_path);
|
||||
if !dest_path.exists() {
|
||||
fs::create_dir_all(&dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy in new files.
|
||||
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||
let src_path = entry.path();
|
||||
let metadata = src_path
|
||||
.symlink_metadata()
|
||||
.context("Failed to get metadata")?;
|
||||
let file_type = metadata.file_type();
|
||||
|
||||
// We want to install files AND symlinks (to anything)
|
||||
if !file_type.is_file() && !file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rel_path = src_path
|
||||
.strip_prefix(destdir)
|
||||
.context("Failed to strip destdir prefix")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let dest_path = rootfs.join(&rel_path);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if dest_path.symlink_metadata().is_ok() {
|
||||
// lexists checks existence without following symlinks
|
||||
// Backup existing
|
||||
let backup_path = tx.backup_path(&rel_path);
|
||||
if let Some(parent) = backup_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Fallback: if symlink, read link and recreate at backup.
|
||||
let dest_meta = dest_path.symlink_metadata()?;
|
||||
if dest_meta.file_type().is_symlink() {
|
||||
let target = fs::read_link(&dest_path)?;
|
||||
std::os::unix::fs::symlink(&target, &backup_path)?;
|
||||
} else {
|
||||
fs::copy(&dest_path, &backup_path)?;
|
||||
}
|
||||
|
||||
tx.backed_up.push(rel_path.clone());
|
||||
} else {
|
||||
tx.created.push(rel_path.clone());
|
||||
}
|
||||
|
||||
// Install new file/symlink
|
||||
// Remove destination if it exists (we backed it up) so we can overwrite
|
||||
if dest_path.symlink_metadata().is_ok() {
|
||||
if dest_path.is_dir() {
|
||||
fs::remove_dir_all(&dest_path)?;
|
||||
} else {
|
||||
fs::remove_file(&dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
if file_type.is_symlink() {
|
||||
let target = fs::read_link(src_path)?;
|
||||
std::os::unix::fs::symlink(target, &dest_path)
|
||||
.with_context(|| format!("Failed to create symlink: {}", rel_path))?;
|
||||
} else {
|
||||
fs::copy(src_path, &dest_path)
|
||||
.with_context(|| format!("Failed to install: {}", rel_path))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove obsolete files (update only)
|
||||
for rel in remove_paths {
|
||||
let rel = rel.as_str();
|
||||
let dest_path = rootfs.join(rel);
|
||||
if !dest_path.exists() {
|
||||
continue;
|
||||
}
|
||||
if !dest_path.is_file() {
|
||||
// Only track files for now.
|
||||
continue;
|
||||
}
|
||||
let backup_path = tx.removed_backup_path(rel);
|
||||
if let Some(parent) = backup_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&dest_path, &backup_path)
|
||||
.with_context(|| format!("Failed to backup removed file: {}", rel))?;
|
||||
fs::remove_file(&dest_path)
|
||||
.with_context(|| format!("Failed to remove obsolete file: {}", rel))?;
|
||||
tx.removed.push(rel.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = tx.rollback();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_licenses_copies_common_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let src_dir = tmp.path().join("src");
|
||||
let destdir = tmp.path().join("dest");
|
||||
std::fs::create_dir_all(&src_dir).unwrap();
|
||||
std::fs::create_dir_all(&destdir).unwrap();
|
||||
|
||||
std::fs::write(src_dir.join("LICENSE"), "license text").unwrap();
|
||||
std::fs::write(src_dir.join("COPYING.md"), "copying text").unwrap();
|
||||
std::fs::write(src_dir.join("README"), "not a license").unwrap();
|
||||
|
||||
let copied = add_licenses(&src_dir, &destdir, "foo").unwrap();
|
||||
assert_eq!(copied, 2);
|
||||
|
||||
let lic_dir = destdir.join("usr/share/licenses/foo");
|
||||
assert!(lic_dir.join("LICENSE").exists());
|
||||
assert!(lic_dir.join("COPYING.md").exists());
|
||||
assert!(!lic_dir.join("README").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_atomic_update_and_rollback_restores_state() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = tmp.path().join("root");
|
||||
let destdir = tmp.path().join("dest");
|
||||
let tx_base = tmp.path().join("tx");
|
||||
std::fs::create_dir_all(&rootfs).unwrap();
|
||||
std::fs::create_dir_all(&destdir).unwrap();
|
||||
|
||||
// Existing installed files
|
||||
std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap();
|
||||
std::fs::write(rootfs.join("usr/bin/foo"), "old").unwrap();
|
||||
std::fs::write(rootfs.join("usr/bin/old_only"), "to_remove").unwrap();
|
||||
|
||||
// New staged files
|
||||
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||
std::fs::write(destdir.join("usr/bin/foo"), "new").unwrap();
|
||||
std::fs::write(destdir.join("usr/bin/new_only"), "added").unwrap();
|
||||
|
||||
let remove_paths = vec!["usr/bin/old_only".to_string()];
|
||||
let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths).unwrap();
|
||||
|
||||
// After install: updated + new present, obsolete removed
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
|
||||
"new"
|
||||
);
|
||||
assert!(rootfs.join("usr/bin/new_only").exists());
|
||||
assert!(!rootfs.join("usr/bin/old_only").exists());
|
||||
|
||||
// Roll back should restore old state
|
||||
tx.rollback().unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(),
|
||||
"old"
|
||||
);
|
||||
assert!(!rootfs.join("usr/bin/new_only").exists());
|
||||
assert!(rootfs.join("usr/bin/old_only").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_atomic_commit_removes_tx_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = tmp.path().join("root");
|
||||
let destdir = tmp.path().join("dest");
|
||||
let tx_base = tmp.path().join("tx");
|
||||
std::fs::create_dir_all(&rootfs).unwrap();
|
||||
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||
std::fs::write(destdir.join("usr/bin/foo"), "x").unwrap();
|
||||
|
||||
let tx = install_atomic(&destdir, &rootfs, &tx_base, &[]).unwrap();
|
||||
let tx_dir = tx.tx_dir.clone();
|
||||
assert!(tx_dir.exists());
|
||||
tx.commit().unwrap();
|
||||
assert!(!tx_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_atomic_symlink_to_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = tmp.path().join("root");
|
||||
let destdir = tmp.path().join("dest");
|
||||
let tx_base = tmp.path().join("tx");
|
||||
std::fs::create_dir_all(&rootfs).unwrap();
|
||||
std::fs::create_dir_all(destdir.join("usr/bin")).unwrap();
|
||||
// Create a symlink bin -> usr/bin in destdir
|
||||
std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap();
|
||||
|
||||
let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[]).unwrap();
|
||||
|
||||
// Verify rootfs/bin is a symlink, not a directory
|
||||
let meta = rootfs
|
||||
.join("bin")
|
||||
.symlink_metadata()
|
||||
.expect("bin should exist");
|
||||
assert!(meta.file_type().is_symlink(), "bin should be a symlink");
|
||||
assert_eq!(
|
||||
std::fs::read_link(rootfs.join("bin")).unwrap(),
|
||||
std::path::PathBuf::from("usr/bin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_walkdir_symlink_behavior() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("target")).unwrap();
|
||||
std::os::unix::fs::symlink("target", dir.join("link")).unwrap();
|
||||
|
||||
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if entry.path().ends_with("link") {
|
||||
let ft = entry.file_type();
|
||||
assert!(
|
||||
!ft.is_dir(),
|
||||
"walkdir should NOT report symlink to dir as a directory"
|
||||
);
|
||||
assert!(ft.is_symlink(), "walkdir SHOULD report it as a symlink");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manifest containing files and directories for a package
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Manifest {
|
||||
pub files: Vec<String>,
|
||||
pub directories: Vec<String>,
|
||||
}
|
||||
|
||||
/// Generate manifest with both files and directories
|
||||
pub fn generate_manifest_with_dirs(destdir: &Path) -> Result<Manifest> {
|
||||
let mut files = Vec::new();
|
||||
let mut directories = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) {
|
||||
let rel_path = entry
|
||||
.path()
|
||||
.strip_prefix(destdir)?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Skip the root (empty path)
|
||||
if rel_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_type = entry.file_type();
|
||||
|
||||
// Check for symlink first
|
||||
if file_type.is_symlink() {
|
||||
// Track symlinks as files (they get removed the same way)
|
||||
files.push(rel_path);
|
||||
} else if file_type.is_file() {
|
||||
files.push(rel_path);
|
||||
} else if file_type.is_dir() {
|
||||
directories.push(rel_path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Manifest { files, directories })
|
||||
}
|
||||
Reference in New Issue
Block a user