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
+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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user