feat: initial implementation of Depot package manager

- Implement source-based package management with support for archives and Git
- Add multi-system build support (Autotools, CMake, Meson, Rust/Cargo, Custom)
- Implement atomic installation logic using transactions and fakeroot
- Add dependency resolution for build-time and runtime requirements
- Implement package indexing and local repository management
- Add comprehensive configuration system with system/package overrides
- Include Project guidelines (AGENTS.md) and README.md
This commit is contained in:
2026-02-16 11:35:23 -06:00
parent 94044a48c3
commit 00ca2ebac6
29 changed files with 9385 additions and 69 deletions
+495
View File
@@ -0,0 +1,495 @@
//! Archive extraction support
use crate::package::{PackageSpec, Source};
use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder;
use std::fs::{self, File};
use std::io::{Cursor, Read, Write};
use std::os::unix::fs as unix_fs;
use std::path::{Path, PathBuf};
use zstd::stream::read::Decoder as ZstdDecoder;
/// Extract an archive source to the build directory.
pub fn extract_archive(
archive_path: &Path,
spec: &PackageSpec,
source: &Source,
build_dir: &Path,
) -> Result<PathBuf> {
let extract_dir_name = spec.expand_vars(&source.extract_dir);
let extract_path = build_dir.join(&extract_dir_name);
// Create build directory
fs::create_dir_all(build_dir)
.with_context(|| format!("Failed to create build dir: {}", build_dir.display()))?;
// Remove existing extraction if present
if extract_path.exists() {
fs::remove_dir_all(&extract_path)?;
}
let filename = archive_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
println!("Extracting: {}", filename);
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
extract_tar_gz(archive_path, build_dir)?;
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
extract_tar_xz(archive_path, build_dir)?;
} else if filename.ends_with(".tar.bz2") || filename.ends_with(".tbz2") {
extract_tar_bz2(archive_path, build_dir)?;
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tzst") {
extract_tar_zst(archive_path, build_dir)?;
} else if filename.ends_with(".zip") {
extract_zip(archive_path, build_dir)?;
} else if filename.ends_with(".tar") {
extract_tar(archive_path, build_dir)?;
} else if filename.ends_with(".deb") {
extract_deb(archive_path, build_dir)?;
} else if filename.ends_with(".rpm") {
extract_rpm(archive_path, build_dir)?;
} else if filename.ends_with(".gz") {
extract_gz_file(archive_path, build_dir)?;
} else if filename.ends_with(".xz") {
extract_xz_file(archive_path, build_dir)?;
} else if filename.ends_with(".zst") {
extract_zst_file(archive_path, build_dir)?;
} else {
bail!("Unsupported archive format: {}", filename);
}
if !extract_path.exists() {
bail!(
"Expected extraction directory not found: {}",
extract_path.display()
);
}
println!("Extracted to: {}", extract_path.display());
Ok(extract_path)
}
fn extract_tar_gz(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
fn extract_tar_xz(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let decoder = xz2::read::XzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
fn extract_tar_bz2(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let decoder = bzip2::read::BzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
fn extract_tar(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let mut archive = tar::Archive::new(file);
archive.unpack(dest)?;
Ok(())
}
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let mut archive = zip::ZipArchive::new(file)?;
archive.extract(dest)?;
Ok(())
}
fn extract_tar_zst(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let decoder = ZstdDecoder::new(file)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
fn extract_gz_file(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let mut decoder = GzDecoder::new(file);
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output");
let out_name = if let Some(stripped) = filename.strip_suffix(".gz") {
stripped
} else {
filename
};
fs::create_dir_all(dest)?;
let mut out = File::create(dest.join(out_name))?;
std::io::copy(&mut decoder, &mut out)?;
Ok(())
}
fn extract_xz_file(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let mut decoder = xz2::read::XzDecoder::new(file);
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output");
let out_name = if let Some(stripped) = filename.strip_suffix(".xz") {
stripped
} else {
filename
};
fs::create_dir_all(dest)?;
let mut out = File::create(dest.join(out_name))?;
std::io::copy(&mut decoder, &mut out)?;
Ok(())
}
fn extract_zst_file(path: &Path, dest: &Path) -> Result<()> {
let file = File::open(path)?;
let mut decoder = ZstdDecoder::new(file)?;
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output");
let out_name = if let Some(stripped) = filename.strip_suffix(".zst") {
stripped
} else {
filename
};
fs::create_dir_all(dest)?;
let mut out = File::create(dest.join(out_name))?;
std::io::copy(&mut decoder, &mut out)?;
Ok(())
}
fn extract_deb(path: &Path, dest: &Path) -> Result<()> {
// Debian packages are ar archives containing (among others) a data.tar.* member
let file = File::open(path)?;
let mut ar = ar::Archive::new(file);
// Iterate members and look for data.tar, data.tar.gz, data.tar.xz, data.tar.zst
while let Some(entry_result) = ar.next_entry() {
let entry = entry_result?;
let id = String::from_utf8_lossy(entry.header().identifier()).to_string();
let lower = id.to_ascii_lowercase();
if lower.starts_with("data.tar") {
// Determine compression
if lower.ends_with(".gz") {
let decoder = GzDecoder::new(entry);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
return Ok(());
} else if lower.ends_with(".xz") {
let decoder = xz2::read::XzDecoder::new(entry);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
return Ok(());
} else if lower.ends_with(".zst") {
let decoder = ZstdDecoder::new(entry)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
return Ok(());
} else {
// plain tar
let mut archive = tar::Archive::new(entry);
archive.unpack(dest)?;
return Ok(());
}
}
}
// No data member found
anyhow::bail!("No data.tar.* member found in deb: {}", path.display());
}
fn extract_cpio_newc_from_reader<R: Read>(mut r: R, dest: &Path) -> Result<()> {
use std::str;
loop {
// read 6-byte magic
let mut magic = [0u8; 6];
if let Err(e) = r.read_exact(&mut magic) {
// EOF
return Err(e).with_context(|| "Failed reading cpio magic")?;
}
if &magic != b"070701" {
anyhow::bail!("Unsupported cpio magic: {:?}", &magic);
}
// read remaining 104 bytes of header (total header is 110)
let mut rest = [0u8; 104];
r.read_exact(&mut rest)?;
let header_str = str::from_utf8(&rest).context("Invalid cpio header encoding")?;
// parse 13 hex fields of 8 chars each
if header_str.len() < 8 * 13 {
anyhow::bail!("Truncated cpio header");
}
let mut fields = [0u64; 13];
for i in 0..13 {
let part = &header_str[i * 8..i * 8 + 8];
fields[i] = u64::from_str_radix(part, 16)
.with_context(|| format!("Invalid header field hex: {}", part))?;
}
let namesize = fields[11] as usize;
let filesize = fields[6] as usize;
// read name
let mut name_buf = vec![0u8; namesize];
r.read_exact(&mut name_buf)?;
let name = match name_buf.iter().position(|&b| b == 0) {
Some(p) => String::from_utf8_lossy(&name_buf[..p]).to_string(),
None => String::from_utf8_lossy(&name_buf).to_string(),
};
// skip header padding to 4 bytes
let header_total = 110 + namesize;
let header_pad = (4 - (header_total % 4)) % 4;
if header_pad > 0 {
let mut tmp = vec![0u8; header_pad];
r.read_exact(&mut tmp)?;
}
if name == "TRAILER!!!" {
break;
}
let mode = fields[1];
let file_path = dest.join(&name);
if (mode & 0o170000) == 0o040000 {
// directory
fs::create_dir_all(&file_path)?;
} else if (mode & 0o170000) == 0o120000 {
// symlink
let mut link_buf = vec![0u8; filesize];
r.read_exact(&mut link_buf)?;
let target = String::from_utf8_lossy(&link_buf).to_string();
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
let _ = fs::remove_file(&file_path);
unix_fs::symlink(target, &file_path)?;
} else {
// regular file
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out = File::create(&file_path)?;
let mut remaining = filesize;
let mut buf = [0u8; 8192];
while remaining > 0 {
let to_read = std::cmp::min(remaining, buf.len());
let n = r.read(&mut buf[..to_read])?;
if n == 0 {
break;
}
out.write_all(&buf[..n])?;
remaining -= n;
}
// set permissions
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&file_path,
fs::Permissions::from_mode((mode & 0o7777) as u32),
)?;
}
// skip file data padding
let data_pad = (4 - (filesize % 4)) % 4;
if data_pad > 0 {
let mut tmp = vec![0u8; data_pad];
r.read_exact(&mut tmp)?;
}
}
Ok(())
}
fn extract_rpm(path: &Path, dest: &Path) -> Result<()> {
// Read entire file and search for compression/cpio magic
let data = std::fs::read(path)?;
// search for known signatures
let gz_sig = b"\x1f\x8b";
let xz_sig = b"\xfd7zXZ\x00";
let zst_sig = b"\x28\xb5\x2f\xfd";
let cpio_sig = b"070701";
if let Some(pos) = find_subslice(&data, gz_sig) {
let cursor = Cursor::new(&data[pos..]);
let decoder = GzDecoder::new(cursor);
extract_cpio_newc_from_reader(decoder, dest)?;
return Ok(());
}
if let Some(pos) = find_subslice(&data, xz_sig) {
let cursor = Cursor::new(&data[pos..]);
let decoder = xz2::read::XzDecoder::new(cursor);
extract_cpio_newc_from_reader(decoder, dest)?;
return Ok(());
}
if let Some(pos) = find_subslice(&data, zst_sig) {
let cursor = Cursor::new(&data[pos..]);
let decoder = ZstdDecoder::new(cursor)?;
extract_cpio_newc_from_reader(decoder, dest)?;
return Ok(());
}
if let Some(pos) = find_subslice(&data, cpio_sig) {
let cursor = Cursor::new(&data[pos..]);
extract_cpio_newc_from_reader(cursor, dest)?;
return Ok(());
}
anyhow::bail!(
"No recognizable cpio payload found in rpm: {}",
path.display()
);
}
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_extract_deb_roundtrip() {
let tmp = tempdir().unwrap();
let deb_path = tmp.path().join("test.deb");
let extract_dir = tmp.path().join("out-deb");
// create a small tar.gz payload with one file
let mut tar_buf = Vec::new();
{
let gz = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default());
let mut tar = tar::Builder::new(gz);
let mut header = tar::Header::new_gnu();
let data = b"hello-deb";
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, "usr/bin/hello-deb", &data[..])
.unwrap();
tar.finish().unwrap();
}
// write ar archive with member data.tar.gz (use temp files because ar::Builder expects File)
{
let db = tmp.path().join("debian-binary");
let ct = tmp.path().join("control.tar.gz");
let dt = tmp.path().join("data.tar.gz");
std::fs::write(&db, b"2.0\n").unwrap();
std::fs::write(&ct, b"").unwrap();
std::fs::write(&dt, &tar_buf[..]).unwrap();
let mut f = File::create(&deb_path).unwrap();
let mut builder = ar::Builder::new(&mut f);
let mut dbf = File::open(&db).unwrap();
let mut ctf = File::open(&ct).unwrap();
let mut dtf = File::open(&dt).unwrap();
builder.append_file(b"debian-binary", &mut dbf).unwrap();
builder.append_file(b"control.tar.gz", &mut ctf).unwrap();
builder.append_file(b"data.tar.gz", &mut dtf).unwrap();
}
fs::create_dir_all(&extract_dir).unwrap();
extract_deb(&deb_path, &extract_dir).unwrap();
assert!(extract_dir.join("usr/bin/hello-deb").exists());
}
fn write_cpio_newc_one_file(w: &mut Vec<u8>, name: &str, data: &[u8]) {
// magic + 13 fields of 8 hex chars each
fn h8(v: u64) -> String {
format!("{:08x}", v)
}
let namesize = name.len() + 1;
let filesize = data.len();
let mut header = Vec::new();
header.extend_from_slice(b"070701");
// ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, check
header.extend_from_slice(h8(0).as_bytes()); // ino
header.extend_from_slice(h8(0o100644).as_bytes()); // mode regular file with perms
header.extend_from_slice(h8(0).as_bytes()); // uid
header.extend_from_slice(h8(0).as_bytes()); // gid
header.extend_from_slice(h8(1).as_bytes()); // nlink
header.extend_from_slice(h8(0).as_bytes()); // mtime
header.extend_from_slice(h8(filesize as u64).as_bytes());
header.extend_from_slice(h8(0).as_bytes()); // devmajor
header.extend_from_slice(h8(0).as_bytes()); // devminor
header.extend_from_slice(h8(0).as_bytes()); // rdevmajor
header.extend_from_slice(h8(0).as_bytes()); // rdevminor
header.extend_from_slice(h8(namesize as u64).as_bytes());
header.extend_from_slice(h8(0).as_bytes()); // check
w.extend_from_slice(&header);
w.extend_from_slice(name.as_bytes());
w.push(0);
// pad to 4
let pad = (4 - ((110 + namesize) % 4)) % 4;
for _ in 0..pad {
w.push(0);
}
// file data
w.extend_from_slice(data);
let dpad = (4 - (filesize % 4)) % 4;
for _ in 0..dpad {
w.push(0);
}
}
fn write_cpio_trailer(w: &mut Vec<u8>) {
write_cpio_newc_one_file(w, "TRAILER!!!", &[]);
}
#[test]
fn test_extract_rpm_roundtrip() {
let tmp = tempdir().unwrap();
let rpm_path = tmp.path().join("test.rpm");
let extract_dir = tmp.path().join("out-rpm");
// build cpio newc stream with one file
let mut cpio = Vec::new();
write_cpio_newc_one_file(&mut cpio, "usr/bin/hello-rpm", b"hello-rpm");
write_cpio_trailer(&mut cpio);
// gzip compress
let mut gz = Vec::new();
{
let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
enc.write_all(&cpio).unwrap();
enc.finish().unwrap();
}
// write fake rpm: some header bytes then gz payload
{
let mut f = File::create(&rpm_path).unwrap();
f.write_all(b"RPMHEAD").unwrap();
f.write_all(&gz).unwrap();
}
fs::create_dir_all(&extract_dir).unwrap();
extract_rpm(&rpm_path, &extract_dir).unwrap();
assert!(extract_dir.join("usr/bin/hello-rpm").exists());
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Source tarball fetching with checksum verification
use crate::package::{PackageSpec, Source};
use anyhow::{Context, Result, bail};
use indicatif::{ProgressBar, ProgressStyle};
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use url::Url;
/// Fetch an archive source tarball, returning path to downloaded file.
pub fn fetch_archive(spec: &PackageSpec, source: &Source, cache_dir: &Path) -> Result<PathBuf> {
let url = spec.expand_vars(&source.url);
let filename = derive_filename_from_url(&url);
let dest_path = cache_dir.join(&filename);
// Create cache directory if needed
fs::create_dir_all(cache_dir)
.with_context(|| format!("Failed to create cache dir: {}", cache_dir.display()))?;
// Check if already cached and verified
if dest_path.exists() && verify_checksum(&dest_path, &source.sha256)? {
println!("Using cached source: {}", dest_path.display());
return Ok(dest_path);
}
println!("Fetching: {}", url);
// Download with progress bar
let client = reqwest::blocking::Client::new();
let mut response = client
.get(&url)
.send()
.with_context(|| format!("Failed to fetch: {}", url))?;
let total_size = response.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let mut file = File::create(&dest_path)
.with_context(|| format!("Failed to create: {}", dest_path.display()))?;
let mut buffer = [0u8; 8192];
let mut downloaded = 0u64;
loop {
let bytes_read = response.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
file.write_all(&buffer[..bytes_read])?;
downloaded += bytes_read as u64;
pb.set_position(downloaded);
}
pb.finish_with_message("Download complete");
// Verify checksum
if !verify_checksum(&dest_path, &source.sha256)? {
fs::remove_file(&dest_path)?;
bail!("Checksum verification failed for {}", filename);
}
println!("Checksum verified: {}", filename);
Ok(dest_path)
}
/// Verify SHA256 checksum of a file
fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
// Skip verification if requested
if expected.to_lowercase() == "skip" {
println!("Checksum verification skipped");
return Ok(true);
}
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let result = hasher.finalize();
let actual = format!("{:x}", result);
Ok(actual == expected.to_lowercase())
}
/// Derive a stable filename from a URL.
///
/// Rules:
/// - Parse as URL and use the last path segment if it looks like a filename (contains a dot)
/// - Otherwise fall back to a stable hash-based name: source-{sha256(url)[..12]}.download
fn derive_filename_from_url(url: &str) -> String {
// try to parse the URL
if let Some(last) = Url::parse(url).ok().and_then(|parsed| {
parsed
.path_segments()?
.rfind(|s| !s.is_empty())
.filter(|l| l.contains('.'))
.map(|l| l.to_string())
}) {
return last;
}
// fallback: stable short-hash name
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let h = hasher.finalize();
let hex = format!("{:x}", h);
format!("source-{}.download", &hex[..12])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_from_simple_url() {
assert_eq!(
derive_filename_from_url("https://example.com/foo-1.2.3.tar.gz"),
"foo-1.2.3.tar.gz"
);
}
#[test]
fn filename_from_url_with_query() {
assert_eq!(
derive_filename_from_url("https://example.com/foo.tar.gz?raw=1"),
"foo.tar.gz"
);
}
#[test]
fn filename_from_url_without_real_name() {
let name = derive_filename_from_url("https://github.com/org/repo/releases/download?id=123");
assert!(name.starts_with("source-") && name.ends_with(".download"));
}
#[test]
fn filename_from_non_url_string() {
let name = derive_filename_from_url("not-a-url-at-all");
assert!(name.starts_with("source-") && name.ends_with(".download"));
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Git source support via libgit2 (git2 crate)
use anyhow::{Context, Result};
use git2::{Cred, FetchOptions, Oid, RemoteCallbacks, Repository};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
/// Checkout a repository URL at a specific revision into `checkout_dir`.
///
/// The URL is expected to be the base URL (without the `#rev` fragment).
/// The revision may be a tag name, branch name, or commit hash.
///
/// Repositories are mirrored under `git_cache_dir` to avoid repeated network fetches.
pub fn checkout(
url: &str,
rev: &str,
checkout_dir: &Path,
git_cache_dir: &Path,
pkgname: &str,
) -> Result<()> {
fs::create_dir_all(git_cache_dir)
.with_context(|| format!("Failed to create git cache dir: {}", git_cache_dir.display()))?;
let mirror_dir = git_cache_dir.join(mirror_key(url));
ensure_mirror(url, &mirror_dir, pkgname)?;
if checkout_dir.exists() {
fs::remove_dir_all(checkout_dir)
.with_context(|| format!("Failed to remove existing checkout: {}", checkout_dir.display()))?;
}
// Clone from local mirror for speed.
let mirror_url = mirror_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid mirror path"))?;
println!("Cloning git source into {}...", checkout_dir.display());
Repository::clone(mirror_url, checkout_dir)
.with_context(|| format!("Failed to clone from mirror for {}", url))?;
let repo = Repository::open(checkout_dir)?;
checkout_rev(&repo, rev)
.with_context(|| format!("Failed to checkout revision '{}'", rev))?;
Ok(())
}
fn mirror_key(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let digest = hasher.finalize();
format!("{:x}", digest)
}
fn ensure_mirror(url: &str, mirror_dir: &Path, pkgname: &str) -> Result<()> {
if !mirror_dir.exists() {
println!("Cloning git mirror for {} ({})...", pkgname, url);
let mut fo = FetchOptions::new();
fo.remote_callbacks(remote_callbacks());
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fo);
builder.bare(true);
builder
.clone(url, mirror_dir)
.with_context(|| format!("Failed to clone git mirror: {}", url))?;
return Ok(());
}
// Fetch updates
let repo = Repository::open_bare(mirror_dir)
.with_context(|| format!("Failed to open git mirror: {}", mirror_dir.display()))?;
let mut remote = repo
.find_remote("origin")
.or_else(|_| repo.remote_anonymous(url))
.with_context(|| format!("Failed to create remote for {}", url))?;
let mut fo = FetchOptions::new();
fo.remote_callbacks(remote_callbacks());
// Fetch all remote refs (tags + heads). Empty refspec uses default.
remote
.fetch(&[] as &[&str], Some(&mut fo), None)
.with_context(|| format!("Failed to fetch updates for {}", url))?;
Ok(())
}
fn checkout_rev(repo: &Repository, rev: &str) -> Result<()> {
// Try a few reasonable ways to resolve the rev.
let obj = repo
.revparse_single(rev)
.or_else(|_| repo.revparse_single(&format!("refs/tags/{rev}")))
.or_else(|_| repo.revparse_single(&format!("refs/heads/{rev}")))
.with_context(|| format!("Could not resolve git rev: {}", rev))?;
// Peel tags to commit if needed.
let commit = obj
.peel_to_commit()
.with_context(|| format!("Could not peel rev to commit: {}", rev))?;
let oid: Oid = commit.id();
repo.set_head_detached(oid)?;
repo.checkout_tree(commit.as_object(), None)?;
Ok(())
}
fn remote_callbacks() -> RemoteCallbacks<'static> {
let mut callbacks = RemoteCallbacks::new();
// Try SSH agent / default key locations.
callbacks.credentials(|_url, username_from_url, _allowed| {
if let Some(name) = username_from_url {
Cred::ssh_key_from_agent(name)
} else {
Cred::default()
}
});
callbacks
}
+296
View File
@@ -0,0 +1,296 @@
//! Post-extraction hooks: apply patches and run commands
use crate::package::{PackageSpec, Source};
use anyhow::{Context, Result, bail};
use indicatif::{ProgressBar, ProgressStyle};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Apply patches and run `post_extract` commands in the extracted source tree.
pub fn post_extract(
spec: &PackageSpec,
source: &Source,
src_dir: &Path,
cache_dir: &Path,
) -> Result<()> {
apply_patches(spec, source, src_dir, cache_dir)?;
run_post_extract_commands(spec, source, src_dir)?;
Ok(())
}
fn apply_patches(
spec: &PackageSpec,
source: &Source,
src_dir: &Path,
cache_dir: &Path,
) -> Result<()> {
if source.patches.is_empty() {
return Ok(());
}
let patch_cache_dir = cache_dir.join("patches").join(&spec.package.name);
fs::create_dir_all(&patch_cache_dir).with_context(|| {
format!(
"Failed to create patch cache dir: {}",
patch_cache_dir.display()
)
})?;
println!("Applying {} patch(es)...", source.patches.len());
for p in &source.patches {
let p = spec.expand_vars(p);
let patch_path = resolve_patch_path(spec, &p, &patch_cache_dir)?;
println!(" patch: {}", patch_path.display());
// Apply with patch(1). Keep it simple: -p1 is the common case.
let status = Command::new("patch")
.current_dir(src_dir)
.arg("-p1")
.arg("-i")
.arg(&patch_path)
.status()
.with_context(|| format!("Failed to execute patch for {}", patch_path.display()))?;
if !status.success() {
bail!("Patch failed: {}", patch_path.display());
}
}
Ok(())
}
fn run_post_extract_commands(spec: &PackageSpec, source: &Source, src_dir: &Path) -> Result<()> {
if source.post_extract.is_empty() {
return Ok(());
}
println!(
"Running {} post-extract command(s)...",
source.post_extract.len()
);
for cmd in &source.post_extract {
let cmd = spec.expand_vars(cmd);
println!(" post_extract: {}", cmd);
// Use a shell for convenience; this is a package manager, so specs are trusted input.
let status = Command::new("sh")
.current_dir(src_dir)
.env("NYAPM_SPECDIR", &spec.spec_dir)
.arg("-c")
.arg(&cmd)
.status()
.with_context(|| format!("Failed to run post_extract command: {}", cmd))?;
if !status.success() {
bail!("post_extract command failed: {}", cmd);
}
}
Ok(())
}
/// Run post-compile commands (after make, before make install).
pub fn run_post_compile_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> {
let commands = &spec.build.flags.post_compile;
if commands.is_empty() {
return Ok(());
}
println!("Running {} post-compile command(s)...", commands.len());
for cmd in commands {
let cmd = spec.expand_vars(cmd);
println!(" post_compile: {}", cmd);
let status = Command::new("sh")
.current_dir(src_dir)
.env("NYAPM_SPECDIR", &spec.spec_dir)
.env("DESTDIR", destdir)
.env("CC", &spec.build.flags.cc)
.env("AR", &spec.build.flags.ar)
.arg("-c")
.arg(&cmd)
.status()
.with_context(|| format!("Failed to run post_compile command: {}", cmd))?;
if !status.success() {
bail!("post_compile command failed: {}", cmd);
}
}
Ok(())
}
/// Run post-install commands (after make install).
pub fn run_post_install_commands(spec: &PackageSpec, src_dir: &Path, destdir: &Path) -> Result<()> {
let commands = &spec.build.flags.post_install;
if commands.is_empty() {
return Ok(());
}
println!("Running {} post-install command(s)...", commands.len());
for cmd in commands {
let cmd = spec.expand_vars(cmd);
println!(" post_install: {}", cmd);
let status = Command::new("sh")
.current_dir(src_dir)
.env("NYAPM_SPECDIR", &spec.spec_dir)
.env("DESTDIR", destdir)
.env("CC", &spec.build.flags.cc)
.env("AR", &spec.build.flags.ar)
.arg("-c")
.arg(&cmd)
.status()
.with_context(|| format!("Failed to run post_install command: {}", cmd))?;
if !status.success() {
bail!("post_install command failed: {}", cmd);
}
}
Ok(())
}
fn resolve_patch_path(spec: &PackageSpec, patch: &str, patch_cache_dir: &Path) -> Result<PathBuf> {
if patch.starts_with("http://") || patch.starts_with("https://") {
let filename = patch
.split('/')
.rfind(|s| !s.is_empty())
.unwrap_or("patch.patch");
let dest = patch_cache_dir.join(filename);
if dest.exists() {
return Ok(dest);
}
download(patch, &dest).with_context(|| format!("Failed to download patch: {}", patch))?;
return Ok(dest);
}
// Treat as local path, relative to the spec file.
let local = spec.spec_dir.join(patch);
if !local.exists() {
bail!(
"Patch not found: {} (resolved to {})",
patch,
local.display()
);
}
Ok(local)
}
fn download(url: &str, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let client = reqwest::blocking::Client::new();
let mut response = client
.get(url)
.send()
.with_context(|| format!("Failed to fetch: {}", url))?;
let total_size = response.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-")
);
let mut file =
fs::File::create(dest).with_context(|| format!("Failed to create: {}", dest.display()))?;
let mut buffer = [0u8; 8192];
let mut downloaded = 0u64;
loop {
let bytes_read = response.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
file.write_all(&buffer[..bytes_read])?;
downloaded += bytes_read as u64;
pb.set_position(downloaded);
}
pb.finish_with_message("Patch download complete");
Ok(())
}
#[cfg(test)]
mod tests {
use super::resolve_patch_path;
use crate::package::{
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
};
fn dummy_spec(spec_dir: &std::path::Path) -> PackageSpec {
PackageSpec {
package: PackageInfo {
name: "foo".into(),
version: "1.0".into(),
revision: 1,
description: "d".into(),
homepage: "h".into(),
license: "MIT".into(),
},
alternatives: Alternatives::default(),
manual_sources: Vec::new(),
source: vec![Source {
url: "https://example.com/foo.tar.gz".into(),
sha256: "skip".into(),
extract_dir: "foo".into(),
patches: Vec::new(),
post_extract: Vec::new(),
}],
build: Build {
build_type: BuildType::Custom,
flags: BuildFlags::default(),
},
dependencies: Dependencies::default(),
spec_dir: spec_dir.to_path_buf(),
}
}
#[test]
fn resolve_patch_relative_to_spec_dir() {
let tmp = tempfile::tempdir().unwrap();
let spec_dir = tmp.path().join("spec");
std::fs::create_dir_all(&spec_dir).unwrap();
let patch_path = spec_dir.join("fix.patch");
std::fs::write(&patch_path, "diff --git a/a b/a\n").unwrap();
let spec = dummy_spec(&spec_dir);
let cache = tmp.path().join("cache");
std::fs::create_dir_all(&cache).unwrap();
let resolved = resolve_patch_path(&spec, "fix.patch", &cache).unwrap();
assert_eq!(resolved, patch_path);
}
#[test]
fn resolve_patch_url_uses_cached_file_if_present() {
let tmp = tempfile::tempdir().unwrap();
let spec_dir = tmp.path().join("spec");
std::fs::create_dir_all(&spec_dir).unwrap();
let spec = dummy_spec(&spec_dir);
let cache = tmp.path().join("cache");
let patch_cache_dir = cache.join("patches").join("foo");
std::fs::create_dir_all(&patch_cache_dir).unwrap();
// URL-derived filename is the last segment.
let cached = patch_cache_dir.join("fix.patch");
std::fs::write(&cached, "dummy").unwrap();
let url = "https://example.com/fix.patch";
let resolved = resolve_patch_path(&spec, url, &patch_cache_dir).unwrap();
assert_eq!(resolved, cached);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Source fetching and extraction
mod extractor;
mod fetcher;
mod git;
pub mod hooks;
use crate::package::PackageSpec;
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::os::unix::fs as unix_fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Copy manual (local) sources to the build directory before fetching remote sources.
///
/// Manual sources are checked first, allowing local files like utility source code
/// to be available during the build process.
pub fn copy_manual_sources(spec: &PackageSpec, build_dir: &Path) -> Result<()> {
if spec.manual_sources.is_empty() {
return Ok(());
}
fs::create_dir_all(build_dir)?;
println!("Copying {} manual source(s)...", spec.manual_sources.len());
for manual in &spec.manual_sources {
let src_path = spec.spec_dir.join(&manual.file);
if !src_path.exists() {
bail!(
"Manual source not found: {} (expected at {})",
manual.file,
src_path.display()
);
}
// Verify SHA256 if provided (unless "skip")
if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip") {
let actual_hash = compute_sha256(&src_path)?;
if actual_hash != *expected_hash {
bail!(
"SHA256 mismatch for {}: expected {}, got {}",
manual.file,
expected_hash,
actual_hash
);
}
}
// Determine destination
let dest_name = manual.dest.as_deref().unwrap_or(&manual.file);
let dest_path = build_dir.join(dest_name);
// Create parent directories if needed
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
println!(" {} -> {}", manual.file, dest_path.display());
fs::copy(&src_path, &dest_path).with_context(|| {
format!(
"Failed to copy {} to {}",
src_path.display(),
dest_path.display()
)
})?;
}
Ok(())
}
fn compute_sha256(path: &Path) -> Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
/// Fetch + extract all sources.
///
/// Returns the primary source directory (the first source entry, or work_dir for manual-only packages).
pub fn prepare(spec: &PackageSpec, cache_dir: &Path, build_dir: &Path) -> Result<PathBuf> {
// If no remote sources, create work_dir and copy manual sources there
if spec.sources().is_empty() {
let work_dir = build_dir.join(&spec.package.name);
fs::create_dir_all(&work_dir)?;
copy_manual_sources(spec, &work_dir)?;
return Ok(work_dir);
}
// Copy manual sources first (before any remote fetching)
copy_manual_sources(spec, build_dir)?;
let mut primary: Option<PathBuf> = None;
for (idx, src) in spec.sources().iter().enumerate() {
let src_dir = prepare_one(spec, src, cache_dir, build_dir)
.with_context(|| format!("Failed to prepare source #{}", idx + 1))?;
if idx == 0 {
primary = Some(src_dir);
}
}
primary.ok_or_else(|| anyhow::anyhow!("No sources in spec"))
}
fn prepare_one(
spec: &PackageSpec,
source: &crate::package::Source,
cache_dir: &Path,
build_dir: &Path,
) -> Result<PathBuf> {
let url = spec.expand_vars(&source.url);
let extract_dir_name = spec.expand_vars(&source.extract_dir);
// Local file:// handling (directories or archives)
if let Some(path_str) = url.strip_prefix("file://") {
let local_path = PathBuf::from(path_str);
if local_path.is_dir() {
let dst = build_dir.join(&extract_dir_name);
if dst.exists() {
fs::remove_dir_all(&dst)?;
}
copy_dir_recursive(&local_path, &dst)?;
hooks::post_extract(spec, source, &dst, cache_dir)?;
return Ok(dst);
} else if local_path.is_file() {
let src_dir = extractor::extract_archive(&local_path, spec, source, build_dir)?;
hooks::post_extract(spec, source, &src_dir, cache_dir)?;
return Ok(src_dir);
} else {
bail!("Local file source not found: {}", local_path.display());
}
}
// Heuristic: if the URL contains '#', treat it as a git URL with a revision.
// (except when it clearly looks like an archive URL)
if let Some((base, rev)) = split_git_url(&url) {
let checkout_dir = build_dir.join(&extract_dir_name);
git::checkout(
&base,
&rev,
&checkout_dir,
&cache_dir.join("git"),
&spec.package.name,
)?;
hooks::post_extract(spec, source, &checkout_dir, cache_dir)?;
return Ok(checkout_dir);
}
let archive_path = fetcher::fetch_archive(spec, source, cache_dir)?;
let src_dir = extractor::extract_archive(&archive_path, spec, source, build_dir)?;
hooks::post_extract(spec, source, &src_dir, cache_dir)?;
Ok(src_dir)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in WalkDir::new(src) {
let entry = entry?;
let rel = entry.path().strip_prefix(src).unwrap();
let target = dst.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target)?;
} else if entry.file_type().is_symlink() {
let target_link = fs::read_link(entry.path())?;
unix_fs::symlink(target_link, &target)?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
fn split_git_url(url: &str) -> Option<(String, String)> {
// Check for explicit revision with #
if let Some((base, rev)) = url.split_once('#') {
// Ignore fragment for obvious archives.
let lower = base.to_ascii_lowercase();
let is_archive = lower.ends_with(".tar.gz")
|| lower.ends_with(".tgz")
|| lower.ends_with(".tar.xz")
|| lower.ends_with(".txz")
|| lower.ends_with(".tar.bz2")
|| lower.ends_with(".tbz2")
|| lower.ends_with(".zip")
|| lower.ends_with(".tar");
if is_archive {
return None;
}
if rev.trim().is_empty() {
// Empty revision after # - use HEAD
return Some((base.to_string(), "HEAD".to_string()));
}
return Some((base.to_string(), rev.to_string()));
}
// Check for bare .git URL without revision - default to HEAD
let lower = url.to_ascii_lowercase();
if lower.ends_with(".git") {
return Some((url.to_string(), "HEAD".to_string()));
}
None
}
#[cfg(test)]
mod tests {
use super::split_git_url;
#[test]
fn split_git_url_accepts_git_with_rev() {
let (base, rev) = split_git_url("https://example.com/repo.git#v1.2.3").unwrap();
assert_eq!(base, "https://example.com/repo.git");
assert_eq!(rev, "v1.2.3");
}
#[test]
fn split_git_url_accepts_bare_git_url() {
let (base, rev) = split_git_url("https://example.com/repo.git").unwrap();
assert_eq!(base, "https://example.com/repo.git");
assert_eq!(rev, "HEAD");
}
#[test]
fn split_git_url_rejects_archive_urls() {
assert!(split_git_url("https://example.com/foo.tar.gz#deadbeef").is_none());
assert!(split_git_url("https://example.com/foo.zip#v1").is_none());
}
#[test]
fn split_git_url_empty_rev_defaults_to_head() {
let (base, rev) = split_git_url("https://example.com/repo.git#").unwrap();
assert_eq!(base, "https://example.com/repo.git");
assert_eq!(rev, "HEAD");
}
}