diff --git a/config/redbear-desktop.toml b/config/redbear-desktop.toml index 3c7c03f4..0882a1a3 100644 --- a/config/redbear-desktop.toml +++ b/config/redbear-desktop.toml @@ -15,3 +15,6 @@ redbear-release = {} # Terminal file manager (Midnight Commander port) mc = {} + +# Package builder (cub -S/-B/-G CLI) +cub = {} diff --git a/config/redbear-full.toml b/config/redbear-full.toml index 9f75792e..db5fa856 100644 --- a/config/redbear-full.toml +++ b/config/redbear-full.toml @@ -30,6 +30,9 @@ firmware-loader = {} evdevd = {} udev-shim = {} +# Package builder (cub -S/-B/-G CLI) +cub = {} + # RBOS meta-package (dependencies, default config) redbear-meta = {} diff --git a/config/redbear-minimal.toml b/config/redbear-minimal.toml index 5f1fda62..6f89f035 100644 --- a/config/redbear-minimal.toml +++ b/config/redbear-minimal.toml @@ -15,6 +15,9 @@ redbear-release = {} # Terminal file manager mc = {} +# Package builder +cub = {} + # Firmware loading firmware-loader = {} diff --git a/local/recipes/system/cub/recipe.toml b/local/recipes/system/cub/recipe.toml new file mode 100644 index 00000000..ba9658e5 --- /dev/null +++ b/local/recipes/system/cub/recipe.toml @@ -0,0 +1,9 @@ +[source] +path = "source" + +[build] +template = "cargo" +cargopath = "cub-cli" + +[package] +dependencies = ["pkgutils"] diff --git a/local/recipes/system/cub/source/Cargo.toml b/local/recipes/system/cub/source/Cargo.toml new file mode 100644 index 00000000..593f6fc7 --- /dev/null +++ b/local/recipes/system/cub/source/Cargo.toml @@ -0,0 +1,28 @@ +[workspace] +resolver = "2" +members = [ + "cub-lib", + "cub-cli", +] +default-members = [ + "cub-cli", +] + +[workspace.package] +version = "0.1.0" +description = "Red Bear OS Package Builder" +license = "MIT" +authors = ["Red Bear OS Contributors"] +repository = "https://gitlab.redox-os.org/redox-os/redox" +edition = "2021" + +[workspace.dependencies] +serde = "1" +serde_derive = "1" +toml = "0.8.2" +thiserror = "2" +clap = { version = "4.3", features = ["cargo", "derive"] } + +[patch.crates-io] +ring = { git = "https://gitlab.redox-os.org/redox-os/ring.git", branch = "redox-0.17.8" } +cc-11 = { git = "https://github.com/tea/cc-rs", branch="riscv-abi-arch-fix", package = "cc" } diff --git a/local/recipes/system/cub/source/cub-cli/Cargo.toml b/local/recipes/system/cub/source/cub-cli/Cargo.toml new file mode 100644 index 00000000..d54ad3ba --- /dev/null +++ b/local/recipes/system/cub/source/cub-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cub-cli" +default-run = "cub" +description = "Red Bear OS Package Builder CLI" + +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "cub" +path = "src/main.rs" + +[dependencies] +cub-lib = { path = "../cub-lib" } +redox-pkg = { path = "../../../../../../recipes/core/pkgutils/source/pkg-lib", default-features = false, features = ["indicatif"] } +clap = { workspace = true } +termion = "4.0.6" diff --git a/local/recipes/system/cub/source/cub-cli/src/main.rs b/local/recipes/system/cub/source/cub-cli/src/main.rs new file mode 100644 index 00000000..d67b7fb6 --- /dev/null +++ b/local/recipes/system/cub/source/cub-cli/src/main.rs @@ -0,0 +1,801 @@ +use std::cell::RefCell; +use std::env; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use clap::{Parser, Subcommand}; +use cub::converter::{self, ConversionReport, ConversionResult}; +use cub::error::CubError; +use cub::rbpkgbuild::RbPkgBuild; +use cub::rbsrcinfo::RbSrcInfo; +use cub::sandbox::SandboxConfig; +use pkg::callback::IndicatifCallback; +use pkg::{Library, PackageName}; + +const DEFAULT_TARGET: &str = "x86_64-unknown-redox"; +const HOST_INSTALL_PATH: &str = "/tmp/pkg_install"; +const REDOX_INSTALL_PATH: &str = "/"; +const PKG_DOWNLOAD_DIR: &str = "/tmp/pkg_download/"; +const CUB_CACHE_DIR: &str = "/tmp/cub_cache/"; +const DEFAULT_BUR_REPO_URL: &str = "https://gitlab.redox-os.org/redox-os/bur.git"; +const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org"; +const PUBLIC_KEY_FILE: &str = "id_ed25519.pub.toml"; +const DEFAULT_SECRET_KEY_FILE: &str = "id_ed25519.toml"; + +struct CookbookAdapter; + +impl CookbookAdapter { + fn write_recipe_dir(rbpkg: &RbPkgBuild, recipe_dir: &Path) -> Result<(), CubError> { + fs::create_dir_all(recipe_dir)?; + let recipe = cub::cookbook::generate_recipe(rbpkg)?; + fs::write(recipe_dir.join("recipe.toml"), recipe)?; + Ok(()) + } +} + +struct PkgbuildConverter; + +impl PkgbuildConverter { + fn convert(content: &str) -> Result { + converter::convert_pkgbuild(content) + } +} + +struct PackageCreator; + +impl PackageCreator { + fn create_pkgar( + stage_dir: &Path, + output_path: &Path, + secret_key_path: &Path, + ) -> Result<(), CubError> { + cub::package::PackageCreator::create_from_stage(stage_dir, output_path, secret_key_path) + } + + fn generate_package_toml(rbpkg: &RbPkgBuild) -> String { + cub::package::PackageCreator::generate_package_toml(rbpkg) + } +} + +#[derive(Debug, Parser)] +#[command(name = "cub")] +#[command(version)] +#[command(about = "Red Bear OS Package Builder")] +#[command(arg_required_else_help = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Install a package from the official repo or BUR + Install { package: String }, + /// Search packages in the official repo and cached BUR + Search { query: String }, + /// Build and install a local RBPKGBUILD directory + Build { dir: String }, + /// Fetch a BUR recipe into the current directory + Get { package: String }, + /// Inspect an installed package or local RBPKGBUILD + Inspect { target: String }, + /// Convert an AUR PKGBUILD into an RBPKGBUILD tree + ImportAur { target: String }, + /// Update all installed packages + UpdateAll, + /// Remove cub and pkg download caches + CleanCache, +} + +struct AppContext { + install_path: PathBuf, + target: String, +} + +impl AppContext { + fn new() -> Self { + let install_path = if cfg!(target_os = "redox") { + PathBuf::from(REDOX_INSTALL_PATH) + } else { + PathBuf::from(HOST_INSTALL_PATH) + }; + + let target = if cfg!(target_os = "redox") { + env::var("TARGET").unwrap_or_else(|_| DEFAULT_TARGET.to_string()) + } else { + DEFAULT_TARGET.to_string() + }; + + Self { + install_path, + target, + } + } + + fn open_library(&self) -> Result { + let callback = new_pkg_callback(); + Library::new(&self.install_path, &self.target, callback) + } + + fn open_local_library( + &self, + source_dir: &Path, + pubkey_dir: &Path, + ) -> Result { + let callback = new_pkg_callback(); + Library::new_local( + source_dir, + pubkey_dir, + &self.install_path, + &self.target, + callback, + ) + } +} + +fn main() -> Result<(), Box> { + let args = rewrite_shortcut_args(env::args_os())?; + let cli = Cli::parse_from(args); + let context = AppContext::new(); + + match cli.command { + Commands::Install { package } => install_package(&context, &package)?, + Commands::Search { query } => search_packages(&context, &query)?, + Commands::Build { dir } => build_local_dir(&context, Path::new(&dir))?, + Commands::Get { package } => fetch_bur_recipe(&package)?, + Commands::Inspect { target } => inspect_target(&context, &target)?, + Commands::ImportAur { target } => import_aur_target(&target)?, + Commands::UpdateAll => update_all(&context)?, + Commands::CleanCache => clean_cache()?, + } + + Ok(()) +} + +fn rewrite_shortcut_args( + args: impl IntoIterator, +) -> Result, Box> { + let collected: Vec = args.into_iter().collect(); + if collected.len() <= 1 { + return Ok(collected); + } + + let binary = collected[0].clone(); + let rest = &collected[1..]; + let Some(flag) = rest.first().and_then(|value| value.to_str()) else { + return Ok(collected); + }; + + match flag { + "-S" => rewrite_value_command(binary, rest, "install", "package"), + "-Ss" => rewrite_value_command(binary, rest, "search", "query"), + "-B" => rewrite_value_command(binary, rest, "build", "dir"), + "-G" => rewrite_value_command(binary, rest, "get", "package"), + "-Pi" => rewrite_value_command(binary, rest, "inspect", "target"), + "--import-aur" => rewrite_value_command(binary, rest, "import-aur", "target"), + "-Sua" => rewrite_flag_command(binary, rest, "update-all"), + "-Sc" => rewrite_flag_command(binary, rest, "clean-cache"), + _ => Ok(collected), + } +} + +fn rewrite_value_command( + binary: OsString, + rest: &[OsString], + subcommand: &str, + value_name: &str, +) -> Result, Box> { + let Some(value) = rest.get(1) else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("missing {value_name} for {}", rest[0].to_string_lossy()), + ) + .into()); + }; + + let mut rewritten = vec![binary, OsString::from(subcommand), value.clone()]; + rewritten.extend(rest.iter().skip(2).cloned()); + Ok(rewritten) +} + +fn rewrite_flag_command( + binary: OsString, + rest: &[OsString], + subcommand: &str, +) -> Result, Box> { + let mut rewritten = vec![binary, OsString::from(subcommand)]; + rewritten.extend(rest.iter().skip(1).cloned()); + Ok(rewritten) +} + +fn new_pkg_callback() -> Rc> { + let mut callback = IndicatifCallback::new(); + callback.set_interactive(true); + Rc::new(RefCell::new(callback)) +} + +fn install_package(context: &AppContext, package: &str) -> Result<(), Box> { + let package_name = PackageName::new(package.to_string())?; + let mut library = context.open_library()?; + + match library.install(vec![package_name.clone()], false) { + Ok(()) => { + let applied = apply_library_changes(&mut library)?; + println!( + "Installed {} from the official repository ({} change(s)).", + package, applied + ); + Ok(()) + } + Err(pkg::backend::Error::PackageNotFound(_)) => { + println!( + "{} was not found in the official repository. Trying BUR...", + package + ); + let bur_dir = ensure_bur_package_dir(package)?; + build_local_dir(context, &bur_dir) + } + Err(error) => Err(Box::new(error)), + } +} + +fn search_packages(context: &AppContext, query: &str) -> Result<(), Box> { + let mut library = context.open_library()?; + let official_matches = library.search(query)?; + let bur_matches = search_cached_bur(query)?; + + if official_matches.is_empty() { + println!("Official repo: no matches for {query:?}"); + } else { + println!("Official repo:"); + for (name, score) in official_matches { + println!(" {} ({score:.2})", name); + } + } + + if bur_matches.is_empty() { + println!("Cached BUR: no matches for {query:?}"); + } else { + println!("Cached BUR:"); + for entry in bur_matches { + if let Some(description) = &entry.description { + println!(" {} - {}", entry.name, description); + } else { + println!(" {}", entry.name); + } + } + } + + Ok(()) +} + +fn build_local_dir(context: &AppContext, dir: &Path) -> Result<(), Box> { + let rbpkg_path = dir.join("RBPKGBUILD"); + let rbpkg = RbPkgBuild::from_file(&rbpkg_path)?; + rbpkg.validate()?; + + let work_dir = create_temp_dir("cub-build")?; + let recipe_dir = work_dir.join(&rbpkg.package.name); + CookbookAdapter::write_recipe_dir(&rbpkg, &recipe_dir)?; + + let sandbox = SandboxConfig::new(&work_dir); + sandbox.setup()?; + + let mut command = Command::new("repo"); + command.arg("cook"); + command.arg(&recipe_dir); + command.envs(sandbox.env_vars()); + + let status = command.status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "repo cook {} failed with status {status}", + recipe_dir.display() + )))); + } + + let stage_dir = find_stage_dir(&sandbox, &work_dir)?; + let secret_key_path = resolve_secret_key_path()?; + let public_key_dir = resolve_public_key_dir(&secret_key_path)?; + + let local_repo_dir = work_dir.join("local-repo"); + let target_repo_dir = local_repo_dir.join(&context.target); + fs::create_dir_all(&target_repo_dir)?; + + let pkgar_path = target_repo_dir.join(format!("{}.pkgar", rbpkg.package.name)); + PackageCreator::create_pkgar(&stage_dir, &pkgar_path, &secret_key_path)?; + + let package_toml_path = target_repo_dir.join(format!("{}.toml", rbpkg.package.name)); + fs::write( + package_toml_path, + PackageCreator::generate_package_toml(&rbpkg), + )?; + + let package_name = PackageName::new(rbpkg.package.name.clone())?; + let mut library = context.open_local_library(&local_repo_dir, &public_key_dir)?; + library.install(vec![package_name], false)?; + let applied = apply_library_changes(&mut library)?; + + println!( + "Built and installed {} successfully ({} change(s)).", + rbpkg.package.name, applied + ); + + Ok(()) +} + +fn fetch_bur_recipe(package: &str) -> Result<(), Box> { + let source_dir = ensure_bur_package_dir(package)?; + let destination = env::current_dir()?.join(package); + if destination.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("destination already exists: {}", destination.display()), + ) + .into()); + } + + copy_dir_recursive(&source_dir, &destination)?; + println!( + "Fetched BUR recipe {} to {}.", + package, + destination.display() + ); + Ok(()) +} + +fn inspect_target(context: &AppContext, target: &str) -> Result<(), Box> { + let path = Path::new(target); + if path.exists() { + inspect_rbpkgbuild_path(path)?; + return Ok(()); + } + + let mut library = context.open_library()?; + let info = library.info(PackageName::new(target.to_string())?)?; + println!("{info:#?}"); + Ok(()) +} + +fn import_aur_target(target: &str) -> Result<(), Box> { + let repo_url = aur_repo_url(target); + let clone_dir = create_temp_dir("cub-aur")?; + + let status = Command::new("git") + .arg("clone") + .arg("--depth") + .arg("1") + .arg(&repo_url) + .arg(&clone_dir) + .status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "failed to clone AUR source from {repo_url}" + )))); + } + + let pkgbuild_path = clone_dir.join("PKGBUILD"); + let pkgbuild = fs::read_to_string(&pkgbuild_path)?; + let conversion = PkgbuildConverter::convert(&pkgbuild)?; + let output_dir = env::current_dir()?.join(&conversion.rbpkg.package.name); + + fs::create_dir_all(&output_dir)?; + fs::create_dir_all(output_dir.join("patches"))?; + fs::create_dir_all(output_dir.join("import"))?; + + fs::write(output_dir.join("RBPKGBUILD"), conversion.rbpkg.to_toml()?)?; + fs::write( + output_dir.join(".RBSRCINFO"), + RbSrcInfo::from_rbpkgbuild(&conversion.rbpkg).to_string(), + )?; + fs::write(output_dir.join("import").join("PKGBUILD"), pkgbuild)?; + + let report = render_conversion_report(&conversion.report); + fs::write(output_dir.join("import").join("report.txt"), &report)?; + + println!("Imported AUR package into {}", output_dir.display()); + println!("{report}"); + Ok(()) +} + +fn update_all(context: &AppContext) -> Result<(), Box> { + let mut library = context.open_library()?; + library.update(Vec::new())?; + let applied = apply_library_changes(&mut library)?; + println!("Updated installed packages ({} change(s)).", applied); + Ok(()) +} + +fn clean_cache() -> Result<(), Box> { + remove_dir_if_exists(Path::new(PKG_DOWNLOAD_DIR))?; + remove_dir_if_exists(Path::new(CUB_CACHE_DIR))?; + println!("Removed package caches from {PKG_DOWNLOAD_DIR} and {CUB_CACHE_DIR}."); + Ok(()) +} + +fn apply_library_changes(library: &mut Library) -> Result> { + match library.apply() { + Ok(changes) => Ok(changes), + Err(error) => { + if let Err(abort_error) = library.abort() { + eprintln!("Failed to abort package transaction: {abort_error}"); + } + Err(Box::new(error)) + } + } +} + +fn inspect_rbpkgbuild_path(path: &Path) -> Result<(), Box> { + let rbpkg_path = if path.is_dir() { + path.join("RBPKGBUILD") + } else { + path.to_path_buf() + }; + let rbpkg = RbPkgBuild::from_file(&rbpkg_path)?; + + println!("Package:"); + println!(" name = {}", rbpkg.package.name); + println!(" version = {}", rbpkg.package.version); + println!(" release = {}", rbpkg.package.release); + println!(" description = {}", rbpkg.package.description); + println!(" homepage = {}", rbpkg.package.homepage); + println!(" license = {:?}", rbpkg.package.license); + println!(" architectures = {:?}", rbpkg.package.architectures); + println!(" maintainers = {:?}", rbpkg.package.maintainers); + + println!("Source:"); + for source in &rbpkg.source.sources { + println!( + " {:?}: url={}, rev={}, branch={}, sha256={}", + source.source_type, source.url, source.rev, source.branch, source.sha256 + ); + } + + println!("Dependencies:"); + println!(" build = {:?}", rbpkg.dependencies.build); + println!(" runtime = {:?}", rbpkg.dependencies.runtime); + println!(" check = {:?}", rbpkg.dependencies.check); + println!(" optional = {:?}", rbpkg.dependencies.optional); + println!(" provides = {:?}", rbpkg.dependencies.provides); + println!(" conflicts = {:?}", rbpkg.dependencies.conflicts); + + println!("Build:"); + println!(" template = {:?}", rbpkg.build.template); + println!(" release = {}", rbpkg.build.release); + println!(" features = {:?}", rbpkg.build.features); + println!(" args = {:?}", rbpkg.build.args); + println!(" build_dir = {}", rbpkg.build.build_dir); + println!(" prepare = {:?}", rbpkg.build.prepare); + println!(" build_script = {:?}", rbpkg.build.build_script); + println!(" check = {:?}", rbpkg.build.check); + println!(" install_script = {:?}", rbpkg.build.install_script); + + println!("Install:"); + println!(" bins = {:?}", rbpkg.install.bins); + println!(" libs = {:?}", rbpkg.install.libs); + println!(" headers = {:?}", rbpkg.install.headers); + println!(" docs = {:?}", rbpkg.install.docs); + println!(" man = {:?}", rbpkg.install.man); + + println!("Patches:"); + println!(" files = {:?}", rbpkg.patches.files); + + println!("Compat:"); + println!(" imported_from = {}", rbpkg.compat.imported_from); + println!(" conversion_status = {:?}", rbpkg.compat.conversion_status); + println!(" target = {}", rbpkg.compat.target); + + println!("Policy:"); + println!(" allow_network = {}", rbpkg.policy.allow_network); + println!(" allow_tests = {}", rbpkg.policy.allow_tests); + println!(" review_required = {}", rbpkg.policy.review_required); + + println!("Generated .RBSRCINFO:"); + println!("{}", rbpkg.to_srcinfo().to_string()); + + Ok(()) +} + +struct BurMatch { + name: String, + description: Option, +} + +fn search_cached_bur(query: &str) -> Result, Box> { + let repo_dir = bur_repo_dir(); + if !repo_dir.exists() { + return Ok(Vec::new()); + } + + let mut matches = Vec::new(); + let lowered_query = query.to_ascii_lowercase(); + for entry in fs::read_dir(repo_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name == ".git" { + continue; + } + + let rbpkg_path = path.join("RBPKGBUILD"); + let mut description = None; + let mut matched = name.to_ascii_lowercase().contains(&lowered_query); + if rbpkg_path.is_file() { + if let Ok(pkg) = RbPkgBuild::from_file(&rbpkg_path) { + if pkg + .package + .name + .to_ascii_lowercase() + .contains(&lowered_query) + || pkg + .package + .description + .to_ascii_lowercase() + .contains(&lowered_query) + { + matched = true; + } + if !pkg.package.description.trim().is_empty() { + description = Some(pkg.package.description); + } + } + } + + if matched { + matches.push(BurMatch { + name: name.to_string(), + description, + }); + } + } + + matches.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(matches) +} + +fn ensure_bur_package_dir(package: &str) -> Result> { + let repo_dir = sync_bur_repo()?; + let package_dir = repo_dir.join(package); + if package_dir.is_dir() { + Ok(package_dir) + } else { + Err(Box::new(CubError::PackageNotFound(format!( + "{package} not found in BUR cache {}", + repo_dir.display() + )))) + } +} + +fn sync_bur_repo() -> Result> { + let repo_dir = bur_repo_dir(); + let parent = repo_dir + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid BUR cache path"))?; + fs::create_dir_all(parent)?; + + if repo_dir.join(".git").is_dir() { + let status = Command::new("git") + .arg("pull") + .arg("--ff-only") + .current_dir(&repo_dir) + .status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "failed to update BUR cache at {}", + repo_dir.display() + )))); + } + } else { + let status = Command::new("git") + .arg("clone") + .arg(default_bur_repo_url()) + .arg(&repo_dir) + .status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "failed to clone BUR repository into {}", + repo_dir.display() + )))); + } + } + + Ok(repo_dir) +} + +fn default_bur_repo_url() -> String { + env::var("CUB_BUR_REPO_URL").unwrap_or_else(|_| DEFAULT_BUR_REPO_URL.to_string()) +} + +fn bur_repo_dir() -> PathBuf { + PathBuf::from(CUB_CACHE_DIR).join("bur") +} + +fn aur_repo_url(target: &str) -> String { + if target.contains("://") || target.ends_with(".git") { + target.to_string() + } else { + format!("{DEFAULT_AUR_BASE_URL}/{}.git", target) + } +} + +fn resolve_secret_key_path() -> Result> { + if let Some(path) = env::var_os("CUB_PKGAR_SECRET_KEY") { + let candidate = PathBuf::from(path); + if candidate.is_file() { + return Ok(candidate); + } + } + + let home = env::var_os("HOME").map(PathBuf::from); + let candidates = [ + home.as_ref() + .map(|path| path.join(".pkg").join(DEFAULT_SECRET_KEY_FILE)), + Some(PathBuf::from("/etc/pkg").join(DEFAULT_SECRET_KEY_FILE)), + Some(PathBuf::from("/pkg").join(DEFAULT_SECRET_KEY_FILE)), + Some(env::current_dir()?.join(DEFAULT_SECRET_KEY_FILE)), + ]; + + for candidate in candidates.into_iter().flatten() { + if candidate.is_file() { + return Ok(candidate); + } + } + + Err(Box::new(CubError::BuildFailed( + "could not locate a pkgar secret key; set CUB_PKGAR_SECRET_KEY".to_string(), + ))) +} + +fn resolve_public_key_dir(secret_key_path: &Path) -> Result> { + if let Some(path) = env::var_os("CUB_PKGAR_PUBKEY_DIR") { + let candidate = PathBuf::from(path); + if candidate.join(PUBLIC_KEY_FILE).is_file() { + return Ok(candidate); + } + } + + let Some(parent) = secret_key_path.parent() else { + return Err(Box::new(CubError::BuildFailed(format!( + "could not determine public key directory for {}", + secret_key_path.display() + )))); + }; + + if parent.join(PUBLIC_KEY_FILE).is_file() { + Ok(parent.to_path_buf()) + } else { + Err(Box::new(CubError::BuildFailed(format!( + "missing {} in {}", + PUBLIC_KEY_FILE, + parent.display() + )))) + } +} + +fn find_stage_dir( + sandbox: &SandboxConfig, + search_root: &Path, +) -> Result> { + let direct_candidates = [ + sandbox.stage_dir.clone(), + sandbox.destdir.clone(), + search_root.join("stage"), + search_root.join("destdir"), + ]; + + for candidate in direct_candidates { + if directory_has_entries(&candidate)? { + return Ok(candidate); + } + } + + let mut stack = vec![search_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + + if matches!(name, "stage" | "destdir") && directory_has_entries(&path)? { + return Ok(path); + } + + stack.push(path); + } + } + + Err(Box::new(CubError::BuildFailed(format!( + "unable to locate a populated stage directory under {}", + search_root.display() + )))) +} + +fn directory_has_entries(path: &Path) -> Result { + if !path.is_dir() { + return Ok(false); + } + + Ok(fs::read_dir(path)?.next().transpose()?.is_some()) +} + +fn render_conversion_report(report: &ConversionReport) -> String { + let mut output = String::new(); + output.push_str(&format!("Conversion: {:?}\n", report.status)); + + if !report.warnings.is_empty() { + output.push_str("\nWarnings:\n"); + for warning in &report.warnings { + output.push_str(&format!("- {warning}\n")); + } + } + + if !report.actions_required.is_empty() { + output.push_str("\nActions required:\n"); + for action in &report.actions_required { + output.push_str(&format!("- {action}\n")); + } + } + + output +} + +fn create_temp_dir(prefix: &str) -> Result> { + let base = env::temp_dir(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + for attempt in 0..128 { + let candidate = base.join(format!("{prefix}-{}-{nanos}-{attempt}", std::process::id())); + if !candidate.exists() { + fs::create_dir_all(&candidate)?; + return Ok(candidate); + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("failed to allocate temporary directory for {prefix}"), + ) + .into()) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Box> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let entry_path = entry.path(); + let destination_path = dst.join(entry.file_name()); + if entry_path.is_dir() { + copy_dir_recursive(&entry_path, &destination_path)?; + } else { + fs::copy(&entry_path, &destination_path)?; + } + } + Ok(()) +} + +fn remove_dir_if_exists(path: &Path) -> Result<(), io::Error> { + if path.exists() { + fs::remove_dir_all(path)?; + } + Ok(()) +} diff --git a/local/recipes/system/cub/source/cub-lib/Cargo.toml b/local/recipes/system/cub/source/cub-lib/Cargo.toml new file mode 100644 index 00000000..2d68c644 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cub-lib" +description = "Red Bear OS Package Builder Library" + +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "cub" +doctest = false + +[dependencies] +serde = { workspace = true } +serde_derive = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +hex = "0.4" +blake3 = "1" +walkdir = "2" +tempfile = "3" + +# pkgar integration for package creation +pkgar = { version = "0.2.2", optional = true } +pkgar-core = { version = "0.2.2", optional = true } +pkgar-keys = { version = "0.2.2", optional = true } + +# HTTP for source fetching +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true } + +[features] +default = ["full"] +full = ["pkgar", "pkgar-core", "pkgar-keys", "reqwest"] diff --git a/local/recipes/system/cub/source/cub-lib/src/converter.rs b/local/recipes/system/cub/source/cub-lib/src/converter.rs new file mode 100644 index 00000000..ca9a37da --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/converter.rs @@ -0,0 +1,463 @@ +use crate::deps::map_dependency; +use crate::error::CubError; +use crate::rbpkgbuild::{ + BuildSection, BuildTemplate, CompatSection, ConversionStatus, DependenciesSection, + InstallSection, PackageSection, PatchesSection, PolicySection, RbPkgBuild, SourceEntry, + SourceSection, SourceType, +}; + +pub struct ConversionResult { + pub rbpkg: RbPkgBuild, + pub report: ConversionReport, +} + +pub struct ConversionReport { + pub status: ConversionStatus, + pub warnings: Vec, + pub actions_required: Vec, +} + +pub fn convert_pkgbuild(content: &str) -> Result { + let pkgname = extract_scalar_assignment(content, "pkgname") + .ok_or_else(|| CubError::Conversion("missing pkgname in PKGBUILD".to_string()))?; + let pkgver = extract_scalar_assignment(content, "pkgver") + .ok_or_else(|| CubError::Conversion("missing pkgver in PKGBUILD".to_string()))?; + + let pkgrel = extract_scalar_assignment(content, "pkgrel") + .and_then(|value| value.parse::().ok()) + .unwrap_or(1); + let pkgdesc = extract_scalar_assignment(content, "pkgdesc").unwrap_or_default(); + let url = extract_scalar_assignment(content, "url").unwrap_or_default(); + let licenses = extract_array_assignment(content, "license").unwrap_or_default(); + let depends = extract_array_assignment(content, "depends").unwrap_or_default(); + let makedepends = extract_array_assignment(content, "makedepends").unwrap_or_default(); + let checkdepends = extract_array_assignment(content, "checkdepends").unwrap_or_default(); + let sources = extract_array_assignment(content, "source").unwrap_or_default(); + let sha256sums = extract_array_assignment(content, "sha256sums").unwrap_or_default(); + + let template = detect_build_template(content); + let mut warnings = detect_linuxisms(content); + let mut actions_required = Vec::new(); + + let mapped_runtime = map_dep_list(&depends, &mut warnings, &mut actions_required); + let mapped_build = map_dep_list(&makedepends, &mut warnings, &mut actions_required); + let mapped_check = map_dep_list(&checkdepends, &mut warnings, &mut actions_required); + + if sources.is_empty() { + warnings.push("PKGBUILD does not define any source entries".to_string()); + } + + let status = if warnings.is_empty() && actions_required.is_empty() { + ConversionStatus::Full + } else { + ConversionStatus::Partial + }; + + let rbpkg = RbPkgBuild { + format: 1, + package: PackageSection { + name: sanitize_pkgname(&pkgname), + version: pkgver, + release: pkgrel, + description: pkgdesc, + homepage: url, + license: licenses, + architectures: vec!["x86_64-unknown-redox".to_string()], + maintainers: Vec::new(), + }, + source: SourceSection { + sources: sources + .into_iter() + .enumerate() + .map(|(index, source)| { + source_from_arch(source, sha256sums.get(index).map(String::as_str)) + }) + .collect(), + }, + dependencies: DependenciesSection { + build: mapped_build, + runtime: mapped_runtime, + check: mapped_check, + optional: Vec::new(), + provides: Vec::new(), + conflicts: Vec::new(), + }, + build: BuildSection { + template, + ..BuildSection::default() + }, + install: InstallSection::default(), + patches: PatchesSection::default(), + compat: CompatSection { + imported_from: "aur".to_string(), + original_pkgbuild: content.to_string(), + conversion_status: status.clone(), + target: "x86_64-unknown-redox".to_string(), + }, + policy: PolicySection::default(), + }; + + rbpkg.validate()?; + let _ = rbpkg.to_srcinfo(); + + Ok(ConversionResult { + rbpkg, + report: ConversionReport { + status, + warnings, + actions_required, + }, + }) +} + +fn map_dep_list( + deps: &[String], + warnings: &mut Vec, + actions_required: &mut Vec, +) -> Vec { + let mut mapped = Vec::new(); + + for dep in deps { + let mapping = map_dependency(dep); + if mapping.mapped.is_empty() { + warnings.push(format!( + "dependency '{}' has no Redox mapping and was omitted", + mapping.original + )); + actions_required.push(format!( + "port or replace dependency '{}' manually", + mapping.original + )); + continue; + } + + if !mapping.is_exact { + warnings.push(format!( + "dependency '{}' mapped to '{}'", + mapping.original, mapping.mapped + )); + } + + if !mapped.contains(&mapping.mapped) { + mapped.push(mapping.mapped); + } + } + + mapped +} + +fn detect_build_template(content: &str) -> BuildTemplate { + let lowered = content.to_ascii_lowercase(); + + if lowered.contains("cargo build") || lowered.contains("cargo install") { + BuildTemplate::Cargo + } else if lowered.contains("meson setup") || lowered.contains(" meson ") { + BuildTemplate::Meson + } else if lowered.contains("cmake") { + BuildTemplate::Cmake + } else if lowered.contains("./configure") || lowered.contains(" configure ") { + BuildTemplate::Configure + } else { + BuildTemplate::Custom + } +} + +fn detect_linuxisms(content: &str) -> Vec { + let lowered = content.to_ascii_lowercase(); + let checks = [ + ( + "systemctl", + "uses systemctl, which is not available on Redox", + ), + ( + "/usr/lib/systemd", + "references /usr/lib/systemd, which is Linux-specific", + ), + ( + "systemd", + "references systemd, which is unavailable on Redox", + ), + ( + "/proc", + "references /proc, which may require Redox-specific adaptation", + ), + ]; + + let mut warnings = Vec::new(); + for (needle, warning) in checks { + if lowered.contains(needle) { + warnings.push(warning.to_string()); + } + } + warnings +} + +fn sanitize_pkgname(name: &str) -> String { + name.trim_matches('"') + .to_ascii_lowercase() + .replace('_', "-") +} + +fn source_from_arch(entry: String, sha256: Option<&str>) -> SourceEntry { + let normalized = normalize_source_entry(&entry); + let source_type = if normalized.starts_with("git+") + || normalized.starts_with("git://") + || normalized.ends_with(".git") + { + SourceType::Git + } else { + SourceType::Tar + }; + + SourceEntry { + sha256: if matches!(source_type, SourceType::Tar) { + sha256.unwrap_or_default().to_string() + } else { + String::new() + }, + url: normalized, + source_type, + rev: String::new(), + branch: String::new(), + } +} + +fn normalize_source_entry(entry: &str) -> String { + let stripped = entry + .split_once("::") + .map(|(_, value)| value) + .unwrap_or(entry) + .trim(); + + stripped + .strip_prefix("git+") + .unwrap_or(stripped) + .to_string() +} + +fn extract_scalar_assignment(content: &str, name: &str) -> Option { + extract_assignment(content, name).map(|raw| parse_scalar(&raw)) +} + +fn extract_array_assignment(content: &str, name: &str) -> Option> { + extract_assignment(content, name).map(|raw| parse_array(&raw)) +} + +fn extract_assignment(content: &str, name: &str) -> Option { + let prefix = format!("{name}="); + let mut lines = content.lines(); + + while let Some(line) = lines.next() { + let trimmed = line.trim_start(); + if !trimmed.starts_with(&prefix) { + continue; + } + + let mut value = trimmed[prefix.len()..].trim().to_string(); + if value.starts_with('(') { + let mut depth = paren_balance(&value); + while depth > 0 { + let Some(next) = lines.next() else { + break; + }; + value.push('\n'); + value.push_str(next.trim()); + depth += paren_balance(next); + } + } else { + while value.ends_with('\\') { + value.pop(); + let Some(next) = lines.next() else { + break; + }; + value.push(' '); + value.push_str(next.trim()); + } + } + + return Some(value); + } + + None +} + +fn paren_balance(input: &str) -> i32 { + let opens = input.chars().filter(|ch| *ch == '(').count() as i32; + let closes = input.chars().filter(|ch| *ch == ')').count() as i32; + opens - closes +} + +fn parse_scalar(raw: &str) -> String { + let binding = strip_unquoted_comment(raw); + let stripped = binding.trim(); + if let Some(unquoted) = unquote(stripped) { + unquoted + } else { + stripped.to_string() + } +} + +fn parse_array(raw: &str) -> Vec { + let binding = strip_unquoted_comment(raw); + let trimmed = binding.trim(); + let inner = trimmed + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(trimmed); + + shell_split(inner) +} + +fn strip_unquoted_comment(input: &str) -> String { + let mut single = false; + let mut double = false; + let mut result = String::new(); + + for ch in input.chars() { + match ch { + '\'' if !double => { + single = !single; + result.push(ch); + } + '"' if !single => { + double = !double; + result.push(ch); + } + '#' if !single && !double => break, + _ => result.push(ch), + } + } + + result +} + +fn unquote(value: &str) -> Option { + if value.len() >= 2 { + let bytes = value.as_bytes(); + let first = bytes[0] as char; + let last = bytes[value.len() - 1] as char; + if (first == '\'' && last == '\'') || (first == '"' && last == '"') { + return Some(value[1..value.len() - 1].to_string()); + } + } + None +} + +fn shell_split(input: &str) -> Vec { + let mut items = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut escape = false; + + for ch in input.chars() { + if escape { + current.push(ch); + escape = false; + continue; + } + + match ch { + '\\' => escape = true, + '\'' | '"' => { + if quote == Some(ch) { + quote = None; + } else if quote.is_none() { + quote = Some(ch); + } else { + current.push(ch); + } + } + '#' if quote.is_none() => break, + ch if ch.is_whitespace() && quote.is_none() => { + if !current.is_empty() { + items.push(current.clone()); + current.clear(); + } + } + _ => current.push(ch), + } + } + + if !current.is_empty() { + items.push(current); + } + + items +} + +#[cfg(test)] +mod tests { + use super::*; + + const PKGBUILD: &str = r#" +pkgname=demo_pkg +pkgver=1.2.3 +pkgrel=4 +pkgdesc="Demo application" +url="https://example.com/demo" +license=('MIT') +depends=('glibc' 'openssl>=1.1' 'systemd') +makedepends=('cargo' 'pkg-config') +checkdepends=('python') +source=('https://example.com/demo-1.2.3.tar.xz') +sha256sums=('abc123deadbeef') + +build() { + cargo build --release +} + +package() { + install -Dm755 target/release/demo "$pkgdir/usr/bin/demo" + systemctl --version >/dev/null +} +"#; + + #[test] + fn converts_pkgbuild_to_rbpkgbuild() { + let result = convert_pkgbuild(PKGBUILD).expect("convert PKGBUILD"); + + assert_eq!(result.rbpkg.package.name, "demo-pkg"); + assert_eq!(result.rbpkg.package.version, "1.2.3"); + assert_eq!(result.rbpkg.package.release, 4); + assert_eq!(result.rbpkg.build.template, BuildTemplate::Cargo); + assert_eq!( + result.rbpkg.dependencies.runtime, + vec!["relibc", "openssl3"] + ); + assert_eq!(result.rbpkg.dependencies.build, vec!["cargo", "pkg-config"]); + assert_eq!(result.rbpkg.dependencies.check, vec!["python"]); + assert_eq!(result.rbpkg.source.sources.len(), 1); + assert_eq!(result.rbpkg.source.sources[0].sha256, "abc123deadbeef"); + } + + #[test] + fn reports_linuxisms_and_unmapped_deps() { + let result = convert_pkgbuild(PKGBUILD).expect("convert PKGBUILD"); + + assert!(matches!(result.report.status, ConversionStatus::Partial)); + assert!(result + .report + .warnings + .iter() + .any(|w| w.contains("systemctl"))); + assert!(result + .report + .actions_required + .iter() + .any(|w| w.contains("systemd"))); + } + + #[test] + fn parses_multiline_arrays() { + let input = "depends=(\n 'glibc'\n 'zlib'\n)\n"; + let parsed = extract_array_assignment(input, "depends").expect("depends array"); + + assert_eq!(parsed, vec!["glibc", "zlib"]); + } + + #[test] + fn detects_meson_template() { + let input = "pkgname=demo\npkgver=1\nmeson setup build\n"; + assert_eq!(detect_build_template(input), BuildTemplate::Meson); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/cookbook.rs b/local/recipes/system/cub/source/cub-lib/src/cookbook.rs new file mode 100644 index 00000000..59de6962 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/cookbook.rs @@ -0,0 +1,324 @@ +use serde_derive::Serialize; + +use crate::error::CubError; +use crate::rbpkgbuild::{BuildTemplate, RbPkgBuild, SourceType}; + +#[derive(Debug, Serialize)] +struct CookbookRecipe { + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + build: CookbookBuild, + #[serde(skip_serializing_if = "Option::is_none")] + package: Option, +} + +#[derive(Debug, Default, Serialize)] +struct CookbookSource { + #[serde(skip_serializing_if = "Option::is_none")] + git: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rev: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blake3: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + patches: Vec, +} + +#[derive(Debug, Serialize)] +struct CookbookBuild { + template: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + dependencies: Vec, + #[serde(rename = "dev-dependencies", skip_serializing_if = "Vec::is_empty")] + dev_dependencies: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + cargoflags: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + configureflags: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + cmakeflags: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + mesonflags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + script: Option, +} + +#[derive(Debug, Serialize)] +struct CookbookPackage { + #[serde(skip_serializing_if = "Vec::is_empty")] + dependencies: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, +} + +pub fn generate_recipe(rbpkg: &RbPkgBuild) -> Result { + rbpkg.validate()?; + + if rbpkg.source.sources.len() > 1 { + return Err(CubError::Conversion( + "Cookbook recipe generation currently supports a single primary source".to_string(), + )); + } + + let source = rbpkg + .source + .sources + .first() + .map(convert_source) + .transpose()? + .map(|mut source| { + source.patches = rbpkg.patches.files.clone(); + source + }); + let build = convert_build(rbpkg)?; + let package = build_package_section(rbpkg); + + toml::to_string_pretty(&CookbookRecipe { + source, + build, + package, + }) + .map_err(CubError::from) +} + +fn convert_source(source: &crate::rbpkgbuild::SourceEntry) -> Result { + let mut cookbook = CookbookSource::default(); + + match source.source_type { + SourceType::Git => { + cookbook.git = Some(source.url.clone()); + cookbook.branch = non_empty(&source.branch); + cookbook.rev = non_empty(&source.rev); + } + SourceType::Tar => { + cookbook.tar = Some(source.url.clone()); + cookbook.blake3 = non_empty(&source.sha256); + } + } + + Ok(cookbook) +} + +fn convert_build(rbpkg: &RbPkgBuild) -> Result { + let mut build = CookbookBuild { + template: template_name(&rbpkg.build.template).to_string(), + dependencies: rbpkg.dependencies.build.clone(), + dev_dependencies: rbpkg.dependencies.check.clone(), + cargoflags: Vec::new(), + configureflags: Vec::new(), + cmakeflags: Vec::new(), + mesonflags: Vec::new(), + script: None, + }; + + match rbpkg.build.template { + BuildTemplate::Cargo => { + if rbpkg.build.release { + build.cargoflags.push("--release".to_string()); + } + if !rbpkg.build.features.is_empty() { + build.cargoflags.push("--features".to_string()); + build.cargoflags.push(rbpkg.build.features.join(",")); + } + build.cargoflags.extend(rbpkg.build.args.clone()); + } + BuildTemplate::Configure => build.configureflags = rbpkg.build.args.clone(), + BuildTemplate::Cmake => build.cmakeflags = rbpkg.build.args.clone(), + BuildTemplate::Meson => build.mesonflags = rbpkg.build.args.clone(), + BuildTemplate::Custom => { + let script = custom_script(rbpkg)?; + build.script = Some(script); + } + } + + Ok(build) +} + +fn build_package_section(rbpkg: &RbPkgBuild) -> Option { + let description = non_empty(&rbpkg.package.description); + let version = Some(if rbpkg.package.release > 0 { + format!("{}-{}", rbpkg.package.version, rbpkg.package.release) + } else { + rbpkg.package.version.clone() + }); + + if rbpkg.dependencies.runtime.is_empty() && description.is_none() && version.is_none() { + None + } else { + Some(CookbookPackage { + dependencies: rbpkg.dependencies.runtime.clone(), + version, + description, + }) + } +} + +fn custom_script(rbpkg: &RbPkgBuild) -> Result { + let mut parts = Vec::new(); + + parts.extend(rbpkg.build.prepare.iter().cloned()); + parts.extend(rbpkg.build.build_script.iter().cloned()); + if rbpkg.policy.allow_tests { + parts.extend(rbpkg.build.check.iter().cloned()); + } + parts.extend(rbpkg.build.install_script.iter().cloned()); + + if parts.is_empty() { + return Err(CubError::InvalidPkgbuild( + "custom template requires at least one prepare/build/check/install command".to_string(), + )); + } + + Ok(parts.join("\n")) +} + +fn template_name(template: &BuildTemplate) -> &'static str { + match template { + BuildTemplate::Custom => "custom", + BuildTemplate::Cargo => "cargo", + BuildTemplate::Configure => "configure", + BuildTemplate::Cmake => "cmake", + BuildTemplate::Meson => "meson", + } +} + +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rbpkgbuild::{ + BuildSection, BuildTemplate, CompatSection, ConversionStatus, DependenciesSection, + InstallSection, PackageSection, PatchesSection, PolicySection, RbPkgBuild, SourceEntry, + SourceSection, SourceType, + }; + + fn base_pkg(template: BuildTemplate) -> RbPkgBuild { + RbPkgBuild { + format: 1, + package: PackageSection { + name: "demo".to_string(), + version: "1.0.0".to_string(), + release: 1, + description: "demo package".to_string(), + homepage: String::new(), + license: vec!["MIT".to_string()], + architectures: vec!["x86_64-unknown-redox".to_string()], + maintainers: Vec::new(), + }, + source: SourceSection { + sources: vec![SourceEntry { + source_type: SourceType::Git, + url: "https://example.com/repo.git".to_string(), + sha256: String::new(), + rev: "abc123".to_string(), + branch: "main".to_string(), + }], + }, + dependencies: DependenciesSection { + build: vec!["cargo".to_string()], + runtime: vec!["openssl3".to_string()], + check: vec!["python".to_string()], + optional: Vec::new(), + provides: Vec::new(), + conflicts: Vec::new(), + }, + build: BuildSection { + template, + release: true, + features: vec!["cli".to_string(), "full".to_string()], + args: vec!["--locked".to_string()], + build_dir: String::new(), + prepare: vec!["./autogen.sh".to_string()], + build_script: vec!["make".to_string()], + check: vec!["make test".to_string()], + install_script: vec!["make install DESTDIR=\"${COOKBOOK_STAGE}\"".to_string()], + }, + install: InstallSection::default(), + patches: PatchesSection { + files: vec!["redox.patch".to_string()], + }, + compat: CompatSection { + imported_from: String::new(), + original_pkgbuild: String::new(), + conversion_status: ConversionStatus::Full, + target: String::new(), + }, + policy: PolicySection::default(), + } + } + + #[test] + fn generates_cargo_recipe() { + let recipe = generate_recipe(&base_pkg(BuildTemplate::Cargo)).expect("generate recipe"); + let value: toml::Value = toml::from_str(&recipe).expect("parse generated recipe"); + + assert_eq!( + value["source"]["git"].as_str(), + Some("https://example.com/repo.git") + ); + assert_eq!(value["build"]["template"].as_str(), Some("cargo")); + assert_eq!(value["build"]["dependencies"][0].as_str(), Some("cargo")); + assert_eq!(value["source"]["patches"][0].as_str(), Some("redox.patch")); + assert_eq!( + value["package"]["dependencies"][0].as_str(), + Some("openssl3") + ); + } + + #[test] + fn generates_tar_recipe_with_checksum() { + let mut pkg = base_pkg(BuildTemplate::Cargo); + pkg.source.sources[0] = SourceEntry { + source_type: SourceType::Tar, + url: "https://example.com/demo.tar.gz".to_string(), + sha256: "abc123deadbeef".to_string(), + rev: String::new(), + branch: String::new(), + }; + + let recipe = generate_recipe(&pkg).expect("generate recipe"); + let value: toml::Value = toml::from_str(&recipe).expect("parse generated recipe"); + + assert_eq!( + value["source"]["tar"].as_str(), + Some("https://example.com/demo.tar.gz") + ); + assert_eq!(value["source"]["blake3"].as_str(), Some("abc123deadbeef")); + } + + #[test] + fn generates_custom_script() { + let recipe = generate_recipe(&base_pkg(BuildTemplate::Custom)).expect("generate recipe"); + let value: toml::Value = toml::from_str(&recipe).expect("parse generated recipe"); + let script = value["build"]["script"].as_str().expect("custom script"); + + assert!(script.contains("./autogen.sh")); + assert!( + script.contains("make\n") || script.ends_with("make") || script.contains("make test") + ); + assert!(script.contains("make install")); + } + + #[test] + fn omits_test_commands_when_policy_disallows_them() { + let mut pkg = base_pkg(BuildTemplate::Custom); + pkg.policy.allow_tests = false; + + let recipe = generate_recipe(&pkg).expect("generate recipe"); + assert!(!recipe.contains("make test")); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/deps.rs b/local/recipes/system/cub/source/cub-lib/src/deps.rs new file mode 100644 index 00000000..647c3645 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/deps.rs @@ -0,0 +1,105 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MappedDep { + pub original: String, + pub mapped: String, + pub is_exact: bool, +} + +pub fn map_dependency(arch_name: &str) -> MappedDep { + let cleaned = arch_name.trim(); + let base = dependency_base_name(cleaned); + + let (mapped, is_exact) = match base.as_str() { + "glibc" => ("relibc".to_string(), false), + "gcc" | "make" => ("build-base".to_string(), false), + "pkg-config" => ("pkg-config".to_string(), true), + "openssl" => ("openssl3".to_string(), false), + "zlib" => ("zlib".to_string(), true), + "libffi" => ("libffi".to_string(), true), + "pcre2" => ("pcre2".to_string(), true), + "ncurses" => ("ncurses".to_string(), true), + "readline" => ("readline".to_string(), true), + "curl" => ("curl".to_string(), true), + "git" => ("git".to_string(), true), + "python" => ("python".to_string(), true), + "rust" => ("rust".to_string(), true), + "cargo" => ("cargo".to_string(), true), + "cmake" => ("cmake".to_string(), true), + "meson" => ("meson".to_string(), true), + "autoconf" => ("autoconf".to_string(), true), + "automake" => ("automake".to_string(), true), + "libtool" => ("libtool".to_string(), true), + "systemd" => (String::new(), false), + "dbus" => ("dbus".to_string(), true), + _ => (base.clone(), true), + }; + + MappedDep { + original: cleaned.to_string(), + mapped, + is_exact, + } +} + +pub fn map_dependencies(arch_deps: &[String]) -> Vec { + arch_deps.iter().map(|dep| map_dependency(dep)).collect() +} + +fn dependency_base_name(name: &str) -> String { + let trimmed = name.trim(); + let no_prefix = trimmed.strip_prefix("host:").unwrap_or(trimmed); + + no_prefix + .chars() + .take_while(|ch| !matches!(ch, '<' | '>' | '=' | ':' | ' ' | '\t')) + .collect::() + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_known_dependency() { + let mapped = map_dependency("glibc"); + + assert_eq!(mapped.original, "glibc"); + assert_eq!(mapped.mapped, "relibc"); + assert!(!mapped.is_exact); + } + + #[test] + fn keeps_unknown_dependency_name() { + let mapped = map_dependency("expat"); + + assert_eq!(mapped.mapped, "expat"); + assert!(mapped.is_exact); + } + + #[test] + fn strips_version_constraints() { + let mapped = map_dependency("openssl>=1.1"); + + assert_eq!(mapped.original, "openssl>=1.1"); + assert_eq!(mapped.mapped, "openssl3"); + } + + #[test] + fn marks_unavailable_dependency() { + let mapped = map_dependency("systemd"); + + assert!(mapped.mapped.is_empty()); + assert!(!mapped.is_exact); + } + + #[test] + fn maps_collections() { + let deps = vec!["glibc".to_string(), "cmake".to_string()]; + let mapped = map_dependencies(&deps); + + assert_eq!(mapped.len(), 2); + assert_eq!(mapped[0].mapped, "relibc"); + assert_eq!(mapped[1].mapped, "cmake"); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/error.rs b/local/recipes/system/cub/source/cub-lib/src/error.rs new file mode 100644 index 00000000..c8bb269e --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CubError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("TOML parse error: {0}")] + TomlParse(#[from] toml::de::Error), + #[error("TOML serialize error: {0}")] + TomlSerialize(#[from] toml::ser::Error), + #[error("Invalid RBPKGBUILD: {0}")] + InvalidPkgbuild(String), + #[error("Build failed: {0}")] + BuildFailed(String), + #[error("Package not found: {0}")] + PackageNotFound(String), + #[error("Conversion error: {0}")] + Conversion(String), + #[error("Dependency resolution failed: {0}")] + Dependency(String), + #[error("Sandbox error: {0}")] + Sandbox(String), +} diff --git a/local/recipes/system/cub/source/cub-lib/src/lib.rs b/local/recipes/system/cub/source/cub-lib/src/lib.rs new file mode 100644 index 00000000..3be642d8 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/lib.rs @@ -0,0 +1,11 @@ +pub mod rbpkgbuild; +pub mod rbsrcinfo; +pub mod cookbook; +pub mod converter; +pub mod deps; +pub mod sandbox; +#[cfg(feature = "full")] +pub mod package; +pub mod error; + +pub use error::CubError; diff --git a/local/recipes/system/cub/source/cub-lib/src/package.rs b/local/recipes/system/cub/source/cub-lib/src/package.rs new file mode 100644 index 00000000..5454677f --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/package.rs @@ -0,0 +1,167 @@ +use std::path::Path; + +use serde_derive::Serialize; + +use crate::error::CubError; +use crate::rbpkgbuild::RbPkgBuild; + +pub struct PackageCreator { + pub name: String, + pub version: String, + pub target: String, +} + +impl PackageCreator { + pub fn create_from_stage( + stage_dir: &Path, + output_path: &Path, + secret_key_path: &Path, + ) -> Result<(), CubError> { + if !stage_dir.is_dir() { + return Err(CubError::PackageNotFound(format!( + "stage directory does not exist: {}", + stage_dir.display() + ))); + } + + pkgar_keys::get_skey(secret_key_path).map_err(|err| { + CubError::BuildFailed(format!( + "failed to load pkgar secret key {}: {err}", + secret_key_path.display() + )) + })?; + + pkgar::folder_entries(stage_dir).map_err(|err| { + CubError::BuildFailed(format!( + "failed to scan stage directory {}: {err}", + stage_dir.display() + )) + })?; + + let flags = pkgar_core::HeaderFlags::latest( + pkgar_core::Architecture::Independent, + pkgar_core::Packaging::Uncompressed, + ); + pkgar::create_with_flags(secret_key_path, output_path, stage_dir, flags).map_err(|err| { + CubError::BuildFailed(format!( + "failed to create pkgar archive {}: {err}", + output_path.display() + )) + }) + } + + pub fn generate_package_toml(rbpkg: &RbPkgBuild) -> String { + #[derive(Serialize)] + struct PackageMetadata { + name: String, + version: String, + target: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + depends: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + optdepends: Vec, + } + + let metadata = PackageMetadata { + name: rbpkg.package.name.clone(), + version: if rbpkg.package.release > 0 { + format!("{}-{}", rbpkg.package.version, rbpkg.package.release) + } else { + rbpkg.package.version.clone() + }, + target: rbpkg + .package + .architectures + .first() + .cloned() + .unwrap_or_else(|| "x86_64-unknown-redox".to_string()), + depends: rbpkg.dependencies.runtime.clone(), + optdepends: rbpkg.dependencies.optional.clone(), + }; + + match toml::to_string_pretty(&metadata) { + Ok(rendered) => rendered, + Err(_) => format!( + "name = \"{}\"\nversion = \"{}\"\ntarget = \"{}\"\n", + metadata.name, metadata.version, metadata.target + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rbpkgbuild::{ + BuildSection, CompatSection, ConversionStatus, DependenciesSection, InstallSection, + PackageSection, PatchesSection, PolicySection, RbPkgBuild, SourceSection, + }; + use tempfile::tempdir; + + fn sample_rbpkgbuild() -> RbPkgBuild { + RbPkgBuild { + format: 1, + package: PackageSection { + name: "demo".to_string(), + version: "1.0.0".to_string(), + release: 1, + description: "demo package".to_string(), + homepage: String::new(), + license: Vec::new(), + architectures: vec!["x86_64-unknown-redox".to_string()], + maintainers: Vec::new(), + }, + source: SourceSection::default(), + dependencies: DependenciesSection { + build: Vec::new(), + runtime: vec!["openssl3".to_string()], + check: Vec::new(), + optional: Vec::new(), + provides: vec!["demo-virtual".to_string()], + conflicts: vec!["demo-old".to_string()], + }, + build: BuildSection { + build_script: vec!["make".to_string()], + install_script: vec!["make install".to_string()], + ..BuildSection::default() + }, + install: InstallSection::default(), + patches: PatchesSection::default(), + compat: CompatSection { + imported_from: String::new(), + original_pkgbuild: String::new(), + conversion_status: ConversionStatus::Full, + target: String::new(), + }, + policy: PolicySection::default(), + } + } + + #[test] + fn generates_package_toml() { + let mut rbpkg = sample_rbpkgbuild(); + rbpkg.dependencies.optional = vec!["git".to_string()]; + + let rendered = PackageCreator::generate_package_toml(&rbpkg); + + assert!(rendered.contains("name = \"demo\"")); + assert!(rendered.contains("version = \"1.0.0-1\"")); + assert!(rendered.contains("target = \"x86_64-unknown-redox\"")); + assert!(rendered.contains("depends = [\"openssl3\"]")); + assert!(rendered.contains("optdepends = [\"git\"]")); + assert!(!rendered.contains("dependencies =")); + } + + #[test] + fn errors_when_stage_dir_is_missing() { + let temp = tempdir().expect("tempdir"); + let err = PackageCreator::create_from_stage( + &temp.path().join("missing-stage"), + &temp.path().join("out.pkgar"), + &temp.path().join("secret.toml"), + ) + .expect_err("missing stage should fail"); + + assert!(matches!(err, CubError::PackageNotFound(_))); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/rbpkgbuild.rs b/local/recipes/system/cub/source/cub-lib/src/rbpkgbuild.rs new file mode 100644 index 00000000..05768cc2 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/rbpkgbuild.rs @@ -0,0 +1,406 @@ +use std::fs; +use std::path::Path; + +use serde_derive::{Deserialize, Serialize}; + +use crate::error::CubError; +use crate::rbsrcinfo::RbSrcInfo; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RbPkgBuild { + pub format: u32, + pub package: PackageSection, + #[serde(default)] + pub source: SourceSection, + #[serde(default)] + pub dependencies: DependenciesSection, + #[serde(default)] + pub build: BuildSection, + #[serde(default)] + pub install: InstallSection, + #[serde(default)] + pub patches: PatchesSection, + #[serde(default)] + pub compat: CompatSection, + #[serde(default)] + pub policy: PolicySection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PackageSection { + pub name: String, + pub version: String, + #[serde(default)] + pub release: u32, + #[serde(default)] + pub description: String, + #[serde(default)] + pub homepage: String, + #[serde(default)] + pub license: Vec, + #[serde(default)] + pub architectures: Vec, + #[serde(default)] + pub maintainers: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourceSection { + #[serde(default)] + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourceEntry { + #[serde(rename = "type")] + pub source_type: SourceType, + #[serde(default)] + pub url: String, + #[serde(default)] + pub sha256: String, + #[serde(default)] + pub rev: String, + #[serde(default)] + pub branch: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SourceType { + Tar, + Git, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct DependenciesSection { + #[serde(default)] + pub build: Vec, + #[serde(default)] + pub runtime: Vec, + #[serde(default)] + pub check: Vec, + #[serde(default)] + pub optional: Vec, + #[serde(default)] + pub provides: Vec, + #[serde(default)] + pub conflicts: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BuildSection { + #[serde(default)] + pub template: BuildTemplate, + #[serde(default)] + pub release: bool, + #[serde(default)] + pub features: Vec, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub build_dir: String, + #[serde(default)] + pub prepare: Vec, + #[serde(default)] + pub build_script: Vec, + #[serde(default)] + pub check: Vec, + #[serde(default)] + pub install_script: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum BuildTemplate { + #[default] + Custom, + Cargo, + Configure, + Cmake, + Meson, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct InstallSection { + #[serde(default)] + pub bins: Vec, + #[serde(default)] + pub libs: Vec, + #[serde(default)] + pub headers: Vec, + #[serde(default)] + pub docs: Vec, + #[serde(default)] + pub man: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InstallEntry { + pub from: String, + pub to: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PatchesSection { + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompatSection { + #[serde(default)] + pub imported_from: String, + #[serde(default)] + pub original_pkgbuild: String, + #[serde(default)] + pub conversion_status: ConversionStatus, + #[serde(default)] + pub target: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ConversionStatus { + #[default] + Full, + Partial, + Manual, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PolicySection { + #[serde(default)] + pub allow_network: bool, + #[serde(default = "default_true")] + pub allow_tests: bool, + #[serde(default = "default_true")] + pub review_required: bool, +} + +fn default_true() -> bool { + true +} + +impl RbPkgBuild { + pub fn from_file(path: impl AsRef) -> Result { + let contents = fs::read_to_string(path)?; + Self::from_str(&contents) + } + + pub fn from_str(s: &str) -> Result { + let parsed: RbPkgBuild = toml::from_str(s)?; + parsed.validate()?; + Ok(parsed) + } + + pub fn to_toml(&self) -> Result { + self.validate()?; + toml::to_string_pretty(self).map_err(CubError::from) + } + + pub fn validate(&self) -> Result<(), CubError> { + if self.format != 1 { + return Err(CubError::InvalidPkgbuild(format!( + "unsupported format {}, expected 1", + self.format + ))); + } + + if self.package.name.is_empty() { + return Err(CubError::InvalidPkgbuild( + "package.name must not be empty".to_string(), + )); + } + + if !valid_package_name(&self.package.name) { + return Err(CubError::InvalidPkgbuild(format!( + "package.name must match [a-z0-9-_]+: {}", + self.package.name + ))); + } + + if self.package.version.trim().is_empty() { + return Err(CubError::InvalidPkgbuild( + "package.version must not be empty".to_string(), + )); + } + + if !self + .package + .architectures + .iter() + .any(|arch| arch == "x86_64-unknown-redox") + { + return Err(CubError::InvalidPkgbuild( + "package.architectures must include x86_64-unknown-redox".to_string(), + )); + } + + for source in &self.source.sources { + if source.url.trim().is_empty() { + return Err(CubError::InvalidPkgbuild( + "source entry url must not be empty".to_string(), + )); + } + + if matches!(source.source_type, SourceType::Git) && source.url.contains(' ') { + return Err(CubError::InvalidPkgbuild(format!( + "git source url must not contain spaces: {}", + source.url + ))); + } + } + + for (i, source) in self.source.sources.iter().enumerate() { + match source.source_type { + SourceType::Tar => { + if source.sha256.is_empty() { + return Err(CubError::InvalidPkgbuild(format!( + "source[{}]: tar source requires sha256 checksum", + i + ))); + } + } + SourceType::Git => { + if source.rev.is_empty() && source.branch.is_empty() { + // Warning only for MVP: some git sources intentionally track default branch. + } + } + } + } + + if matches!(self.build.template, BuildTemplate::Custom) + && self.build.prepare.is_empty() + && self.build.build_script.is_empty() + && self.build.install_script.is_empty() + && self.install.bins.is_empty() + && self.install.libs.is_empty() + && self.install.headers.is_empty() + && self.install.docs.is_empty() + && self.install.man.is_empty() + { + return Err(CubError::InvalidPkgbuild( + "custom builds require prepare/build/install instructions".to_string(), + )); + } + + Ok(()) + } + + pub fn to_srcinfo(&self) -> RbSrcInfo { + RbSrcInfo::from_rbpkgbuild(self) + } +} + +fn valid_package_name(name: &str) -> bool { + name.chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + const SAMPLE_TOML: &str = r#" +format = 1 + +[package] +name = "demo-pkg" +version = "1.0.0" +release = 1 +description = "demo package" +homepage = "https://example.com" +license = ["MIT"] +architectures = ["x86_64-unknown-redox", "aarch64-unknown-redox"] +maintainers = ["Red Bear OS"] + +[source] +sources = [ + { type = "git", url = "https://example.com/repo.git", rev = "abc123", branch = "main" } +] + +[dependencies] +build = ["cargo"] +runtime = ["openssl3"] + +[build] +template = "cargo" +release = true +features = ["std"] + +[policy] +allow_network = false +"#; + + #[test] + fn parses_valid_rbpkgbuild() { + let pkg = RbPkgBuild::from_str(SAMPLE_TOML).expect("parse RBPKGBUILD"); + + assert_eq!(pkg.format, 1); + assert_eq!(pkg.package.name, "demo-pkg"); + assert_eq!(pkg.build.template, BuildTemplate::Cargo); + assert!(pkg.build.release); + } + + #[test] + fn rejects_invalid_name() { + let invalid = SAMPLE_TOML.replace("demo-pkg", "DemoPkg"); + let err = RbPkgBuild::from_str(&invalid).expect_err("invalid name should fail"); + + assert!(matches!(err, CubError::InvalidPkgbuild(_))); + } + + #[test] + fn rejects_missing_redox_architecture() { + let invalid = SAMPLE_TOML.replace( + "[\"x86_64-unknown-redox\", \"aarch64-unknown-redox\"]", + "[\"x86_64-unknown-linux-gnu\"]", + ); + let err = RbPkgBuild::from_str(&invalid).expect_err("missing redox arch should fail"); + + assert!(matches!(err, CubError::InvalidPkgbuild(_))); + } + + #[test] + fn rejects_tar_source_without_sha256() { + let invalid = SAMPLE_TOML.replace( + r#"{ type = "git", url = "https://example.com/repo.git", rev = "abc123", branch = "main" }"#, + r#"{ type = "tar", url = "https://example.com/demo.tar.gz" }"#, + ); + let err = + RbPkgBuild::from_str(&invalid).expect_err("tar source without sha256 should fail"); + + assert!(matches!(err, CubError::InvalidPkgbuild(_))); + } + + #[test] + fn round_trips_to_toml() { + let pkg = RbPkgBuild::from_str(SAMPLE_TOML).expect("parse RBPKGBUILD"); + let toml = pkg.to_toml().expect("serialize RBPKGBUILD"); + let reparsed = RbPkgBuild::from_str(&toml).expect("reparse RBPKGBUILD"); + + assert_eq!(reparsed.package.name, "demo-pkg"); + assert_eq!(reparsed.build.features, vec!["std"]); + } + + #[test] + fn parses_from_file() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), SAMPLE_TOML).expect("write RBPKGBUILD"); + + let pkg = RbPkgBuild::from_file(file.path()).expect("read RBPKGBUILD"); + assert_eq!(pkg.package.version, "1.0.0"); + } + + #[test] + fn converts_to_srcinfo() { + let pkg = RbPkgBuild::from_str(SAMPLE_TOML).expect("parse RBPKGBUILD"); + let srcinfo = pkg.to_srcinfo(); + + assert_eq!(srcinfo.pkgname, "demo-pkg"); + assert_eq!(srcinfo.pkgver, "1.0.0"); + assert_eq!(srcinfo.makedepends, vec!["cargo"]); + assert_eq!(srcinfo.depends, vec!["openssl3"]); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/rbsrcinfo.rs b/local/recipes/system/cub/source/cub-lib/src/rbsrcinfo.rs new file mode 100644 index 00000000..9625de68 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/rbsrcinfo.rs @@ -0,0 +1,226 @@ +use std::fs; +use std::path::Path; + +use crate::error::CubError; +use crate::rbpkgbuild::{RbPkgBuild, SourceType}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RbSrcInfo { + pub pkgname: String, + pub pkgver: String, + pub pkgrel: u32, + pub pkgdesc: String, + pub arch: String, + pub depends: Vec, + pub makedepends: Vec, + pub source: Vec, + pub sha256sums: Vec, + pub provides: Vec, + pub conflicts: Vec, +} + +impl RbSrcInfo { + pub fn from_file(path: impl AsRef) -> Result { + let contents = fs::read_to_string(path)?; + Self::from_str(&contents) + } + + pub fn to_string(&self) -> String { + let mut lines = Vec::new(); + + push_scalar(&mut lines, "pkgname", &self.pkgname); + push_scalar(&mut lines, "pkgver", &self.pkgver); + lines.push(format!("pkgrel = {}", self.pkgrel)); + push_scalar(&mut lines, "pkgdesc", &self.pkgdesc); + push_scalar(&mut lines, "arch", &self.arch); + + push_list(&mut lines, "depends", &self.depends); + push_list(&mut lines, "makedepends", &self.makedepends); + push_list(&mut lines, "source", &self.source); + push_list(&mut lines, "sha256sums", &self.sha256sums); + push_list(&mut lines, "provides", &self.provides); + push_list(&mut lines, "conflicts", &self.conflicts); + + let mut output = lines.join("\n"); + output.push('\n'); + output + } + + pub fn from_rbpkgbuild(rb: &RbPkgBuild) -> Self { + let mut sha256sums = Vec::new(); + let source = rb + .source + .sources + .iter() + .map(|entry| { + if matches!(entry.source_type, SourceType::Tar) && !entry.sha256.is_empty() { + sha256sums.push(entry.sha256.clone()); + } + entry.url.clone() + }) + .collect(); + + Self { + pkgname: rb.package.name.clone(), + pkgver: rb.package.version.clone(), + pkgrel: rb.package.release, + pkgdesc: rb.package.description.clone(), + arch: rb + .package + .architectures + .first() + .cloned() + .unwrap_or_else(|| "x86_64-unknown-redox".to_string()), + depends: rb.dependencies.runtime.clone(), + makedepends: rb.dependencies.build.clone(), + source, + sha256sums, + provides: rb.dependencies.provides.clone(), + conflicts: rb.dependencies.conflicts.clone(), + } + } + + fn from_str(contents: &str) -> Result { + let mut info = RbSrcInfo::default(); + + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + + let key = key.trim(); + let value = value.trim().trim_matches('"'); + + match key { + "pkgname" => info.pkgname = value.to_string(), + "pkgver" => info.pkgver = value.to_string(), + "pkgrel" => { + info.pkgrel = value.parse().map_err(|_| { + CubError::InvalidPkgbuild(format!("invalid pkgrel in .RBSRCINFO: {value}")) + })? + } + "pkgdesc" => info.pkgdesc = value.to_string(), + "arch" => info.arch = value.to_string(), + "depends" => info.depends.push(value.to_string()), + "makedepends" => info.makedepends.push(value.to_string()), + "source" => info.source.push(value.to_string()), + "sha256sums" => info.sha256sums.push(value.to_string()), + "provides" => info.provides.push(value.to_string()), + "conflicts" => info.conflicts.push(value.to_string()), + _ => {} + } + } + + Ok(info) + } +} + +fn push_scalar(lines: &mut Vec, key: &str, value: &str) { + if !value.is_empty() { + lines.push(format!("{key} = {value}")); + } +} + +fn push_list(lines: &mut Vec, key: &str, values: &[String]) { + for value in values { + if !value.is_empty() { + lines.push(format!("{key} = {value}")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rbpkgbuild::{ + BuildSection, CompatSection, ConversionStatus, DependenciesSection, InstallSection, + PackageSection, PatchesSection, PolicySection, RbPkgBuild, SourceEntry, SourceSection, + SourceType, + }; + use tempfile::NamedTempFile; + + fn sample_rbpkgbuild() -> RbPkgBuild { + RbPkgBuild { + format: 1, + package: PackageSection { + name: "demo".to_string(), + version: "1.2.3".to_string(), + release: 4, + description: "Demo package".to_string(), + homepage: String::new(), + license: vec!["MIT".to_string()], + architectures: vec!["x86_64-unknown-redox".to_string()], + maintainers: Vec::new(), + }, + source: SourceSection { + sources: vec![SourceEntry { + source_type: SourceType::Tar, + url: "https://example.com/demo.tar.xz".to_string(), + sha256: "abc123".to_string(), + rev: String::new(), + branch: String::new(), + }], + }, + dependencies: DependenciesSection { + build: vec!["cmake".to_string()], + runtime: vec!["zlib".to_string()], + check: Vec::new(), + optional: Vec::new(), + provides: vec!["demo-virtual".to_string()], + conflicts: vec!["demo-old".to_string()], + }, + build: BuildSection::default(), + install: InstallSection::default(), + patches: PatchesSection::default(), + compat: CompatSection { + imported_from: String::new(), + original_pkgbuild: String::new(), + conversion_status: ConversionStatus::Full, + target: String::new(), + }, + policy: PolicySection::default(), + } + } + + #[test] + fn converts_from_rbpkgbuild() { + let info = RbSrcInfo::from_rbpkgbuild(&sample_rbpkgbuild()); + + assert_eq!(info.pkgname, "demo"); + assert_eq!(info.pkgver, "1.2.3"); + assert_eq!(info.pkgrel, 4); + assert_eq!(info.depends, vec!["zlib"]); + assert_eq!(info.makedepends, vec!["cmake"]); + assert_eq!(info.sha256sums, vec!["abc123"]); + } + + #[test] + fn serializes_and_parses_round_trip() { + let info = RbSrcInfo::from_rbpkgbuild(&sample_rbpkgbuild()); + let rendered = info.to_string(); + let reparsed = RbSrcInfo::from_str(&rendered).expect("parse .RBSRCINFO"); + + assert_eq!(reparsed, info); + } + + #[test] + fn parses_from_file() { + let file = NamedTempFile::new().expect("temp file"); + fs::write( + file.path(), + "pkgname = demo\npkgver = 1.0.0\npkgrel = 1\narch = x86_64-unknown-redox\n", + ) + .expect("write .RBSRCINFO"); + + let info = RbSrcInfo::from_file(file.path()).expect("read .RBSRCINFO"); + assert_eq!(info.pkgname, "demo"); + assert_eq!(info.pkgver, "1.0.0"); + assert_eq!(info.pkgrel, 1); + assert_eq!(info.arch, "x86_64-unknown-redox"); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/sandbox.rs b/local/recipes/system/cub/source/cub-lib/src/sandbox.rs new file mode 100644 index 00000000..50c19339 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/sandbox.rs @@ -0,0 +1,164 @@ +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::CubError; + +#[derive(Debug, Clone)] +pub struct SandboxConfig { + pub target: String, + pub gnu_target: String, + pub destdir: PathBuf, + pub prefix: String, + pub cores: u32, + pub allow_network: bool, + pub source_dir: PathBuf, + pub build_dir: PathBuf, + pub stage_dir: PathBuf, + pub sysroot_dir: PathBuf, +} + +impl SandboxConfig { + pub fn new(source_dir: &Path) -> Self { + let root = source_dir.join(".cub-sandbox"); + let build_dir = root.join("build"); + let stage_dir = root.join("stage"); + let sysroot_dir = root.join("sysroot"); + + Self { + target: "x86_64-unknown-redox".to_string(), + gnu_target: "x86_64-redox".to_string(), + destdir: stage_dir.clone(), + prefix: "/usr".to_string(), + cores: std::thread::available_parallelism() + .map(|count| count.get() as u32) + .unwrap_or(1), + allow_network: false, + source_dir: source_dir.to_path_buf(), + build_dir, + stage_dir, + sysroot_dir, + } + } + + pub fn env_vars(&self) -> HashMap { + let mut env = HashMap::new(); + let current_path = std::env::var("PATH").unwrap_or_default(); + let tool_path = self.sysroot_dir.join("bin"); + + env.insert( + "COOKBOOK_SOURCE".to_string(), + self.source_dir.display().to_string(), + ); + env.insert( + "COOKBOOK_STAGE".to_string(), + self.stage_dir.display().to_string(), + ); + env.insert( + "COOKBOOK_SYSROOT".to_string(), + self.sysroot_dir.display().to_string(), + ); + env.insert("COOKBOOK_TARGET".to_string(), self.target.clone()); + env.insert( + "COOKBOOK_HOST_TARGET".to_string(), + "x86_64-unknown-linux-gnu".to_string(), + ); + env.insert("COOKBOOK_MAKE_JOBS".to_string(), self.cores.to_string()); + env.insert("DESTDIR".to_string(), self.stage_dir.display().to_string()); + env.insert("TARGET".to_string(), self.target.clone()); + env.insert("GNU_TARGET".to_string(), self.gnu_target.clone()); + env.insert( + "PATH".to_string(), + if current_path.is_empty() { + tool_path.display().to_string() + } else { + format!("{}:{}", tool_path.display(), current_path) + }, + ); + + env + } + + pub fn setup(&self) -> Result<(), CubError> { + for dir in [ + &self.build_dir, + &self.stage_dir, + &self.sysroot_dir, + &self.destdir, + ] { + fs::create_dir_all(dir).map_err(|err| { + CubError::Sandbox(format!("failed to create {}: {err}", dir.display())) + })?; + } + + Ok(()) + } + + pub fn cleanup(&self) -> Result<(), CubError> { + let mut dirs = BTreeSet::new(); + dirs.insert(self.destdir.clone()); + dirs.insert(self.stage_dir.clone()); + dirs.insert(self.build_dir.clone()); + dirs.insert(self.sysroot_dir.clone()); + + for dir in dirs.into_iter().rev() { + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|err| { + CubError::Sandbox(format!("failed to remove {}: {err}", dir.display())) + })?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn builds_expected_defaults() { + let temp = tempdir().expect("tempdir"); + let sandbox = SandboxConfig::new(temp.path()); + + assert_eq!(sandbox.target, "x86_64-unknown-redox"); + assert_eq!(sandbox.gnu_target, "x86_64-redox"); + assert_eq!(sandbox.prefix, "/usr"); + assert!(sandbox.cores >= 1); + } + + #[test] + fn exposes_cookbook_environment() { + let temp = tempdir().expect("tempdir"); + let sandbox = SandboxConfig::new(temp.path()); + let env = sandbox.env_vars(); + + assert_eq!( + env.get("COOKBOOK_TARGET"), + Some(&"x86_64-unknown-redox".to_string()) + ); + assert_eq!(env.get("GNU_TARGET"), Some(&"x86_64-redox".to_string())); + assert!(env + .get("PATH") + .expect("PATH set") + .starts_with(&sandbox.sysroot_dir.join("bin").display().to_string())); + } + + #[test] + fn sets_up_and_cleans_directories() { + let temp = tempdir().expect("tempdir"); + let sandbox = SandboxConfig::new(temp.path()); + + sandbox.setup().expect("setup sandbox"); + assert!(sandbox.build_dir.exists()); + assert!(sandbox.stage_dir.exists()); + assert!(sandbox.sysroot_dir.exists()); + + sandbox.cleanup().expect("cleanup sandbox"); + assert!(!sandbox.build_dir.exists()); + assert!(!sandbox.stage_dir.exists()); + assert!(!sandbox.sysroot_dir.exists()); + } +} diff --git a/local/scripts/integrate-redbear.sh b/local/scripts/integrate-redbear.sh index f8536e81..de8cbc82 100755 --- a/local/scripts/integrate-redbear.sh +++ b/local/scripts/integrate-redbear.sh @@ -154,6 +154,7 @@ symlink "../../local/recipes/system/redbear-meta" "recipes/system/redbear-meta" symlink "../../local/recipes/system/udev-shim" "recipes/system/udev-shim" symlink "../../local/recipes/core/ext4d" "recipes/core/ext4d" symlink "../../local/recipes/tui/mc" "recipes/tui/mc" +symlink "../../local/recipes/system/cub" "recipes/system/cub" status "Custom recipe symlinks ready" echo "" diff --git a/recipes/system/cub b/recipes/system/cub new file mode 120000 index 00000000..01abd4eb --- /dev/null +++ b/recipes/system/cub @@ -0,0 +1 @@ +../../local/recipes/system/cub \ No newline at end of file