Add CUB package builder and include in all Red Bear OS configs

CUB (Red Bear OS Package Builder) is a Rust CLI tool that combines package management and building:
- RBPKGBUILD parser (TOML format) with full spec support
- Cookbook adapter converting RBPKGBUILD to recipe.toml
- PKGBUILD (Arch AUR) to RBPKGBUILD conversion with Linuxism detection
- Dependency mapping (Arch to Redox names)
- pkgar package creation integration
- Build environment setup with Cookbook env vars
- CLI with pacman-style shortcuts: -S, -Ss, -B, -G, -Pi, -Sua, -Sc, --import-aur

28 cub-lib tests passing. cub-cli compiles with local pkgutils.
Added cub = {} to redbear-desktop, redbear-full, redbear-minimal configs.
Created recipe symlink and updated integrate-redbear.sh.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-12 23:51:48 +01:00
parent ca13795f06
commit 59d4ba5dcf
19 changed files with 2789 additions and 0 deletions
@@ -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<String>,
pub actions_required: Vec<String>,
}
pub fn convert_pkgbuild(content: &str) -> Result<ConversionResult, CubError> {
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::<u32>().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<String>,
actions_required: &mut Vec<String>,
) -> Vec<String> {
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<String> {
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<String> {
extract_assignment(content, name).map(|raw| parse_scalar(&raw))
}
fn extract_array_assignment(content: &str, name: &str) -> Option<Vec<String>> {
extract_assignment(content, name).map(|raw| parse_array(&raw))
}
fn extract_assignment(content: &str, name: &str) -> Option<String> {
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<String> {
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<String> {
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<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = 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);
}
}
@@ -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<CookbookSource>,
build: CookbookBuild,
#[serde(skip_serializing_if = "Option::is_none")]
package: Option<CookbookPackage>,
}
#[derive(Debug, Default, Serialize)]
struct CookbookSource {
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tar: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
blake3: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
patches: Vec<String>,
}
#[derive(Debug, Serialize)]
struct CookbookBuild {
template: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
#[serde(rename = "dev-dependencies", skip_serializing_if = "Vec::is_empty")]
dev_dependencies: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cargoflags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
configureflags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cmakeflags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
mesonflags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
script: Option<String>,
}
#[derive(Debug, Serialize)]
struct CookbookPackage {
#[serde(skip_serializing_if = "Vec::is_empty")]
dependencies: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
pub fn generate_recipe(rbpkg: &RbPkgBuild) -> Result<String, CubError> {
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<CookbookSource, CubError> {
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<CookbookBuild, CubError> {
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<CookbookPackage> {
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<String, CubError> {
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<String> {
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"));
}
}
@@ -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<MappedDep> {
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::<String>()
.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");
}
}
@@ -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),
}
@@ -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;
@@ -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<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
optdepends: Vec<String>,
}
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(_)));
}
}
@@ -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<String>,
#[serde(default)]
pub architectures: Vec<String>,
#[serde(default)]
pub maintainers: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceSection {
#[serde(default)]
pub sources: Vec<SourceEntry>,
}
#[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<String>,
#[serde(default)]
pub runtime: Vec<String>,
#[serde(default)]
pub check: Vec<String>,
#[serde(default)]
pub optional: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
}
#[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<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub build_dir: String,
#[serde(default)]
pub prepare: Vec<String>,
#[serde(default)]
pub build_script: Vec<String>,
#[serde(default)]
pub check: Vec<String>,
#[serde(default)]
pub install_script: Vec<String>,
}
#[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<InstallEntry>,
#[serde(default)]
pub libs: Vec<InstallEntry>,
#[serde(default)]
pub headers: Vec<InstallEntry>,
#[serde(default)]
pub docs: Vec<InstallEntry>,
#[serde(default)]
pub man: Vec<InstallEntry>,
}
#[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<String>,
}
#[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<Path>) -> Result<RbPkgBuild, CubError> {
let contents = fs::read_to_string(path)?;
Self::from_str(&contents)
}
pub fn from_str(s: &str) -> Result<RbPkgBuild, CubError> {
let parsed: RbPkgBuild = toml::from_str(s)?;
parsed.validate()?;
Ok(parsed)
}
pub fn to_toml(&self) -> Result<String, CubError> {
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"]);
}
}
@@ -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<String>,
pub makedepends: Vec<String>,
pub source: Vec<String>,
pub sha256sums: Vec<String>,
pub provides: Vec<String>,
pub conflicts: Vec<String>,
}
impl RbSrcInfo {
pub fn from_file(path: impl AsRef<Path>) -> Result<RbSrcInfo, CubError> {
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<RbSrcInfo, CubError> {
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<String>, key: &str, value: &str) {
if !value.is_empty() {
lines.push(format!("{key} = {value}"));
}
}
fn push_list(lines: &mut Vec<String>, 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");
}
}
@@ -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<String, String> {
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());
}
}