From 1d756c6023c7d389af409913d3f00c96246f159d Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Thu, 16 Jul 2026 04:02:27 +0900 Subject: [PATCH] defaults: restore package lists Co-Authored-By: Claude Fable 5 --- README.md | 17 +++ boards/blackhole/target-packages.txt | 2 - boards/k1/target-packages.txt | 9 -- boards/k230/target-packages.txt | 4 - boards/k3/target-packages.txt | 16 +- boards/ky-x1/target-packages.txt | 6 - boards/odroid-m1/target-packages.txt | 2 - boards/odroid-m1s/target-packages.txt | 2 - boards/odroid-m2/target-packages.txt | 2 - boards/pentium-mmx/target-packages.txt | 9 +- crossdev-stages/src/cli/image.rs | 12 +- crossdev-stages/src/cli/sandbox.rs | 4 +- crossdev-stages/src/cli/target.rs | 8 +- crossdev-stages/src/cli/util.rs | 7 +- crossdev-stages/src/error.rs | 9 ++ crossdev-stages/src/image.rs | 94 ++++-------- crossdev-stages/src/main.rs | 16 +- crossdev-stages/src/package_list.rs | 199 +++++++++++++++++++++++++ crossdev-stages/src/portage.rs | 48 +++--- crossdev-stages/src/sandbox.rs | 47 +++++- defaults/sandbox-packages.txt | 32 ++++ defaults/target-packages.txt | 39 +++++ docs/design.md | 33 +++- 23 files changed, 458 insertions(+), 159 deletions(-) delete mode 100644 boards/blackhole/target-packages.txt delete mode 100644 boards/k1/target-packages.txt delete mode 100644 boards/k230/target-packages.txt delete mode 100644 boards/ky-x1/target-packages.txt delete mode 100644 boards/odroid-m1/target-packages.txt delete mode 100644 boards/odroid-m1s/target-packages.txt delete mode 100644 boards/odroid-m2/target-packages.txt create mode 100644 crossdev-stages/src/package_list.rs create mode 100644 defaults/sandbox-packages.txt create mode 100644 defaults/target-packages.txt diff --git a/README.md b/README.md index 377fb8a1..dad3cce5 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,29 @@ eselect repository create crossdev Each board lives in `boards//` with: - `board.conf` -- variables read by Rust and bash scripts - `genimage.cfg` -- disk image layout +- `sandbox-packages.txt` -- extra host packages for the sandbox (optional) +- `sandbox-packages.use` -- USE flags for those packages (optional) +- `target-packages.txt` -- extra packages cross-emerged into the image (optional) - `pre-{step}.sh` -- runs before Rust default (optional) - `post-{step}.sh` -- runs after Rust default (optional) - `override-{step}.sh` -- replaces Rust default entirely (optional) Steps: `deps`, `checkout`, `bootloader`, `kernel`, `assemble`, `pack` +Package lists in `defaults/` apply to every sandbox +(`defaults/sandbox-packages.txt`) and every image +(`defaults/target-packages.txt`). The effective target set is +`defaults/target-packages.txt` UNION `boards//target-packages.txt` +MINUS the board's `-atom` lines (e.g. `-app-misc/fastfetch` drops a +default) -- every part is a plain file. Subtracting an atom not in the +set warns and does nothing; `-atom` lines in the defaults file itself +are an error. Heavy extras (mold, go, cmake, rust, iw, wpa_supplicant) +stay out of the defaults -- boards opt in via their own list. +List lines are `atom [keywords]` -- a keyword override (e.g. +`sys-boot/syslinux **`) lands in `etc/portage/package.accept_keywords/`. +Portage config files under `defaults/portage/` are overlaid onto the +sandbox's `etc/portage/` during prepare. + ### Build step execution ``` diff --git a/boards/blackhole/target-packages.txt b/boards/blackhole/target-packages.txt deleted file mode 100644 index b9f5fc48..00000000 --- a/boards/blackhole/target-packages.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Packages for Blackhole target -net-misc/openssh diff --git a/boards/k1/target-packages.txt b/boards/k1/target-packages.txt deleted file mode 100644 index 2a6a932b..00000000 --- a/boards/k1/target-packages.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Packages to cross-install into the target sysroot for the K1 image - -# Services enabled by default -net-misc/openssh -app-admin/metalog -net-misc/ntp - -# Initramfs / boot -sys-apps/busybox diff --git a/boards/k230/target-packages.txt b/boards/k230/target-packages.txt deleted file mode 100644 index 6219278a..00000000 --- a/boards/k230/target-packages.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Packages to cross-install into the target sysroot for K230 - -net-misc/openssh -sys-apps/busybox diff --git a/boards/k3/target-packages.txt b/boards/k3/target-packages.txt index b970abdf..2c7d07db 100644 --- a/boards/k3/target-packages.txt +++ b/boards/k3/target-packages.txt @@ -1,26 +1,14 @@ -# Packages to cross-install into the target sysroot for the K3 image +# K3 extras on top of defaults/target-packages.txt -# Services -net-misc/openssh -app-admin/metalog -net-misc/ntp - -# Initramfs / boot -sys-apps/busybox - -# Dev tools -app-editors/vim -dev-vcs/git +# Dev tools (git is in defaults) dev-debug/strace dev-debug/gdb net-misc/curl net-misc/wget # System tools -sys-process/htop sys-process/lsof sys-apps/file -app-misc/fastfetch app-misc/screen # Build systems diff --git a/boards/ky-x1/target-packages.txt b/boards/ky-x1/target-packages.txt deleted file mode 100644 index 5f90432d..00000000 --- a/boards/ky-x1/target-packages.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Packages to cross-install into the target sysroot for KY-X1 - -net-misc/openssh -app-admin/metalog -net-misc/ntp -sys-apps/busybox diff --git a/boards/odroid-m1/target-packages.txt b/boards/odroid-m1/target-packages.txt deleted file mode 100644 index d55b4501..00000000 --- a/boards/odroid-m1/target-packages.txt +++ /dev/null @@ -1,2 +0,0 @@ -net-misc/openssh -sys-apps/busybox diff --git a/boards/odroid-m1s/target-packages.txt b/boards/odroid-m1s/target-packages.txt deleted file mode 100644 index d55b4501..00000000 --- a/boards/odroid-m1s/target-packages.txt +++ /dev/null @@ -1,2 +0,0 @@ -net-misc/openssh -sys-apps/busybox diff --git a/boards/odroid-m2/target-packages.txt b/boards/odroid-m2/target-packages.txt deleted file mode 100644 index d55b4501..00000000 --- a/boards/odroid-m2/target-packages.txt +++ /dev/null @@ -1,2 +0,0 @@ -net-misc/openssh -sys-apps/busybox diff --git a/boards/pentium-mmx/target-packages.txt b/boards/pentium-mmx/target-packages.txt index c98692a4..1d28cbb9 100644 --- a/boards/pentium-mmx/target-packages.txt +++ b/boards/pentium-mmx/target-packages.txt @@ -1,12 +1,7 @@ -# Packages cross-installed into the i586 target sysroot +# pentium-mmx extras on top of defaults/target-packages.txt -# Network & remote access (services enabled in BOOT_SERVICES) -net-misc/openssh +# Network (service enabled in BOOT_SERVICES) net-misc/dhcpcd # Disk utilities required by the grow-rootfs first-boot service -sys-block/parted sys-fs/e2fsprogs - -# Minimal shell utilities -sys-apps/busybox diff --git a/crossdev-stages/src/cli/image.rs b/crossdev-stages/src/cli/image.rs index c2c28850..33ca502f 100644 --- a/crossdev-stages/src/cli/image.rs +++ b/crossdev-stages/src/cli/image.rs @@ -8,6 +8,7 @@ pub async fn run( ws: &Workspace, cmd: ImageCmd, boards_root: &Utf8Path, + defaults_root: &Utf8Path, mirror: Option<&str>, dry_run: bool, ) -> Result<()> { @@ -72,6 +73,7 @@ pub async fn run( sandbox.as_deref(), &board_cfg.arch, &board_cfg, + defaults_root, mirror, None, ) @@ -93,7 +95,15 @@ pub async fn run( } else { Some(steps.as_slice()) }; - image::build(ws, &sb, &tgt, &board_cfg, boards_root, steps_opt)?; + image::build( + ws, + &sb, + &tgt, + &board_cfg, + boards_root, + defaults_root, + steps_opt, + )?; } ImageCmd::Prune => { let builds = ws.list_builds()?; diff --git a/crossdev-stages/src/cli/sandbox.rs b/crossdev-stages/src/cli/sandbox.rs index 86ac35fa..c7fedc46 100644 --- a/crossdev-stages/src/cli/sandbox.rs +++ b/crossdev-stages/src/cli/sandbox.rs @@ -7,6 +7,7 @@ pub async fn run( ws: &Workspace, cmd: SandboxCmd, boards_root: &camino::Utf8Path, + defaults_root: &camino::Utf8Path, mirror: Option<&str>, ) -> Result<()> { match cmd { @@ -33,7 +34,7 @@ pub async fn run( SandboxCmd::Prepare { name, bare } => { let dir = ws.resolve_sandbox(name.as_deref())?; let sb = sandbox::Sandbox::open(dir)?; - sb.prepare(mirror, bare)?; + sb.prepare(mirror, defaults_root, bare)?; } SandboxCmd::Crossdev { arch, @@ -51,6 +52,7 @@ pub async fn run( name.as_deref(), &arch, &board_cfg, + defaults_root, mirror, gcc_version.as_deref(), ) diff --git a/crossdev-stages/src/cli/target.rs b/crossdev-stages/src/cli/target.rs index fd6f54cc..5bc5bde3 100644 --- a/crossdev-stages/src/cli/target.rs +++ b/crossdev-stages/src/cli/target.rs @@ -2,7 +2,7 @@ use crate::cli::util::{default_board_config, ensure_crossdev, ensure_target}; use crate::cli::TargetCmd; use crate::error::Result; use crate::{container, stage, target, workspace::Workspace}; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; pub async fn run( ws: &Workspace, @@ -10,6 +10,7 @@ pub async fn run( sandbox: Option, target_name: Option, cmd: TargetCmd, + defaults_root: &Utf8Path, mirror: Option<&str>, ) -> Result<()> { match cmd { @@ -51,6 +52,7 @@ pub async fn run( sandbox.as_deref(), &resolved_arch, &default_board_config(&resolved_arch), + defaults_root, mirror, None, ) @@ -62,6 +64,7 @@ pub async fn run( target_name.as_deref(), arch.as_deref(), sandbox.as_deref(), + defaults_root, mirror, ) .await?; @@ -73,6 +76,7 @@ pub async fn run( target_name.as_deref(), arch.as_deref(), sandbox.as_deref(), + defaults_root, mirror, ) .await?; @@ -84,6 +88,7 @@ pub async fn run( target_name.as_deref(), arch.as_deref(), sandbox.as_deref(), + defaults_root, mirror, ) .await?; @@ -96,6 +101,7 @@ pub async fn run( target_name.as_deref(), arch.as_deref(), sandbox.as_deref(), + defaults_root, mirror, ) .await?; diff --git a/crossdev-stages/src/cli/util.rs b/crossdev-stages/src/cli/util.rs index 169f15ed..85a94240 100644 --- a/crossdev-stages/src/cli/util.rs +++ b/crossdev-stages/src/cli/util.rs @@ -1,3 +1,5 @@ +use camino::Utf8Path; + use crate::error::Result; use crate::workspace::Workspace; use crate::{board, error, sandbox, stage, target}; @@ -9,6 +11,7 @@ pub async fn ensure_crossdev( sandbox_name: Option<&str>, arch: &str, board_cfg: &board::BoardConfig, + defaults_root: &Utf8Path, mirror: Option<&str>, gcc_version: Option<&str>, ) -> Result { @@ -27,7 +30,7 @@ pub async fn ensure_crossdev( } }; let sb = sandbox::Sandbox::open(sd)?; - sb.prepare(mirror, false)?; + sb.prepare(mirror, defaults_root, false)?; sb.setup_crossdev(arch, board_cfg, gcc_version)?; Ok(sb) } @@ -39,6 +42,7 @@ pub async fn ensure_target( target_name: Option<&str>, arch_override: Option<&str>, sandbox_name: Option<&str>, + defaults_root: &Utf8Path, mirror: Option<&str>, ) -> Result<(target::Target, sandbox::Sandbox)> { let (tgt, resolved_arch) = match ws.resolve_target(target_name) { @@ -67,6 +71,7 @@ pub async fn ensure_target( sandbox_name, &resolved_arch, &default_board_config(&resolved_arch), + defaults_root, mirror, None, ) diff --git a/crossdev-stages/src/error.rs b/crossdev-stages/src/error.rs index 56de69f2..0e7115b5 100644 --- a/crossdev-stages/src/error.rs +++ b/crossdev-stages/src/error.rs @@ -26,6 +26,15 @@ pub enum Error { #[error("command failed (exit {code}): {reason}")] CommandFailed { code: i32, reason: String }, + #[error("required package list not found: {0}")] + PackageListNotFound(camino::Utf8PathBuf), + + #[error("subtraction line '-{atom}' not allowed in defaults list {file} (only board lists may subtract)")] + SubtractionInDefaults { + file: camino::Utf8PathBuf, + atom: String, + }, + #[error("io error: {0}")] Io(#[from] std::io::Error), } diff --git a/crossdev-stages/src/image.rs b/crossdev-stages/src/image.rs index 0f817d78..dd44156e 100644 --- a/crossdev-stages/src/image.rs +++ b/crossdev-stages/src/image.rs @@ -133,74 +133,39 @@ fn default_deps( target: &Target, board: &BoardConfig, boards_root: &Utf8Path, + defaults_root: &Utf8Path, ) -> Result<()> { - let sandbox_pkgs = boards_root.join(&board.name).join("sandbox-packages.txt"); - if sandbox_pkgs.exists() { + // Sandbox extras: defaults are already installed during prepare; only the + // board's own extras (e.g. grub for pentium-mmx) need emerging here. + // merge() with an empty base means a `-atom` line here can only cancel + // the board's own extras, never uninstall a prepare-time default. + let board_dir = boards_root.join(&board.name); + let board_sandbox = crate::package_list::merge( + Vec::new(), + crate::package_list::read_optional(&board_dir.join("sandbox-packages.txt"))?, + ); + if !board_sandbox.is_empty() { + let portage_dir = sandbox.dir.join("etc/portage"); + crate::package_list::write_accept_keywords(&board_sandbox, &portage_dir)?; + // USE overrides from sandbox-packages.use, e.g. "sys-boot/grub grub_platforms_pc" + crate::package_list::write_package_use( + &board_dir.join("sandbox-packages.use"), + &portage_dir, + )?; let host_runner = board_runner(sandbox, board); let portage = Portage::new(&host_runner); - let content = std::fs::read_to_string(&sandbox_pkgs)?; - let lines: Vec<&str> = content - .lines() - .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .collect(); - - // Write package.accept_keywords entries for packages with keyword overrides. - // Format: "atom [keywords]" — e.g. "sys-boot/syslinux **" or "dev-libs/foo ~amd64" - let accept_keywords_dir = sandbox.dir.join("etc/portage/package.accept_keywords"); - std::fs::create_dir_all(&accept_keywords_dir)?; - let mut pkgs: Vec<&str> = Vec::new(); - for line in &lines { - let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect(); - let atom = parts[0]; - pkgs.push(atom); - if parts.len() > 1 { - let keywords = parts[1].trim(); - let safe_name = atom.replace('/', "_"); - std::fs::write( - accept_keywords_dir.join(&safe_name), - format!("{atom} {keywords}\n"), - )?; - } - } - - // Write package.use entries from sandbox-packages.use. - // Format: "atom use_flags..." — e.g. "sys-boot/syslinux bios -uefi" - let sandbox_use = boards_root.join(&board.name).join("sandbox-packages.use"); - if sandbox_use.exists() { - let use_dir = sandbox.dir.join("etc/portage/package.use"); - std::fs::create_dir_all(&use_dir)?; - let use_content = std::fs::read_to_string(&sandbox_use)?; - for line in use_content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect(); - let atom = parts[0]; - let safe_name = atom.replace('/', "_"); - std::fs::write(use_dir.join(&safe_name), format!("{line}\n"))?; - } - } - - if !pkgs.is_empty() { - portage.emerge(&pkgs)?; - } + portage.emerge(&crate::package_list::atoms(&board_sandbox))?; } - let target_pkgs = boards_root.join(&board.name).join("target-packages.txt"); - if target_pkgs.exists() { - let content = std::fs::read_to_string(&target_pkgs)?; - let pkgs: Vec<&str> = content - .lines() - .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .collect(); - if !pkgs.is_empty() { - let target_runner = board_runner(sandbox, board).with_target(&target.dir); - let portage = Portage::new(&target_runner); - portage.cross_emerge(&board.chost(), &pkgs)?; - } + // Target packages: defaults UNION board extras MINUS board `-atom` lines. + let target_pkgs = crate::package_list::merge( + crate::package_list::read_required(&defaults_root.join("target-packages.txt"))?, + crate::package_list::read_optional(&board_dir.join("target-packages.txt"))?, + ); + if !target_pkgs.is_empty() { + let target_runner = board_runner(sandbox, board).with_target(&target.dir); + let portage = Portage::new(&target_runner); + portage.cross_emerge(&board.chost(), &crate::package_list::atoms(&target_pkgs))?; } Ok(()) @@ -407,6 +372,7 @@ pub fn build( target: &Target, board: &BoardConfig, boards_root: &Utf8Path, + defaults_root: &Utf8Path, steps: Option<&[String]>, ) -> Result<()> { let bld = Build::create(ws, &board.name)?; @@ -442,7 +408,7 @@ pub fn build( let result = match *step { "deps" => run_step("deps", "deps", &bld, &runner, boards_root, board, |_r| { - default_deps(_r, sandbox, target, board, boards_root) + default_deps(_r, sandbox, target, board, boards_root, defaults_root) }), "checkout" => run_step( "checkout", diff --git a/crossdev-stages/src/main.rs b/crossdev-stages/src/main.rs index 78ac5247..6b0f8234 100644 --- a/crossdev-stages/src/main.rs +++ b/crossdev-stages/src/main.rs @@ -4,6 +4,7 @@ mod cli; mod container; mod error; mod image; +mod package_list; mod portage; mod sandbox; mod source_cache; @@ -27,11 +28,12 @@ async fn main() -> anyhow::Result<()> { let ws = crate::workspace::Workspace::open()?; ws.ensure_dirs()?; - let boards_root = { - let p = std::fs::canonicalize(cli.project_dir.join("boards")) - .unwrap_or_else(|_| cli.project_dir.join("boards")); - Utf8PathBuf::try_from(p).expect("boards path is not UTF-8") + let project_dir = { + let p = std::fs::canonicalize(&cli.project_dir).unwrap_or_else(|_| cli.project_dir.clone()); + Utf8PathBuf::try_from(p).expect("project path is not UTF-8") }; + let boards_root = project_dir.join("boards"); + let defaults_root = project_dir.join("defaults"); let mirror = cli.mirror.as_deref(); let dry_run = cli.dry_run; @@ -40,7 +42,7 @@ async fn main() -> anyhow::Result<()> { cli::stages::run(&ws, cmd, mirror).await?; } Commands::Sandbox(cmd) => { - cli::sandbox::run(&ws, cmd, &boards_root, mirror).await?; + cli::sandbox::run(&ws, cmd, &boards_root, &defaults_root, mirror).await?; } Commands::Target { arch, @@ -48,13 +50,13 @@ async fn main() -> anyhow::Result<()> { target, command, } => { - cli::target::run(&ws, arch, sandbox, target, command, mirror).await?; + cli::target::run(&ws, arch, sandbox, target, command, &defaults_root, mirror).await?; } Commands::Board(cmd) => { cli::board::run(&boards_root, cmd)?; } Commands::Image(cmd) => { - cli::image::run(&ws, cmd, &boards_root, mirror, dry_run).await?; + cli::image::run(&ws, cmd, &boards_root, &defaults_root, mirror, dry_run).await?; } Commands::Maint(cmd) => { cli::maint::run(&ws, cmd, &boards_root, dry_run)?; diff --git a/crossdev-stages/src/package_list.rs b/crossdev-stages/src/package_list.rs new file mode 100644 index 00000000..1938cc7e --- /dev/null +++ b/crossdev-stages/src/package_list.rs @@ -0,0 +1,199 @@ +//! Read portage atom lists from text files (`#` comments, blank lines skipped). +//! +//! Line format: `atom [keywords]` — e.g. `sys-boot/syslinux **` or +//! `dev-libs/foo ~amd64`. Keyword overrides are written to +//! `package.accept_keywords/` via [`write_accept_keywords`]. +//! +//! Board lists may subtract from the defaults with a `-atom` line +//! (e.g. `-app-editors/vim`); see [`merge`]. Defaults files define the +//! base set, so `-atom` lines in them are rejected by [`read_required`]. + +use std::io::ErrorKind; + +use camino::Utf8Path; + +use crate::error::{Error, Result}; + +#[derive(Debug)] +pub struct Entry { + pub atom: String, + /// Optional ACCEPT_KEYWORDS override from the `atom [keywords]` syntax. + pub keywords: Option, + /// `-atom` line: remove `atom` from the merged set (board lists only). + pub remove: bool, +} + +/// Read a defaults package list. Errors if the file is missing or contains +/// `-atom` subtraction lines (defaults define the base set; only board +/// lists may subtract). +pub fn read_required(path: &Utf8Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(content) => parse_base(&content, path), + Err(e) if e.kind() == ErrorKind::NotFound => Err(Error::PackageListNotFound(path.into())), + Err(e) => Err(e.into()), + } +} + +fn parse_base(content: &str, path: &Utf8Path) -> Result> { + let entries = parse(content); + if let Some(e) = entries.iter().find(|e| e.remove) { + return Err(Error::SubtractionInDefaults { + file: path.into(), + atom: e.atom.clone(), + }); + } + Ok(entries) +} + +/// Merge board `overlay` entries into the `base` defaults, in order: +/// additions append, `-atom` entries remove the atom from the set built so +/// far. Subtracting an atom that is not in the set is a no-op with a +/// warning (not an error), so a board list survives a default being +/// dropped from `defaults/`. +pub fn merge(mut base: Vec, overlay: Vec) -> Vec { + for e in overlay { + if e.remove { + let before = base.len(); + base.retain(|b| b.atom != e.atom); + if base.len() == before { + tracing::warn!("package list: -{} removes nothing (not in set)", e.atom); + } + } else { + base.push(e); + } + } + base +} + +/// Read a package list if the file exists, otherwise return an empty list. +pub fn read_optional(path: &Utf8Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(content) => Ok(parse(&content)), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(e.into()), + } +} + +/// Borrow just the atoms, e.g. for an emerge command line. +pub fn atoms(entries: &[Entry]) -> Vec<&str> { + entries.iter().map(|e| e.atom.as_str()).collect() +} + +/// Write `package.accept_keywords/` entries under `portage_dir` (an +/// `etc/portage` directory) for atoms with keyword overrides. +pub fn write_accept_keywords(entries: &[Entry], portage_dir: &Utf8Path) -> Result<()> { + let dir = portage_dir.join("package.accept_keywords"); + for e in entries { + if let Some(keywords) = &e.keywords { + std::fs::create_dir_all(&dir)?; + let safe_name = e.atom.replace('/', "_"); + std::fs::write(dir.join(safe_name), format!("{} {keywords}\n", e.atom))?; + } + } + Ok(()) +} + +/// Copy `atom use_flags...` lines from `path` (if present) into +/// `portage_dir/package.use/`, one file per atom. +/// Format: e.g. `sys-boot/grub grub_platforms_pc -grub_platforms_efi-64`. +pub fn write_package_use(path: &Utf8Path, portage_dir: &Utf8Path) -> Result<()> { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let dir = portage_dir.join("package.use"); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + std::fs::create_dir_all(&dir)?; + let atom = line.split_whitespace().next().unwrap_or(line); + let safe_name = atom.replace('/', "_"); + std::fs::write(dir.join(safe_name), format!("{line}\n"))?; + } + Ok(()) +} + +fn parse(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|line| { + let mut parts = line.splitn(2, char::is_whitespace); + let atom = parts.next().unwrap_or(line); + let (atom, remove) = match atom.strip_prefix('-') { + Some(stripped) => (stripped.to_string(), true), + None => (atom.to_string(), false), + }; + let keywords = parts + .next() + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()); + Entry { + atom, + keywords, + remove, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn atoms_owned(entries: &[Entry]) -> Vec { + entries.iter().map(|e| e.atom.clone()).collect() + } + + #[test] + fn parse_subtraction_line() { + let entries = parse("-app-editors/vim\nsys-process/htop\n"); + assert_eq!(entries[0].atom, "app-editors/vim"); + assert!(entries[0].remove); + assert_eq!(entries[1].atom, "sys-process/htop"); + assert!(!entries[1].remove); + } + + #[test] + fn merge_subtracts_default() { + let base = parse("app-editors/vim\nsys-process/htop\n"); + let overlay = parse("-app-editors/vim\nnet-analyzer/nmap\n"); + let merged = merge(base, overlay); + assert_eq!( + atoms_owned(&merged), + vec!["sys-process/htop", "net-analyzer/nmap"] + ); + } + + #[test] + fn merge_subtracting_unlisted_atom_is_noop() { + // Documented behavior: warning, not an error — the merged set is + // unchanged if the subtracted atom was never in it. + let base = parse("app-editors/vim\n"); + let overlay = parse("-net-misc/curl\n"); + let merged = merge(base, overlay); + assert_eq!(atoms_owned(&merged), vec!["app-editors/vim"]); + } + + #[test] + fn defaults_reject_subtraction() { + let path = Utf8Path::new("defaults/target-packages.txt"); + let err = parse_base("net-misc/openssh\n-app-editors/vim\n", path).unwrap_err(); + assert!(matches!( + err, + Error::SubtractionInDefaults { ref atom, .. } if atom == "app-editors/vim" + )); + } + + #[test] + fn keywords_still_parse() { + let entries = parse("sys-boot/syslinux **\n"); + assert_eq!(entries[0].atom, "sys-boot/syslinux"); + assert_eq!(entries[0].keywords.as_deref(), Some("**")); + assert!(!entries[0].remove); + } +} diff --git a/crossdev-stages/src/portage.rs b/crossdev-stages/src/portage.rs index 0f185455..263be584 100644 --- a/crossdev-stages/src/portage.rs +++ b/crossdev-stages/src/portage.rs @@ -146,12 +146,6 @@ impl<'a> Portage<'a> { Ok(()) } - /// Emerge packages from binary packages only (`-G`). - pub fn emerge_binary(&self, packages: &[&str]) -> Result<()> { - let pkgs = packages.join(" "); - self.runner.run(&format!("emerge -G {pkgs}")) - } - /// Emerge packages, using binary if available (`-b -k`). pub fn emerge(&self, packages: &[&str]) -> Result<()> { let pkgs = packages.join(" "); @@ -202,32 +196,28 @@ pub fn sync_portage_tree(runner: &SandboxRunner) -> Result<()> { } /// Install all host-side dependencies required for cross-compilation. -pub fn install_host_deps(runner: &SandboxRunner) -> Result<()> { +/// +/// Reads the package list from `/sandbox-packages.txt`. Per-board +/// extras (boards//sandbox-packages.txt) are emerged separately during +/// image build via `image::default_deps`. Keyword overrides (`atom [keywords]` +/// lines) are written to `/package.accept_keywords/`. +pub fn install_host_deps( + runner: &SandboxRunner, + defaults_root: &Utf8Path, + portage_dir: &Utf8Path, +) -> Result<()> { sync_portage_tree(runner)?; let portage = Portage::new(runner); - let bin_packages = ["app-arch/zstd", "app-arch/bzip2", "app-arch/xz-utils"]; - tracing::info!("Installing binary packages…"); - portage.emerge_binary(&bin_packages)?; - - let packages = [ - "sys-devel/crossdev", - "sys-devel/bc", - "sys-apps/merge-usr", - "dev-vcs/git", - "dev-embedded/u-boot-tools", - "sys-apps/dtc", - "sys-kernel/dracut", - "sys-apps/busybox", - "sys-fs/genimage", - "sys-fs/dosfstools", - "sys-fs/mtools", - "app-eselect/eselect-repository", - "dev-lang/rust", - "dev-python/pyelftools", - ]; - tracing::info!("Installing build dependencies…"); - portage.emerge(&packages)?; + let path = defaults_root.join("sandbox-packages.txt"); + let packages = crate::package_list::read_required(&path)?; + crate::package_list::write_accept_keywords(&packages, portage_dir)?; + + tracing::info!( + "Installing host build dependencies ({} packages)…", + packages.len() + ); + portage.emerge(&crate::package_list::atoms(&packages))?; tracing::info!("Installing Rust ldconfig…"); runner.run("cargo install --root /usr/local ldconfig")?; diff --git a/crossdev-stages/src/sandbox.rs b/crossdev-stages/src/sandbox.rs index e9310134..7f252a23 100644 --- a/crossdev-stages/src/sandbox.rs +++ b/crossdev-stages/src/sandbox.rs @@ -48,7 +48,7 @@ impl Sandbox { /// Idempotent: skips if `.prepared` marker exists (or `.prepared-bare` when `bare`). /// /// With `bare`, writes `make.conf` and syncs the portage tree but does not emerge packages. - pub fn prepare(&self, mirror: Option<&str>, bare: bool) -> Result<()> { + pub fn prepare(&self, mirror: Option<&str>, defaults_root: &Utf8Path, bare: bool) -> Result<()> { if self.dir.join(".prepared").exists() { tracing::info!("Sandbox already prepared, skipping."); return Ok(()); @@ -67,13 +67,22 @@ impl Sandbox { } .write(&self.dir.join("etc/portage"))?; + // Overlay any user-provided portage config from defaults/portage/ + // onto the sandbox's /etc/portage/. Lets boards declare USE flags + // (e.g. sys-fs/dosfstools compat) and other portage tweaks as files + // rather than hardcoded strings. + let portage_src = defaults_root.join("portage"); + if portage_src.is_dir() { + copy_tree(&portage_src, &self.dir.join("etc/portage"))?; + } + if bare { sync_portage_tree(&self.runner())?; std::fs::write(self.dir.join(".prepared-bare"), "")?; tracing::info!("Sandbox bare-prepared."); } else { tracing::info!("Installing host dependencies…"); - install_host_deps(&self.runner())?; + install_host_deps(&self.runner(), defaults_root, &self.dir.join("etc/portage"))?; std::fs::write(self.dir.join(".prepared"), "")?; let _ = std::fs::remove_file(self.dir.join(".prepared-bare")); tracing::info!("Sandbox prepared."); @@ -355,9 +364,7 @@ impl Sandbox { let Some(ref platforms) = board.grub_platforms else { return Ok(()); }; - let grub_mods = self - .dir - .join(format!("usr/{chost}/usr/lib/grub/i386-pc")); + let grub_mods = self.dir.join(format!("usr/{chost}/usr/lib/grub/i386-pc")); if grub_mods.exists() { return Ok(()); } @@ -368,7 +375,9 @@ impl Sandbox { .split_whitespace() .map(|p| format!("grub_platforms_{p}")) .collect(); - let use_dir = self.dir.join(format!("usr/{chost}/etc/portage/package.use")); + let use_dir = self + .dir + .join(format!("usr/{chost}/etc/portage/package.use")); std::fs::create_dir_all(&use_dir)?; std::fs::write( use_dir.join("grub"), @@ -513,3 +522,29 @@ pub struct SandboxInfo { pub prepared: bool, pub bare_prepared: bool, } + +/// Recursively copy directory `src` into `dst`, overwriting files. +/// Symlinks and other special entries are rejected. +fn copy_tree(src: &Utf8Path, dst: &Utf8Path) -> Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let kind = entry.file_type()?; + let from = Utf8PathBuf::try_from(entry.path()).map_err(|e| Error::CommandFailed { + code: 1, + reason: e.to_string(), + })?; + let to = dst.join(from.file_name().unwrap_or_default()); + if kind.is_dir() { + copy_tree(&from, &to)?; + } else if kind.is_file() { + std::fs::copy(&from, &to)?; + } else { + return Err(Error::CommandFailed { + code: 1, + reason: format!("unsupported entry in portage overlay (symlink?): {from}"), + }); + } + } + Ok(()) +} diff --git a/defaults/sandbox-packages.txt b/defaults/sandbox-packages.txt new file mode 100644 index 00000000..a39ce2b2 --- /dev/null +++ b/defaults/sandbox-packages.txt @@ -0,0 +1,32 @@ +# Host build deps installed into every sandbox during prepare. +# Per-board extras can be added in boards//sandbox-packages.txt +# (additive — both lists are emerged). + +# Bootstrap: compression tools for portage tree snapshot +app-arch/zstd +app-arch/bzip2 +app-arch/xz-utils + +# Cross-toolchain + portage utilities +sys-devel/crossdev +sys-devel/bc +sys-apps/merge-usr +app-eselect/eselect-repository + +# Source / VCS +dev-vcs/git + +# Boot / firmware +dev-embedded/u-boot-tools +sys-apps/dtc +sys-kernel/dracut +sys-apps/busybox + +# Image generation +sys-fs/genimage +sys-fs/dosfstools +sys-fs/mtools + +# Languages used during build +dev-lang/rust +dev-python/pyelftools diff --git a/defaults/target-packages.txt b/defaults/target-packages.txt new file mode 100644 index 00000000..06655c99 --- /dev/null +++ b/defaults/target-packages.txt @@ -0,0 +1,39 @@ +# Default packages cross-emerged into every image's sysroot. +# Lifted from cross-stage.sh ADDITIONAL_PACKAGES, trimmed to light +# essentials. Per-board extras go in boards//target-packages.txt; +# a board can drop a default with a `-atom` line (e.g. `-app-misc/fastfetch`). +# +# Deliberately NOT here (boards opt in via their own list): +# - heavy toolchains: sys-devel/mold, dev-lang/go, dev-build/cmake, +# dev-lang/rust +# - wireless: net-wireless/iw, net-wireless/wpa_supplicant +# - app-misc/screen: cross-emerge fails at fowners root:utmp (utmp user +# missing from the cross-prefix passwd) — do not re-add here +# - dev-build/meson: PEP517 wheel build is fragile under cross-compilation +# (host/target python paths confused) — do not re-add here + +# Networking / remote access +net-misc/openssh +net-misc/ntp + +# Logging +app-admin/metalog + +# Initramfs / minimal userland +sys-apps/busybox + +# Basic interactive tools +app-editors/vim +sys-process/htop +app-misc/fastfetch + +# Disk utilities +sys-block/parted + +# Dev / diagnostics +dev-vcs/git +net-analyzer/nmap + +# Portage helpers (equery, genlop) +app-portage/gentoolkit +app-portage/genlop diff --git a/docs/design.md b/docs/design.md index 7d5c986b..282970c5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -120,7 +120,7 @@ and per-package CFLAGS workarounds. Steps run in order (configurable via `BUILD_STEPS` in `board.conf`): ``` -deps Install extra sandbox packages + cross-emerge target-packages.txt. +deps Cross-emerge defaults + per-board package lists into the target. checkout Clone/update kernel, opensbi, u-boot source via git source cache. bootloader Build opensbi and/or u-boot inside the sandbox container. kernel Build Linux kernel + modules; install modules into target stage. @@ -143,6 +143,37 @@ sibling helpers. --- +## Package lists + +`defaults/` holds package lists applied everywhere: + +- `defaults/sandbox-packages.txt` — host build deps emerged during + `sandbox prepare` (required). +- `defaults/target-packages.txt` — packages cross-emerged into every + image's sysroot during `deps` (required). + +Per-board lists (`boards//sandbox-packages.txt`, `target-packages.txt`) +overlay the defaults. The effective target set is +`defaults/target-packages.txt` UNION `boards//target-packages.txt` +MINUS the board's `-atom` lines (e.g. `-app-misc/fastfetch`) — every part +is a plain file. Subtracting an atom not in the merged set warns and does +nothing; `-atom` lines in a defaults file are rejected with an error, since +defaults define the base and only boards subtract. Heavy per-board opt-ins +(mold, go, cmake, rust, iw, wpa_supplicant) are deliberately not defaults. +Sandbox defaults are installed at prepare time, so a `-atom` in a board's +`sandbox-packages.txt` can only cancel the board's own extras. + +List lines are `atom [keywords]`: an optional keyword override (e.g. +`sys-boot/syslinux **`) is written to `etc/portage/package.accept_keywords/` +before emerging. A board's `sandbox-packages.use` (`atom use_flags...` +lines) is written to `etc/portage/package.use/` the same way. + +Files under `defaults/portage/` are overlaid onto the sandbox's +`etc/portage/` during prepare. Prepare is idempotent (`.prepared` +marker), so later changes do not reach already-prepared sandboxes. + +--- + ## Sandboxing approach hakoniwa creates a user-namespace container (unshares Mount, User, Pid, Ipc,