ce5fefa833
- split CLI definitions and command execution out of main.rs into new cli.rs and commands.rs modules\n- add transaction hook support via /etc/depot.d/hooks/*.toml with pre/post phases, operation/package/path filters, wildcard negation, and optional affected-path stdin\n- execute transaction hooks around install/update/remove operations and add package-action prompts for install/build/remove flows\n- require and verify detached minisign signatures for binary package archives when allow_unsigned=false, including cache handling and tests\n- persist optional dependencies from package metadata into repo index data and include optional deps in produced metadata\n- update dependency/planner logic to ignore dependencies provided by local split outputs and stop pulling test deps into install planning\n- auto-disable tests when required test dependencies are missing instead of hard-failing build/install paths\n- improve autotools defaults by injecting standard install directory flags when supported and preserving user overrides\n- remove static .a archives during staging by default with new build.flags.no_delete_static escape hatch\n- preserve symlinks and file metadata (permissions/timestamps) in extractor fallback copy path and add regression tests\n- refresh README docs for detached package signatures, optional dependencies, and transaction hooks\n- adjust dependency set: add filetime, disable reqwest default features, and simplify git2/OpenSSL linkage
376 lines
12 KiB
Rust
376 lines
12 KiB
Rust
//! 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;
|
|
|
|
fn is_internal_staging_rel_path(rel_path: &Path) -> bool {
|
|
let s = rel_path.to_string_lossy();
|
|
let p = s.trim_start_matches('/');
|
|
p == crate::staging::INTERNAL_DEPOT_DIR
|
|
|| p.strip_prefix(crate::staging::INTERNAL_DEPOT_DIR)
|
|
.is_some_and(|rest| rest.starts_with('/'))
|
|
}
|
|
|
|
fn license_value(licenses: &[String]) -> toml::Value {
|
|
if licenses.len() == 1 {
|
|
toml::Value::String(licenses[0].clone())
|
|
} else {
|
|
toml::Value::Array(
|
|
licenses
|
|
.iter()
|
|
.map(|license| toml::Value::String(license.clone()))
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
crate::log_info!("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 19 if not specified)
|
|
let level = self
|
|
.config
|
|
.package_overrides
|
|
.get("compression_level")
|
|
.and_then(|v| v.as_integer())
|
|
.unwrap_or(19) 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;
|
|
}
|
|
if is_internal_staging_rel_path(rel_path) {
|
|
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()?;
|
|
|
|
crate::log_info!("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(),
|
|
license_value(&self.spec.package.license),
|
|
);
|
|
|
|
// Add provides
|
|
map.insert(
|
|
"provides".to_string(),
|
|
toml::Value::Array(
|
|
self.spec
|
|
.alternatives
|
|
.provides
|
|
.iter()
|
|
.map(|s| toml::Value::String(s.clone()))
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
// Add install-relevant dependency kinds for repo/runtime consumers.
|
|
let mut deps = toml::map::Map::new();
|
|
deps.insert(
|
|
"runtime".to_string(),
|
|
toml::Value::Array(
|
|
self.spec
|
|
.dependencies
|
|
.runtime
|
|
.iter()
|
|
.map(|s| toml::Value::String(s.clone()))
|
|
.collect(),
|
|
),
|
|
);
|
|
deps.insert(
|
|
"optional".to_string(),
|
|
toml::Value::Array(
|
|
self.spec
|
|
.dependencies
|
|
.optional
|
|
.iter()
|
|
.map(|s| toml::Value::String(s.clone()))
|
|
.collect(),
|
|
),
|
|
);
|
|
map.insert("dependencies".to_string(), toml::Value::Table(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 is_internal_staging_rel_path(Path::new(&relative)) {
|
|
continue;
|
|
}
|
|
|
|
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: vec!["MIT".into()],
|
|
},
|
|
packages: Vec::new(),
|
|
alternatives: Alternatives::default(),
|
|
manual_sources: Vec::new(),
|
|
source: Vec::new(),
|
|
build: Build {
|
|
build_type: BuildType::Custom,
|
|
flags: BuildFlags::default(),
|
|
},
|
|
dependencies: Dependencies::default(),
|
|
package_alternatives: Default::default(),
|
|
package_dependencies: Default::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_collect_files_skips_internal_output_staging() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let dest = tmp.path();
|
|
fs::create_dir_all(dest.join("usr/bin")).unwrap();
|
|
fs::create_dir_all(dest.join(".depot/outputs/clang/usr/bin")).unwrap();
|
|
fs::write(dest.join("usr/bin/foo"), "x").unwrap();
|
|
fs::write(dest.join(".depot/outputs/clang/usr/bin/clang"), "x").unwrap();
|
|
|
|
let packager = mk_packager(dest.to_path_buf());
|
|
let mut files = Vec::new();
|
|
packager.collect_files(dest, dest, &mut files).unwrap();
|
|
|
|
assert!(files.contains(&"usr/bin/foo".to_string()));
|
|
assert!(!files.iter().any(|f| f.starts_with(".depot/outputs/")));
|
|
}
|
|
|
|
#[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("runtime").unwrap().as_array().unwrap().is_empty());
|
|
assert!(deps.get("optional").unwrap().as_array().unwrap().is_empty());
|
|
assert!(deps.get("build").is_none());
|
|
assert!(deps.get("test").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_metadata_toml_with_multiple_licenses() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let dest = tmp.path();
|
|
|
|
let mut packager = mk_packager(dest.to_path_buf());
|
|
packager.spec.package.license = vec!["MIT".into(), "Apache-2.0".into()];
|
|
packager.generate_metadata_toml().unwrap();
|
|
|
|
let meta_path = dest.join(".metadata.toml");
|
|
let content = fs::read_to_string(meta_path).unwrap();
|
|
let val: toml::Value = toml::from_str(&content).unwrap();
|
|
let arr = val
|
|
.get("license")
|
|
.and_then(|v| v.as_array())
|
|
.expect("license should be an array");
|
|
assert_eq!(arr.len(), 2);
|
|
assert_eq!(arr[0].as_str(), Some("MIT"));
|
|
assert_eq!(arr[1].as_str(), Some("Apache-2.0"));
|
|
}
|
|
}
|