From 81942ccf54ea8919611a3ee10dc346147a6e2d4b Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 06:26:40 +0900 Subject: [PATCH 01/13] boards: TESTING is dead config, say it with TAGS --- boards/odroid-c2/board.conf | 2 +- boards/odroid-c4/board.conf | 2 +- boards/odroid-xu4/board.conf | 2 +- boards/orangepi-5-ultra/board.conf | 2 +- boards/vsrves01/board.conf | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/boards/odroid-c2/board.conf b/boards/odroid-c2/board.conf index 5cadbbe9..5c2f6bea 100644 --- a/boards/odroid-c2/board.conf +++ b/boards/odroid-c2/board.conf @@ -1,4 +1,4 @@ -TESTING="true" +TAGS=("aarch64" "amlogic" "s905" "odroid" "testing") BOARD_NAME="odroid-c2" BOARD_ARCH="aarch64" diff --git a/boards/odroid-c4/board.conf b/boards/odroid-c4/board.conf index 3ec5654e..f0c3e6a8 100644 --- a/boards/odroid-c4/board.conf +++ b/boards/odroid-c4/board.conf @@ -1,4 +1,4 @@ -TESTING="true" +TAGS=("aarch64" "amlogic" "s905x3" "odroid" "testing") BOARD_NAME="odroid-c4" BOARD_ARCH="aarch64" diff --git a/boards/odroid-xu4/board.conf b/boards/odroid-xu4/board.conf index d225f265..6bbac37e 100644 --- a/boards/odroid-xu4/board.conf +++ b/boards/odroid-xu4/board.conf @@ -1,4 +1,4 @@ -TESTING="true" +TAGS=("armv7a" "exynos" "exynos5422" "odroid" "testing") BOARD_NAME="odroid-xu4" BOARD_ARCH="armv7a" diff --git a/boards/orangepi-5-ultra/board.conf b/boards/orangepi-5-ultra/board.conf index ad8dd4c5..429c1609 100644 --- a/boards/orangepi-5-ultra/board.conf +++ b/boards/orangepi-5-ultra/board.conf @@ -1,4 +1,4 @@ -TESTING="true" +TAGS=("aarch64" "rockchip" "rk3588" "orangepi" "testing") BOARD_NAME="orangepi-5-ultra" BOARD_ARCH="aarch64" diff --git a/boards/vsrves01/board.conf b/boards/vsrves01/board.conf index 83ca9386..73b4708a 100644 --- a/boards/vsrves01/board.conf +++ b/boards/vsrves01/board.conf @@ -1,6 +1,6 @@ BOARD_NAME="vsrves01" BOARD_ARCH="riscv32" -TESTING="true" +TAGS=("riscv32" "vlsi" "vsrves01" "testing") BOARD_CFLAGS="-Os -march=rv32ima_zicsr_zifencei -mabi=ilp32 -mcmodel=medlow -pipe" BOARD_GCC_VERSION="16.1.0" CROSS_COMPILE="riscv32-unknown-linux-musl-" From 00af240a031dd575578e12b5dd526ee33ecb56bd Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 11:43:27 +0900 Subject: [PATCH 02/13] sandbox: install the overlay for the providers that need it, not for every run --- crossdev-stages/src/image.rs | 7 +++ crossdev-stages/src/provider.rs | 34 ++++++++++++ crossdev-stages/src/sandbox.rs | 98 +++++++++++++++++++++------------ 3 files changed, 103 insertions(+), 36 deletions(-) diff --git a/crossdev-stages/src/image.rs b/crossdev-stages/src/image.rs index a161c926..8683b131 100644 --- a/crossdev-stages/src/image.rs +++ b/crossdev-stages/src/image.rs @@ -2089,6 +2089,13 @@ pub fn build( let result = match *step { "deps" => run_step("deps", "deps", &bld, &runner, boards_root, board, |_r| { + // The overlay is a precondition of these providers alone + // (apk-tools, dnf5), so it is installed here and not in + // prepare(): every other command, a plain kernel build + // included, must build with the overlay repo unreachable. + if provider.needs_overlay() { + sandbox.install_overlay(defaults_root, provider)?; + } // A wrong atom is otherwise only found by emerge, which gets // there after the sandbox list has already been built -- ten // minutes of compiling thrown away over a package that was diff --git a/crossdev-stages/src/provider.rs b/crossdev-stages/src/provider.rs index 33b7f6ee..595dd0a1 100644 --- a/crossdev-stages/src/provider.rs +++ b/crossdev-stages/src/provider.rs @@ -145,6 +145,21 @@ impl RootfsProvider { } } + /// Whether the `deps` step emerges atoms the crossdev-stages overlay + /// carries (`app-arch/apk-tools`, `sys-apps/dnf5`). The only reason + /// any build needs the overlay repository to be reachable, so nothing + /// else may treat it as a precondition. + pub fn needs_overlay(&self) -> bool { + match self { + Self::Alpine | Self::Fedora => true, + Self::Gentoo + | Self::Debian + | Self::Ubuntu + | Self::Buildroot + | Self::None => false, + } + } + /// The debootstrap flavour behind this provider, if it is one. pub fn debootstrap(&self) -> Option { match self { @@ -442,6 +457,25 @@ impl FedoraImage { mod tests { use super::{Debootstrap, RootfsProvider, SecondStage}; + #[test] + fn only_alpine_and_fedora_need_the_overlay() { + assert!(RootfsProvider::Alpine.needs_overlay()); + assert!(RootfsProvider::Fedora.needs_overlay()); + for p in [ + RootfsProvider::Gentoo, + RootfsProvider::Debian, + RootfsProvider::Ubuntu, + RootfsProvider::Buildroot, + RootfsProvider::None, + ] { + assert!( + !p.needs_overlay(), + "{} must build without the overlay", + p.name() + ); + } + } + #[test] fn parse_known_values() { assert_eq!(RootfsProvider::parse("gentoo"), Some(RootfsProvider::Gentoo)); diff --git a/crossdev-stages/src/sandbox.rs b/crossdev-stages/src/sandbox.rs index 11a213cf..469dbc48 100644 --- a/crossdev-stages/src/sandbox.rs +++ b/crossdev-stages/src/sandbox.rs @@ -10,6 +10,7 @@ use crate::container::{ }; use crate::error::{Error, Result}; use crate::portage::{install_host_deps, sync_portage_tree, MakeConf}; +use crate::provider::RootfsProvider; use crate::stage::gentoo_profile; use crate::workspace::{store_key, Workspace}; @@ -50,15 +51,9 @@ 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. When `/overlay.conf` names one, - /// installs the `crossdev-stages` portage overlay (apk-tools, dnf5, the - /// opt-in ESOS firmware ebuilds). + /// emerge packages. The `crossdev-stages` overlay is not installed here: + /// see [`Sandbox::install_overlay`]. pub fn prepare(&self, mirror: Option<&str>, defaults_root: &Utf8Path, bare: bool) -> Result<()> { - // The overlay refreshes on every prepare, even on an already-prepared - // sandbox: the pinned overlay repo is the source of truth and the - // checkout is cheap and idempotent. - install_overlay(self.runner(), &self.dir, defaults_root)?; - if self.dir.join(".prepared").exists() { tracing::info!("Sandbox already prepared, skipping."); return Ok(()); @@ -96,6 +91,36 @@ impl Sandbox { Ok(()) } + /// Install the `crossdev-stages` portage overlay at the revision pinned + /// in `/overlay.conf`. + /// + /// Only a provider whose `deps` step emerges from the overlay may call + /// this ([`RootfsProvider::needs_overlay`]), and there it is fatal: + /// making it a precondition of `prepare` instead let an unreachable + /// overlay repository kill kernel builds that use no ebuild of it. + /// `provider` only names the reason in the error. + pub fn install_overlay( + &self, + defaults_root: &Utf8Path, + provider: RootfsProvider, + ) -> Result<()> { + let conf = defaults_root.join("overlay.conf"); + let failed = |msg: String| Error::Config { + file: conf.to_string(), + msg: format!( + "ROOTFS_PROVIDER={} needs packages from the crossdev-stages \ + overlay, but {msg}", + provider.name() + ), + }; + let Some((repo, tag)) = read_overlay_conf(&conf)? else { + return Err(failed("OVERLAY_REPO is empty".into())); + }; + tracing::info!("Installing crossdev-stages overlay from {repo} ({tag})…"); + install_overlay(self.runner(), &self.dir, &repo, &tag) + .map_err(|e| failed(format!("installing it from {repo} failed: {e}"))) + } + /// Return installed GCC versions grouped by slot, using `.gcc_versions` cache if present. /// Versions within each slot are sorted newest-first. pub fn get_installed_gcc_versions(&self) -> Result>> { @@ -798,48 +823,49 @@ const OVERLAY_SRC_IN_CONTAINER: &str = "/.overlay-src"; /// Overlay checkout inside the sandbox. const OVERLAY_DIR: &str = "/var/db/repos/crossdev-stages"; -/// Install the `crossdev-stages` portage overlay at the revision pinned in -/// `/overlay.conf` and write a repos.conf entry. -/// No-op when that file is absent or sets no `OVERLAY_REPO`. +/// Where a new checkout is staged before it replaces `OVERLAY_DIR`. +const OVERLAY_STAGE: &str = "/var/db/repos/.crossdev-stages.new"; + +/// Check out the overlay `repo` at `tag` inside the sandbox and write its +/// repos.conf entry. Not called directly: see [`Sandbox::install_overlay`]. /// /// The ebuilds live in their own repository, so this one carries no ebuilds -/// and no licenses but its own. `OVERLAY_REPO` may also name a local -/// clone, which is bind-mounted read-only and copied in; that is the route -/// for an overlay that is not published yet. -fn install_overlay( - runner: SandboxRunner, - sandbox: &Utf8Path, - defaults_root: &Utf8Path, -) -> Result<()> { - let conf = defaults_root.join("overlay.conf"); - let Some((repo, tag)) = read_overlay_conf(&conf)? else { - return Ok(()); - }; - tracing::info!("Installing crossdev-stages overlay from {repo} ({tag})…"); - +/// and no licenses but its own. `repo` may also name a local clone, which +/// is bind-mounted read-only and copied in; that is the route for an +/// overlay that is not published yet. +fn install_overlay(runner: SandboxRunner, sandbox: &Utf8Path, repo: &str, tag: &str) -> Result<()> { // /var/db/repos is created by portage inside the container, so it ends up // owned by a subordinate uid the host user cannot write to: every write // below goes through the container. + // + // The checkout is staged next to the overlay and swapped in only once it + // is complete, so a failure leaves the previous overlay and the + // repos.conf entry that points at it consistent with each other, never + // an empty directory portage is still told to read. runner.run(&format!( - "mkdir -p {OVERLAY_DIR} && find {OVERLAY_DIR} -mindepth 1 -delete" + "rm -rf {OVERLAY_STAGE} && mkdir -p {OVERLAY_STAGE}" ))?; - let local = Utf8Path::new(&repo); - if local.is_dir() { + let local = Utf8Path::new(repo); + let runner = if local.is_dir() { + let runner = runner.with_extra_ro(local, OVERLAY_SRC_IN_CONTAINER); + runner.run(&format!( + "cp -a {OVERLAY_SRC_IN_CONTAINER}/. {OVERLAY_STAGE}/" + ))?; runner - .with_extra_ro(local, OVERLAY_SRC_IN_CONTAINER) - .run(&format!( - "cp -a {OVERLAY_SRC_IN_CONTAINER}/. {OVERLAY_DIR}/" - ))?; } else { crate::source_cache::cached_clone( &runner, - &repo, - &tag, - OVERLAY_DIR, + repo, + tag, + OVERLAY_STAGE, "crossdev-stages-overlay", )?; - } + runner + }; + runner.run(&format!( + "rm -rf {OVERLAY_DIR} && mv {OVERLAY_STAGE} {OVERLAY_DIR}" + ))?; let repos_conf = sandbox.join("etc/portage/repos.conf"); fs::create_dir_all(&repos_conf)?; From 3b39a73f89d5342a7508d1fc476e53cd29507394 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 11:43:27 +0900 Subject: [PATCH 03/13] board: show the steps a board invented, not just the built-in six --- crossdev-stages/src/cli/board.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crossdev-stages/src/cli/board.rs b/crossdev-stages/src/cli/board.rs index e12a0c12..2f7493d5 100644 --- a/crossdev-stages/src/cli/board.rs +++ b/crossdev-stages/src/cli/board.rs @@ -143,14 +143,16 @@ pub fn run(boards_root: &Utf8Path, cmd: BoardCmd) -> Result<()> { } let board_dir = boards_root.join(&board_name); - let steps = [ - "deps", - "checkout", - "bootloader", - "kernel", - "assemble", - "pack", - ]; + // The default steps, plus any step this board invented. A custom + // step has no Rust default, so its override-.sh is the only + // thing that makes it run at all -- listing only the six built-in + // names hides the hook that defines such a board. + let mut steps: Vec<&str> = board::DEFAULT_BUILD_STEPS.to_vec(); + for s in board_cfg.effective_build_steps() { + if !steps.contains(&s) { + steps.push(s); + } + } let mut hooks = Vec::new(); for s in &steps { if board_dir.join(format!("override-{s}.sh")).exists() { From e41843d6df008afa5efe8eda9688a2f8de4c4206 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 11:43:27 +0900 Subject: [PATCH 04/13] readme: drop the headings a rebase duplicated --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c3d13028..0fbf0410 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Rootless cross-compilation of Gentoo stages using crossdev and hakoniwa | pentium-mmx | i586 | mainline v6.18 | BIOS (no firmware) | `-O2 -march=pentium-mmx` | testing | | premier-p550 | riscv64 | mainline v7.2-rc1 | vendor QSPI (OpenSBI + U-Boot) + extlinux | `-O3 -march=rv64gc_zba_zbb` | testing | | vsrves01 | riscv32 | mainline v6.18 + patches | VSDSP6/VSOS DDRLoad (no U-Boot/OpenSBI) | `-Os -march=rv32ima_zicsr_zifencei` | experimental (hardware dead) | +| ch32v467 | riscv32 | mainline v7.1 + patches (buildroot) | none: MCU firmware is the machine (tiny-rv32ima) | `-Os -march=rv32ima_zicsr_zifencei` | experimental (never run on hardware) | ## CLI @@ -411,7 +412,6 @@ Distinct from `enter` below, which opens a shell in the container the *build* runs in: the build host, with the cross compiler, and the target mounted at `/target`. -### Debugging a build ### Debugging a build `enter` opens a shell in the very container a build step runs in -- same @@ -457,7 +457,6 @@ The measurement is the ABI and ISA checks below, against the finished image. They are the only thing that can fail a build, because they are the only thing that reads the artifact. -### ABI verification ### ABI verification Nothing in a binary package records the libc or the compiler that produced it. From e0753019e32a92d8f50c7e55fa7491351b24e9de Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 11:53:44 +0900 Subject: [PATCH 05/13] manifest: fail when a pinned sha and the built tree disagree --- crossdev-stages/src/image.rs | 18 +++++ crossdev-stages/src/manifest.rs | 108 +++++++++++++++++++++++++++- crossdev-stages/src/source_cache.rs | 2 +- 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/crossdev-stages/src/image.rs b/crossdev-stages/src/image.rs index 8683b131..851f88c6 100644 --- a/crossdev-stages/src/image.rs +++ b/crossdev-stages/src/image.rs @@ -2241,6 +2241,10 @@ pub fn build( .with_build(&bld.dir, &project_root(boards_root)) .with_cache(ws.base()); record_sources(&runner, &mut manifest, board)?; + // Read before write(), which consumes the builder. The lock is written + // either way: it is the evidence of what this build actually compiled, + // and a build that fails is exactly when someone needs to read it. + let pin_mismatches = manifest.pin_mismatches(); if manifest.has_resolved_source() { let manifest_path = bld.dir.join("build.lock.toml"); manifest.write(&runner, &manifest_path)?; @@ -2253,6 +2257,20 @@ pub fn build( tracing::info!("Skipping manifest write: no resolved git sources yet"); } + // A tag that is a full commit SHA and a tree that is not on it are the + // same reading taken twice and disagreeing, not a stale record: the image + // was built from sources other than the ones it names. Like the ABI and + // ISA checks, this reads what was built, so it is allowed to fail. + if !pin_mismatches.is_empty() { + for line in &pin_mismatches { + tracing::error!("{line}"); + } + return Err(crate::error::Error::CommandFailed { + code: 1, + reason: "build.lock.toml records sources this build did not build".into(), + }); + } + let total_elapsed = build_start.elapsed(); println!("\nBuild complete: {}", format_duration(total_elapsed)); Ok(()) diff --git a/crossdev-stages/src/manifest.rs b/crossdev-stages/src/manifest.rs index 3ddaa206..9a80c16a 100644 --- a/crossdev-stages/src/manifest.rs +++ b/crossdev-stages/src/manifest.rs @@ -1,8 +1,9 @@ //! Build provenance and image manifests. Two emitters: //! //! - [`ManifestBuilder`] → `build.lock.toml` in the build dir: source -//! commits, stage3, toolchain CFLAGS, config hashes. Observability -//! only (no enforcement yet). +//! commits, stage3, toolchain CFLAGS, config hashes. Observability, +//! with one exception: a source pinned to a commit SHA that the built +//! tree is not on fails the build (see `pin_mismatches`). //! - [`write_image_sidecar`] → `.manifest.json` next to the packed //! image: full-image sha256 + partition table (offset/size/source/sha256) //! for verifying integrity and dd-ing partitions to eMMC/SPI flash at @@ -19,7 +20,7 @@ use crate::container::SandboxRunner; use crate::error::Result; /// What went into a single image build. Written as `build.lock.toml` in the -/// build dir at pipeline end. Phase 1: observability only (no enforcement). +/// build dir at pipeline end. #[derive(Debug, Serialize)] pub struct BuildManifest { pub build: BuildMeta, @@ -153,6 +154,29 @@ impl ManifestBuilder { Ok(()) } + /// Sources whose recorded tag is a full commit SHA the built tree does + /// not sit on. A 40-hex tag names one tree and nothing else, so tag and + /// commit are the same quantity read twice: what the build was told to + /// check out, and what `git rev-parse HEAD` found in the tree the build + /// compiled. They cannot legitimately disagree -- a skipped checkout, or + /// a SHA pinned onto the wrong repo, and the image is not what the lock + /// says it is. A named tag or branch makes no such claim and is skipped. + pub fn pin_mismatches(&self) -> Vec { + self.sources + .iter() + .filter(|(_, s)| matches!(s.kind, SourceKind::Git)) + .filter(|(_, s)| { + crate::source_cache::is_commit_sha(&s.tag) && !s.tag.eq_ignore_ascii_case(&s.commit) + }) + .map(|(name, s)| { + format!( + "source '{name}' pinned to {} but the tree that was built is at {}", + s.tag, s.commit + ) + }) + .collect() + } + /// Gather toolchain CFLAGS by reading the two relevant make.conf files /// inside the sandbox. fn read_toolchain(&self, runner: &SandboxRunner) -> Result { @@ -418,6 +442,84 @@ fn parse_partitions(cfg: &str) -> Vec { mod tests { use super::*; + fn builder_with(entries: &[(&str, &str, &str, SourceKind)]) -> ManifestBuilder { + let mut b = ManifestBuilder::new(&crate::cli::util::default_board_config("aarch64")); + for (name, tag, commit, kind) in entries { + b.sources.insert( + (*name).to_string(), + SourceEntry { + repo: "https://example.invalid/repo.git".into(), + tag: (*tag).to_string(), + commit: (*commit).to_string(), + kind: match kind { + SourceKind::Git => SourceKind::Git, + SourceKind::Local => SourceKind::Local, + SourceKind::Missing => SourceKind::Missing, + }, + path: format!("/build/{name}"), + }, + ); + } + b + } + + /// The lock a real build wrote on 2026-08-25: the kernel step was skipped + /// by its resume marker, so the pin never reached the tree. + #[test] + fn pinned_sha_against_a_stale_tree_is_a_mismatch() { + let b = builder_with(&[( + "kernel", + "4e69c1856bfd9ffb7e9d335a25842fa211628929", + "8d3ae59288f1e7d58d76558a6ee96d533bc5019f", + SourceKind::Git, + )]); + let found = b.pin_mismatches(); + assert_eq!(found.len(), 1); + assert!(found[0].contains("kernel")); + } + + #[test] + fn pinned_sha_that_matches_is_clean() { + let sha = "4e69c1856bfd9ffb7e9d335a25842fa211628929"; + assert!(builder_with(&[("kernel", sha, sha, SourceKind::Git)]) + .pin_mismatches() + .is_empty()); + // git prints lowercase; a hand-edited board.conf may not. + let upper = sha.to_ascii_uppercase(); + assert!(builder_with(&[("kernel", &upper, sha, SourceKind::Git)]) + .pin_mismatches() + .is_empty()); + } + + /// A name resolves to whatever it points at today, so it claims nothing + /// the commit could contradict. + #[test] + fn named_tags_and_branches_claim_nothing() { + for tag in ["v7.2", "master", "linux-6.6.y"] { + assert!(builder_with(&[( + "kernel", + tag, + "8d3ae59288f1e7d58d76558a6ee96d533bc5019f", + SourceKind::Git, + )]) + .pin_mismatches() + .is_empty()); + } + } + + /// commit is a tree hash for local and empty for missing; neither is a + /// git sha and neither can be compared to one. + #[test] + fn non_git_sources_are_not_compared() { + let sha = "4e69c1856bfd9ffb7e9d335a25842fa211628929"; + assert!(builder_with(&[("kernel", sha, "deadbeef", SourceKind::Local)]) + .pin_mismatches() + .is_empty()); + assert!(builder_with(&[("kernel", sha, "", SourceKind::Missing)]) + .pin_mismatches() + .is_empty()); + } + #[test] fn skips_preceding_filesystem_images() { let cfg = r#" diff --git a/crossdev-stages/src/source_cache.rs b/crossdev-stages/src/source_cache.rs index 1c505858..084b58cf 100644 --- a/crossdev-stages/src/source_cache.rs +++ b/crossdev-stages/src/source_cache.rs @@ -47,7 +47,7 @@ pub fn cached_clone( /// True for a full 40-hex git commit SHA, as written into build.lock.toml /// by `git rev-parse HEAD` and fed back through `image build --pinned`. -fn is_commit_sha(tag: &str) -> bool { +pub(crate) fn is_commit_sha(tag: &str) -> bool { tag.len() == 40 && tag.bytes().all(|b| b.is_ascii_hexdigit()) } From 4ae430a84236fd759d1ee533b74c85e50304d13e Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 11:55:09 +0900 Subject: [PATCH 06/13] provider: a step a board invented gets the cross toolchain --- README.md | 2 +- crossdev-stages/src/provider.rs | 34 ++++++++++++++++++++++++++++++--- docs/design.md | 8 ++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0fbf0410..b68b6973 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ by `uboot`. | `BOARD_GCC_VERSION` | no | Pin gcc: `15` (slot), `15.2` (prefix), or exact version (default: highest installed slot) | | `KERNEL_TAG` | no | Kernel git ref (default: top-level `TAG`) | | `KERNEL_ARCH` | no | Linux `ARCH=` value (default: auto from `BOARD_ARCH`) | -| `BUILD_STEPS` | no | Build pipeline steps (default: deps checkout bootloader kernel assemble pack); custom step names require a matching `override-.sh` hook | +| `BUILD_STEPS` | no | Build pipeline steps (default: deps checkout bootloader kernel assemble pack); custom step names require a matching `override-.sh` hook and build with the cross toolchain | | `BOOT_PIPELINE` | no | Ordered bootloader stages (default: `("opensbi" "uboot" "syslinux" "grub")`; `()` = none) | | `BOOT_EXTLINUX` | no | `true` makes `assemble` write `/extlinux/extlinux.conf` | | `BOOT_APPEND` | no | Kernel arguments added to that entry | diff --git a/crossdev-stages/src/provider.rs b/crossdev-stages/src/provider.rs index 595dd0a1..13af30f4 100644 --- a/crossdev-stages/src/provider.rs +++ b/crossdev-stages/src/provider.rs @@ -118,12 +118,27 @@ impl RootfsProvider { | Self::Alpine | Self::Fedora | Self::Buildroot - | Self::None => steps - .iter() - .any(|s| matches!(*s, "kernel" | "bootloader")), + | Self::None => steps.iter().any(|s| Self::step_needs_cross_toolchain(s)), } } + /// Whether one build step compiles target code, and so needs the + /// prefix. Stated as what does not: `deps` fills /target with the + /// provider's own tool, `checkout` clones, `assemble` lays out a + /// tree, `pack` runs genimage. Everything else does, invented step + /// names included: a board writes `override-.sh` precisely + /// when it builds what no built-in step builds, which is the case + /// most likely to want a cross compiler. Naming the compiling + /// steps instead mounted no prefix for such a step, leaving its + /// hook the host gcc or whatever toolchain residue the sandbox + /// rootfs happened to carry, and the ABI and ISA checks that would + /// catch the result need that same prefix to run. Work that + /// compiles nothing belongs in a `pre-`/`post-.sh` hook on a + /// step that already runs, and costs no toolchain. + fn step_needs_cross_toolchain(step: &str) -> bool { + !matches!(step, "deps" | "checkout" | "assemble" | "pack") + } + /// Whether `/target` is seeded from a Gentoo stage3 tarball. pub fn provisions_stage3(&self) -> bool { matches!(self, Self::Gentoo) @@ -524,6 +539,19 @@ mod tests { assert!(p.needs_cross_toolchain(&["bootloader", "pack"])); } + /// A board only invents a step because it compiles something no + /// built-in step covers, so the invented step is the one most + /// likely to want the prefix. Naming the compiling steps left it + /// with no prefix and no way to ask for one. + #[test] + fn a_step_a_board_invented_gets_the_toolchain() { + let p = RootfsProvider::Debian; + assert!(p.needs_cross_toolchain(&["deps", "firmware", "assemble", "pack"])); + // The inverted list is load-bearing: every built-in step that + // compiles nothing has to stay toolchain-free. + assert!(!p.needs_cross_toolchain(&["deps", "checkout", "assemble", "pack"])); + } + /// Which of (a) and (c) a buildroot board gets is decided by /// BUILD_STEPS, not by the provider: no crossdev for a board whose /// kernel comes out of the defconfig, crossdev for one that still diff --git a/docs/design.md b/docs/design.md index 71fcbca7..3b245107 100644 --- a/docs/design.md +++ b/docs/design.md @@ -150,7 +150,9 @@ none nothing seeded, installed, or configured; board hook scripts ``` Providers other than gentoo set up the toolchain store only when -BUILD_STEPS compiles target code (kernel/bootloader). +BUILD_STEPS compiles target code. `deps`, `checkout`, `assemble` and +`pack` do not; `kernel`, `bootloader` and every step a board invents do, +because a board invents a step to build what no built-in step builds. ### `SecondStage` @@ -396,7 +398,9 @@ sibling helpers. `BUILD_STEPS` may also name custom steps with no Rust default. A custom step must provide `override-{step}.sh` (it runs with the same `.{step}` marker and hook conventions); a custom step with no override hook is a hard -error, not a silent skip. +error, not a silent skip. It runs with the cross toolchain mounted, which +a board that compiles nothing there can avoid by making the work a +`pre-`/`post-{step}.sh` hook on a step it already runs. --- From 4197380c66949484f3192c408d6d147db60f5139 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 11:55:12 +0900 Subject: [PATCH 07/13] sandbox: refuse an overlay checkout that is not a portage repo --- crossdev-stages/src/sandbox.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crossdev-stages/src/sandbox.rs b/crossdev-stages/src/sandbox.rs index 469dbc48..2a41be69 100644 --- a/crossdev-stages/src/sandbox.rs +++ b/crossdev-stages/src/sandbox.rs @@ -863,6 +863,16 @@ fn install_overlay(runner: SandboxRunner, sandbox: &Utf8Path, repo: &str, tag: & )?; runner }; + // A clone or a copy can succeed and still not be a portage repository: + // a wrong OVERLAY_TAG, or an OVERLAY_REPO naming the wrong local + // directory. Portage takes such a tree as a repository with no packages + // and says nothing about it beyond a masters warning, so the overlay's + // ebuilds go missing and the failure is reported against the package. + // Refuse here, while the previous overlay is still in place. + runner.run(&format!( + "test -s {OVERLAY_STAGE}/profiles/repo_name || \ + {{ echo 'overlay checkout has no profiles/repo_name' >&2; exit 1; }}" + ))?; runner.run(&format!( "rm -rf {OVERLAY_DIR} && mv {OVERLAY_STAGE} {OVERLAY_DIR}" ))?; From 3095baec0a80dbebd6bfde0a725fa084db664695 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 12:01:18 +0900 Subject: [PATCH 08/13] source_cache: clone a pinned commit out of the cache, not the network --- crossdev-stages/src/source_cache.rs | 31 +++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crossdev-stages/src/source_cache.rs b/crossdev-stages/src/source_cache.rs index 084b58cf..50301650 100644 --- a/crossdev-stages/src/source_cache.rs +++ b/crossdev-stages/src/source_cache.rs @@ -12,8 +12,13 @@ use crate::error::Result; /// 2. Bare cache exists -> `git fetch` /// 3. Branch/tag: `git clone --reference cache --depth=1 --branch tag repo dest` /// Commit SHA (from a pinned build.lock.toml): `git clone --branch` cannot -/// resolve raw commits, so clone without it, fetch the commit explicitly -/// and check it out detached. +/// resolve raw commits, so the commit is put in the bare cache and the +/// checkout is cloned from the cache instead of from the network. +/// +/// `dest` is replaced, not reused. A checkout is derived state: the tree +/// left by a run that stopped after cloning and before its patches applied +/// is at some other tag, or half patched, and `git clone` into it only +/// fails with "already exists and is not an empty directory". pub fn cached_clone( runner: &SandboxRunner, repo: &str, @@ -33,14 +38,28 @@ pub fn cached_clone( ))?; if is_commit_sha(tag) { + // `--shared` borrows the cache's objects, so the pin costs no + // transfer and no default-branch checkout for the pinned commit to + // overwrite. The network is touched only on a cache miss, where + // `git fetch ` asks for an object no ref advertises: that needs + // protocol v2 (the client default since git 2.26) or + // uploadpack.allowReachableSHA1InWant -- git.kernel.org refuses it + // over v0. A fetched commit is unreferenced in a bare repo, so + // refs/pins/ is what keeps the cache's own gc off it. runner.run(&format!( - "git clone --reference {cache} {repo} {dest} && \ - git -C {dest} fetch origin {tag} && \ - git -C {dest} checkout --detach FETCH_HEAD" + "rm -rf {dest} && \ + if ! git -C {cache} cat-file -e {tag} 2>/dev/null; then \ + git -C {cache} fetch origin {tag} && \ + git -C {cache} update-ref refs/pins/{tag} {tag}; \ + fi && \ + git clone --shared --no-checkout {cache} {dest} && \ + git -C {dest} remote set-url origin {repo} && \ + git -C {dest} checkout --detach {tag}" )) } else { runner.run(&format!( - "git clone --reference {cache} --depth=1 --branch {tag} {repo} {dest}" + "rm -rf {dest} && \ + git clone --reference {cache} --depth=1 --branch {tag} {repo} {dest}" )) } } From c239954dc5d57dbc9ca9f9a6a2854ffa7c0b913d Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 12:01:24 +0900 Subject: [PATCH 09/13] image: a step marker records its inputs, so a changed input redoes the step --- crossdev-stages/src/cli/maint.rs | 6 +- crossdev-stages/src/image.rs | 408 +++++++++++++++++++++++++++++-- 2 files changed, 395 insertions(+), 19 deletions(-) diff --git a/crossdev-stages/src/cli/maint.rs b/crossdev-stages/src/cli/maint.rs index ec347d49..7ae07c2a 100644 --- a/crossdev-stages/src/cli/maint.rs +++ b/crossdev-stages/src/cli/maint.rs @@ -236,9 +236,11 @@ fn logs(ws: &Workspace, board_name: &str, step: Option<&str>) -> Result<()> { ] { let marker = build.dir.join(format!(".{s}")); if marker.exists() { - let ts = std::fs::read_to_string(&marker).unwrap_or_default(); + // First line is the timestamp; the rest is the input digest. + let body = std::fs::read_to_string(&marker).unwrap_or_default(); + let ts = body.lines().next().unwrap_or_default(); let label = if step == Some(s) { " <--" } else { "" }; - println!(" {s}: {}{label}", ts.trim()); + println!(" {s}: {ts}{label}"); } } diff --git a/crossdev-stages/src/image.rs b/crossdev-stages/src/image.rs index 851f88c6..adfe0fe9 100644 --- a/crossdev-stages/src/image.rs +++ b/crossdev-stages/src/image.rs @@ -39,7 +39,7 @@ impl Build { if let Ok(builds) = ws.list_builds() { for dir in builds { if let Some(b) = Self::open(dir.clone()) { - if b.board == board && !b.is_done("packed") { + if b.board == board && !b.is_packed() { tracing::info!("Resuming build: {}", dir); return Ok(b); } @@ -77,14 +77,41 @@ impl Build { self.dir.join(format!(".{step}")) } - fn is_done(&self, step: &str) -> bool { - self.marker(step).exists() + /// Whether this leaf reached the end of the pipeline. Resume picks a + /// leaf, [`Build::is_done`] picks the steps inside it. + fn is_packed(&self) -> bool { + self.marker("packed").exists() + } + + /// A step is done only when its marker names the inputs it ran with and + /// they have not moved since. Existence alone cannot tell a resumed + /// build from a stale one: adding a patch or moving a TAG leaves the + /// marker in place, the step is skipped, and the image is built from + /// what was there before. + /// + /// A marker written before the digest was recorded carries none, so it + /// reads as stale. That costs one rebuild and never ships an image the + /// board.conf no longer describes. + fn is_done(&self, step: &str, inputs: &str) -> bool { + let Ok(body) = std::fs::read_to_string(self.marker(step)) else { + return false; + }; + body.lines() + .any(|line| line.strip_prefix("inputs ") == Some(inputs)) } - fn mark_done(&self, step: &str) -> Result<()> { - std::fs::write(self.marker(step), Utc::now().to_rfc3339())?; + fn mark_done(&self, step: &str, inputs: &str) -> Result<()> { + std::fs::write( + self.marker(step), + format!("{}\ninputs {inputs}\n", Utc::now().to_rfc3339()), + )?; Ok(()) } + + /// Drop a step's marker. True when there was one to drop. + fn clear_marker(&self, step: &str) -> bool { + std::fs::remove_file(self.marker(step)).is_ok() + } } /// Move a flat pre-nesting build (builds// containing .board) into @@ -120,14 +147,15 @@ fn migrate_legacy_build(ws: &Workspace, board: &str) -> Result<()> { fn run_step( step: &str, - marker: &str, + inputs: &str, build: &Build, runner: &SandboxRunner, boards_root: &Utf8Path, board: &BoardConfig, default_fn: impl FnOnce(&SandboxRunner) -> Result<()>, ) -> Result<()> { - if build.is_done(marker) { + let marker = marker_for(step); + if build.is_done(marker, inputs) { return Ok(()); } @@ -136,7 +164,7 @@ fn run_step( let override_sh = format!("override-{step}.sh"); if board_dir.join(&override_sh).exists() { runner.run(&run_board_script(board, &override_sh))?; - return build.mark_done(marker); + return build.mark_done(marker, inputs); } let pre_sh = format!("pre-{step}.sh"); @@ -151,7 +179,7 @@ fn run_step( runner.run(&run_board_script(board, &post_sh))?; } - build.mark_done(marker) + build.mark_done(marker, inputs) } fn run_board_script(board: &BoardConfig, script: &str) -> String { @@ -172,6 +200,191 @@ fn run_board_script(board: &BoardConfig, script: &str) -> String { ) } +// -- What each step reads --------------------------------------------------- +// +// A marker that only records "this ran" is a cache with no key. Each step +// therefore records a digest of its own inputs, and is redone when they +// move. A step missing from this map is a step that can be skipped after +// its inputs change, so anything a step grows has to be added here. + +/// Marker file name for a build step. Three markers were named before the +/// convention settled and keep the names already on disk. +fn marker_for(step: &str) -> &str { + match step { + "checkout" => "sources", + "assemble" => "assembled", + "pack" => "packed", + other => other, + } +} + +fn digest_of(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().iter().fold(String::new(), |mut acc, byte| { + use std::fmt::Write; + let _ = write!(acc, "{byte:02x}"); + acc + }) +} + +/// Digest of a file's contents, or `absent` -- a file appearing and a file +/// changing are the same event to a step that reads it. +fn file_digest(path: &Utf8Path) -> String { + match std::fs::read(path) { + Ok(bytes) => digest_of(&bytes), + Err(_) => "absent".to_string(), + } +} + +/// Every file under `dir`, depth first in name order, as `label/rel digest`. +/// A file added, removed or edited all move the result. +fn tree_lines(label: &str, dir: &Utf8Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + out.push(format!("{label} absent")); + return; + }; + let mut paths: Vec = entries + .filter_map(|e| Utf8PathBuf::from_path_buf(e.ok()?.path()).ok()) + .collect(); + paths.sort(); + for path in paths { + let name = path.file_name().unwrap_or_default().to_string(); + let label = format!("{label}/{name}"); + if path.is_dir() { + tree_lines(&label, &path, out); + } else { + out.push(format!("{label} {}", file_digest(&path))); + } + } +} + +/// A config file's settings, with comments and blank lines dropped: editing +/// a comment in board.conf must not cost a rebuild, editing a value must. +fn config_settings(path: &Utf8Path) -> String { + let Ok(body) = std::fs::read_to_string(path) else { + return String::new(); + }; + body.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .fold(String::new(), |mut acc, line| { + acc.push_str(line); + acc.push('\n'); + acc + }) +} + +/// board.conf and the include files it names, in the order the loader and +/// the hook scripts read them. Every step sees these, because every step +/// is either a Rust default reading the parsed config or a hook sourcing +/// the same files. +fn board_config_digest(board: &BoardConfig, boards_root: &Utf8Path) -> String { + let mut text = String::new(); + for name in &board.includes { + text.push_str(&config_settings( + &boards_root.join("include").join(format!("{name}.conf")), + )); + } + text.push_str(&config_settings( + &boards_root.join(&board.name).join("board.conf"), + )); + digest_of(text.as_bytes()) +} + +/// Digest of everything `step` reads that a build can change between runs. +/// +/// Not included, deliberately: what an earlier step produced (a step that +/// is redone invalidates every step after it, which covers that), and the +/// sandbox and crossdev prefix (portage keys its own caches; a stamp that +/// triggers rebuilds on a gcc bump is a cache key with worse ergonomics -- +/// the ABI and ISA checks at the end of assemble read the artifact). +fn step_inputs( + step: &str, + board: &BoardConfig, + boards_root: &Utf8Path, + defaults_root: &Utf8Path, +) -> String { + let board_dir = boards_root.join(&board.name); + let mut lines = vec![ + format!("step {step}"), + format!("board.conf {}", board_config_digest(board, boards_root)), + ]; + + // The hooks are the step, whenever a board writes one. + for hook in [ + format!("override-{step}.sh"), + format!("pre-{step}.sh"), + format!("post-{step}.sh"), + ] { + lines.push(format!("{hook} {}", file_digest(&board_dir.join(&hook)))); + } + + match step { + "deps" => { + for name in ["sandbox-packages.txt", "target-packages.txt", "overlay.conf"] { + lines.push(format!( + "defaults/{name} {}", + file_digest(&defaults_root.join(name)) + )); + } + let provider_list = format!("{}-packages.txt", board.rootfs_provider.name()); + for name in [ + "sandbox-packages.txt", + "sandbox-packages.use", + "target-packages.txt", + "target-packages.use", + "package.provided", + provider_list.as_str(), + ] { + lines.push(format!("{name} {}", file_digest(&board_dir.join(name)))); + } + tree_lines( + "portage-patches", + &board_dir.join("portage-patches"), + &mut lines, + ); + } + // The one that bit: a patch added under patches// changes + // what checkout produces and nothing else says so. + "checkout" => tree_lines("patches", &board_dir.join("patches"), &mut lines), + "kernel" => { + for name in &board.kernel_config_fragments { + // Board first, then defaults -- the order the shell uses. + let in_board = board_dir.join("kernel-config").join(name); + let path = if in_board.is_file() { + in_board + } else { + defaults_root.join("kernel-config").join(name) + }; + lines.push(format!("kernel-config/{name} {}", file_digest(&path))); + } + } + "assemble" => { + lines.push(format!( + "make.conf {}", + file_digest(&board_dir.join("make.conf")) + )); + tree_lines("defaults/scripts", &defaults_root.join("scripts"), &mut lines); + } + "pack" => { + let in_board = board_dir.join("genimage.cfg"); + let path = if in_board.is_file() { + in_board + } else { + project_root(boards_root).join("genimage.cfg") + }; + lines.push(format!("genimage.cfg {}", file_digest(&path))); + } + // bootloader, and any custom step, read the board config and their + // own hooks and nothing else on the host. + _ => {} + } + + digest_of(lines.join("\n").as_bytes()) +} + /// Identifiers for the partition table, derived from the board rather than /// written out by hand or drawn at random. /// @@ -2065,10 +2278,38 @@ pub fn build( // entirely (it may not exist); their runners are plain sandbox runners. let needs_toolchain = provider.needs_cross_toolchain(&steps_to_run); + // Resume is decided here, before anything runs, because a step that has + // to be redone invalidates every step after it: they were built out of + // what it produced. Clearing those markers up front is what stops a + // fresh `checkout` from being assembled behind a stale `kernel`. + let inputs: Vec = steps_to_run + .iter() + .map(|step| step_inputs(step, board, boards_root, defaults_root)) + .collect(); + if let Some(first) = steps_to_run + .iter() + .zip(&inputs) + .position(|(step, digest)| !bld.is_done(marker_for(step), digest)) + { + let cleared: Vec<&str> = steps_to_run[first..] + .iter() + .copied() + .filter(|step| bld.clear_marker(marker_for(step))) + .collect(); + if !cleared.is_empty() { + tracing::info!( + "Inputs changed at step '{}': redoing {}", + steps_to_run[first], + cleared.join(" ") + ); + } + } + let total = steps_to_run.len(); let build_start = std::time::Instant::now(); for (i, step) in steps_to_run.iter().enumerate() { + let d = inputs[i].as_str(); let step_start = std::time::Instant::now(); println!("==> [{}/{}] {}...", i + 1, total, step); @@ -2088,7 +2329,7 @@ pub fn build( .with_binpkgs(&binpkgs_dir); let result = match *step { - "deps" => run_step("deps", "deps", &bld, &runner, boards_root, board, |_r| { + "deps" => run_step("deps", d, &bld, &runner, boards_root, board, |_r| { // The overlay is a precondition of these providers alone // (apk-tools, dnf5), so it is installed here and not in // prepare(): every other command, a plain kernel build @@ -2130,7 +2371,7 @@ pub fn build( }), "checkout" => run_step( "checkout", - "sources", + d, &bld, &runner, boards_root, @@ -2139,19 +2380,19 @@ pub fn build( ), "bootloader" => run_step( "bootloader", - "bootloader", + d, &bld, &runner, boards_root, board, |r| default_bootloader(r, board), ), - "kernel" => run_step("kernel", "kernel", &bld, &runner, boards_root, board, |r| { + "kernel" => run_step("kernel", d, &bld, &runner, boards_root, board, |r| { default_kernel(r, board) }), "assemble" => run_step( "assemble", - "assembled", + d, &bld, &runner, boards_root, @@ -2162,13 +2403,13 @@ pub fn build( default_assemble(r, board, &bld, ws, kernel_built) }, ), - "pack" => run_step("pack", "packed", &bld, &runner, boards_root, board, |r| { + "pack" => run_step("pack", d, &bld, &runner, boards_root, board, |r| { default_pack(r, board, &bld, boards_root) }), // Custom step: no Rust default. run_step still honours an // override-.sh hook (with resume-marker support); if the // hook is missing, the default_fn below turns it into a hard error. - other => run_step(other, other, &bld, &runner, boards_root, board, |_r| { + other => run_step(other, d, &bld, &runner, boards_root, board, |_r| { let hook = format!("boards/{}/override-{}.sh", board.name, other); Err(crate::error::Error::BoardConfigParse { file: hook.clone(), @@ -2383,8 +2624,9 @@ fn format_duration(d: std::time::Duration) -> String { #[cfg(test)] mod tests { use super::{atom_cpn, buildroot_build_script, extlinux_conf, extlinux_fdt, - kernel_config_fragments}; + kernel_config_fragments, marker_for, step_inputs, Build}; use crate::board::BoardConfig; + use camino::Utf8PathBuf; fn board_with(fragments: &[&str]) -> BoardConfig { let mut board = crate::cli::util::default_board_config("riscv64"); @@ -2393,6 +2635,138 @@ mod tests { board } + + // -- Step markers ------------------------------------------------------ + // + // The defect these cover: `is_done` used to be `marker(step).exists()`, + // so a step whose inputs had moved was skipped and the image was built + // from what was there before. Observed twice: a patch added under + // patches/linux/ and a KERNEL_TAG moved to another commit both produced + // an exit-0 image from the old tree. + + /// A directory under target/ that no other test shares. Not /tmp: this + /// project's own workspace lives on disk for the same reason. + fn scratch(name: &str) -> Utf8PathBuf { + let dir = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-scratch") + .join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn demo_board() -> BoardConfig { + let mut board = crate::cli::util::default_board_config("aarch64"); + board.name = "demo".into(); + board + } + + #[test] + fn a_marker_that_names_other_inputs_is_not_done() { + let dir = scratch("marker-inputs"); + let build = Build { + dir: dir.clone(), + board: "demo".into(), + }; + build.mark_done("sources", "aaa").unwrap(); + + // What the old implementation looked at, and why it was wrong. + assert!(dir.join(".sources").exists()); + + assert!(build.is_done("sources", "aaa")); + assert!(!build.is_done("sources", "bbb")); + } + + #[test] + fn a_marker_from_before_the_digest_reads_as_stale() { + let dir = scratch("marker-legacy"); + let build = Build { + dir: dir.clone(), + board: "demo".into(), + }; + // Exactly what mark_done wrote before: a bare RFC3339 timestamp. + std::fs::write(dir.join(".sources"), "2026-08-25T22:50:10.389533200+00:00").unwrap(); + assert!(!build.is_done("sources", "aaa")); + } + + #[test] + fn markers_keep_the_names_already_on_disk() { + assert_eq!(marker_for("checkout"), "sources"); + assert_eq!(marker_for("assemble"), "assembled"); + assert_eq!(marker_for("pack"), "packed"); + assert_eq!(marker_for("kernel"), "kernel"); + assert_eq!(marker_for("flash-blobs"), "flash-blobs"); + } + + #[test] + fn adding_a_patch_moves_the_checkout_digest() { + let root = scratch("checkout-digest"); + let boards = root.join("boards"); + let defaults = root.join("defaults"); + let patches = boards.join("demo/patches/linux"); + std::fs::create_dir_all(&patches).unwrap(); + std::fs::create_dir_all(&defaults).unwrap(); + std::fs::write(boards.join("demo/board.conf"), "KERNEL_TAG=\"v7.2\"\n").unwrap(); + std::fs::write(patches.join("0001-a.patch"), "one\n").unwrap(); + let board = demo_board(); + + let before = step_inputs("checkout", &board, &boards, &defaults); + std::fs::write(patches.join("0002-b.patch"), "two\n").unwrap(); + let after = step_inputs("checkout", &board, &boards, &defaults); + assert_ne!(before, after, "a new patch has to invalidate checkout"); + + // Editing one in place counts too, and so does taking one away. + std::fs::write(patches.join("0002-b.patch"), "two, edited\n").unwrap(); + let edited = step_inputs("checkout", &board, &boards, &defaults); + assert_ne!(after, edited); + std::fs::remove_file(patches.join("0002-b.patch")).unwrap(); + assert_eq!(before, step_inputs("checkout", &board, &boards, &defaults)); + } + + #[test] + fn a_comment_costs_no_rebuild_but_a_value_does() { + let root = scratch("board-conf-digest"); + let boards = root.join("boards"); + let defaults = root.join("defaults"); + std::fs::create_dir_all(boards.join("demo")).unwrap(); + std::fs::create_dir_all(&defaults).unwrap(); + let conf = boards.join("demo/board.conf"); + std::fs::write(&conf, "KERNEL_TAG=\"v7.2\"\n").unwrap(); + let board = demo_board(); + + let before = step_inputs("kernel", &board, &boards, &defaults); + std::fs::write(&conf, "# mainline\nKERNEL_TAG=\"v7.2\"\n\n").unwrap(); + assert_eq!(before, step_inputs("kernel", &board, &boards, &defaults)); + + std::fs::write(&conf, "KERNEL_TAG=\"4e69c185\"\n").unwrap(); + assert_ne!(before, step_inputs("kernel", &board, &boards, &defaults)); + } + + #[test] + fn a_board_hook_is_part_of_its_step() { + let root = scratch("hook-digest"); + let boards = root.join("boards"); + let defaults = root.join("defaults"); + std::fs::create_dir_all(boards.join("demo")).unwrap(); + std::fs::create_dir_all(&defaults).unwrap(); + std::fs::write(boards.join("demo/board.conf"), "KERNEL_ARCH=\"arm64\"\n").unwrap(); + let board = demo_board(); + + let before = step_inputs("kernel", &board, &boards, &defaults); + let hook = boards.join("demo/override-kernel.sh"); + std::fs::write(&hook, "make\n").unwrap(); + let with_hook = step_inputs("kernel", &board, &boards, &defaults); + assert_ne!(before, with_hook); + std::fs::write(&hook, "make -j4\n").unwrap(); + assert_ne!(with_hook, step_inputs("kernel", &board, &boards, &defaults)); + // Another step's hook is not this step's input. + std::fs::write(boards.join("demo/post-pack.sh"), "true\n").unwrap(); + assert_eq!( + step_inputs("kernel", &board, &boards, &defaults), + step_inputs("kernel", &board, &boards, &defaults) + ); + } + #[test] fn no_fragments_generates_nothing() { assert!(kernel_config_fragments(&board_with(&[])).is_empty()); From d27cd96a663a3aaf22487047247f91a51b9b5232 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 12:02:06 +0900 Subject: [PATCH 10/13] source_cache: say which protocol makes a sha fetch work --- crossdev-stages/src/source_cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crossdev-stages/src/source_cache.rs b/crossdev-stages/src/source_cache.rs index 50301650..1fe48d76 100644 --- a/crossdev-stages/src/source_cache.rs +++ b/crossdev-stages/src/source_cache.rs @@ -42,7 +42,7 @@ pub fn cached_clone( // transfer and no default-branch checkout for the pinned commit to // overwrite. The network is touched only on a cache miss, where // `git fetch ` asks for an object no ref advertises: that needs - // protocol v2 (the client default since git 2.26) or + // protocol v2 (git's default, `protocol.version`) or the server's // uploadpack.allowReachableSHA1InWant -- git.kernel.org refuses it // over v0. A fetched commit is unreferenced in a bare repo, so // refs/pins/ is what keeps the cache's own gc off it. From ef4f8d5a6b70a75b00cc1bfb1ee48b1d56414bf3 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 12:55:11 +0900 Subject: [PATCH 11/13] boards/pentium-mmx: root by PARTUUID, the hook reads BOOT_ROOT_DEV --- boards/pentium-mmx/board.conf | 10 +++++++++- boards/pentium-mmx/genimage.cfg | 6 ++++++ boards/pentium-mmx/post-assemble.sh | 8 ++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/boards/pentium-mmx/board.conf b/boards/pentium-mmx/board.conf index bf069a89..95977719 100644 --- a/boards/pentium-mmx/board.conf +++ b/boards/pentium-mmx/board.conf @@ -20,7 +20,15 @@ BOOT_HOSTNAME="gentoo-pentium-mmx" BOOT_CONSOLE="ttyS0,115200" BOOT_SERIAL_TTY="ttyS0" BOOT_SERIAL_BAUD="115200" -BOOT_ROOT_DEV="/dev/sda2" +# rootfs is partition 2, named by the MBR PARTUUID rather than /dev/sda2. +# The kernel has no LABEL= case at all (block/early-lookup.c takes only +# PARTUUID=, PARTLABEL=, /dev/ and a raw device number) and there is no +# initramfs here to resolve one. PARTUUID over /dev/sda2 because the +# disk is sda only while it is master on the first ATA channel. +# BOOT_DISK_ID is exported before this file is sourced, genimage.cfg +# stamps the matching disk-signature, and the kernel spells the pair +# "{sig}-02" itself (block/partitions/msdos.c). +BOOT_ROOT_DEV="PARTUUID=${BOOT_DISK_ID}-02" BOOT_KERNEL_NAME="bzImage" BOOT_SERVICES=("sshd:default" "dhcpcd:default") diff --git a/boards/pentium-mmx/genimage.cfg b/boards/pentium-mmx/genimage.cfg index 2515c781..0108583b 100644 --- a/boards/pentium-mmx/genimage.cfg +++ b/boards/pentium-mmx/genimage.cfg @@ -28,6 +28,12 @@ image bootfs.vfat { image gentoo-linux-pentium-mmx_dev-sdcard.img { hdimage { partition-table-type = "mbr" + /* Derived from the board and exported by the pack step; genimage + * writes 0 when unset, which every other unsigned disk also claims. + * BOOT_ROOT_DEV names the PARTUUID built from this signature, so + * the value is load-bearing, not cosmetic. The exec-post below + * writes only boot.img's first 440 bytes, which leaves it intact. */ + disk-signature = "${BOOT_DISK_SIG}" } partition bootfs { diff --git a/boards/pentium-mmx/post-assemble.sh b/boards/pentium-mmx/post-assemble.sh index 24bc45c5..68c82f74 100644 --- a/boards/pentium-mmx/post-assemble.sh +++ b/boards/pentium-mmx/post-assemble.sh @@ -15,13 +15,17 @@ mkdir -p /build/gen/boot/grub/i386-pc cp "$GRUB_MODS_SRC"/*.mod /build/gen/boot/grub/i386-pc/ # Write GRUB configuration. -# Use label-based root so the config survives device renames. +# root= is whatever board.conf declared, expanded here: it spells a +# PARTUUID built from BOOT_DISK_ID, which the pack step exports before +# this script is sourced and genimage.cfg stamps into the MBR. Restating +# a device path here is what left BOOT_ROOT_DEV unread and the kernel +# holding a root=LABEL= it has no code to resolve. cat > /build/gen/boot/grub/grub.cfg << EXTEOF set timeout=3 set default=0 menuentry "Gentoo Linux (${kver})" { search --no-floppy --label --set=root bootfs - linux /${BOOT_KERNEL_NAME} root=LABEL=rootfs rw rootfstype=ext4 console=${BOOT_CONSOLE} + linux /${BOOT_KERNEL_NAME} root=${BOOT_ROOT_DEV} rw rootwait rootfstype=ext4 console=${BOOT_CONSOLE} } EXTEOF From 0c513ff16deb84f337c8dc8ad90d6c1cf3968231 Mon Sep 17 00:00:00 2001 From: Sungjoon Moon Date: Wed, 26 Aug 2026 13:00:24 +0900 Subject: [PATCH 12/13] boards: add dell-dimension-4100 (Pentium III, i815E, BIOS GRUB) --- README.md | 1 + boards/dell-dimension-4100/README.md | 122 ++++++++++++++++++ boards/dell-dimension-4100/board.conf | 84 ++++++++++++ boards/dell-dimension-4100/genimage.cfg | 78 +++++++++++ .../kernel-config/dimension-4100 | 29 +++++ .../kernel-config/pentium3 | 12 ++ boards/dell-dimension-4100/post-assemble.sh | 50 +++++++ .../dell-dimension-4100/sandbox-packages.txt | 4 + .../dell-dimension-4100/sandbox-packages.use | 3 + .../dell-dimension-4100/target-packages.txt | 10 ++ 10 files changed, 393 insertions(+) create mode 100644 boards/dell-dimension-4100/README.md create mode 100644 boards/dell-dimension-4100/board.conf create mode 100644 boards/dell-dimension-4100/genimage.cfg create mode 100644 boards/dell-dimension-4100/kernel-config/dimension-4100 create mode 100644 boards/dell-dimension-4100/kernel-config/pentium3 create mode 100755 boards/dell-dimension-4100/post-assemble.sh create mode 100644 boards/dell-dimension-4100/sandbox-packages.txt create mode 100644 boards/dell-dimension-4100/sandbox-packages.use create mode 100644 boards/dell-dimension-4100/target-packages.txt diff --git a/README.md b/README.md index b68b6973..29f4e424 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Rootless cross-compilation of Gentoo stages using crossdev and hakoniwa | odroid-xu4 | armv7a | mainline v7.2 | signed BL1/BL2/TZSW + U-Boot | `-O2 -march=armv7ve -mtune=cortex-a15.cortex-a7 -mfpu=neon-vfpv4` | testing | | orangepi-5-ultra | aarch64 | mainline v7.2 | TFA + U-Boot + rkbin | `-O3 -mcpu=cortex-a76.cortex-a55+crc+crypto` | testing | | pentium-mmx | i586 | mainline v6.18 | BIOS (no firmware) | `-O2 -march=pentium-mmx` | testing | +| dell-dimension-4100 | i686 | mainline v7.2 | BIOS (no firmware) | `-O2 -march=pentium3` | testing | | premier-p550 | riscv64 | mainline v7.2-rc1 | vendor QSPI (OpenSBI + U-Boot) + extlinux | `-O3 -march=rv64gc_zba_zbb` | testing | | vsrves01 | riscv32 | mainline v6.18 + patches | VSDSP6/VSOS DDRLoad (no U-Boot/OpenSBI) | `-Os -march=rv32ima_zicsr_zifencei` | experimental (hardware dead) | | ch32v467 | riscv32 | mainline v7.1 + patches (buildroot) | none: MCU firmware is the machine (tiny-rv32ima) | `-Os -march=rv32ima_zicsr_zifencei` | experimental (never run on hardware) | diff --git a/boards/dell-dimension-4100/README.md b/boards/dell-dimension-4100/README.md new file mode 100644 index 00000000..4d5c5b49 --- /dev/null +++ b/boards/dell-dimension-4100/README.md @@ -0,0 +1,122 @@ +# Dell Dimension 4100 (Pentium III Coppermine, i815E) + +Retail Socket 370 desktop, 2000 vintage. Pentium III Coppermine at +866/933/1000 MHz (MMX and SSE, no SSE2), Intel 815E chipset (FW82815 GMCH +plus FW82801BA ICH2), up to 512 MB PC133, one serial port, two USB 1.1 +ports, Ultra ATA disk, Phoenix BIOS (newest A11). Dell's OEM build of +Intel's D815EEA; the UART is on an SMSC LPC47M102 Super I/O. + +Nothing here has been booted on the hardware. Every claim below comes +from the kernel tree, the chipset datasheets and Dell's archived tech +specs, not from a serial log. + +## Boot chain + +No firmware stage of any kind: no U-Boot, no OpenSBI, no vendor blob. + +``` +BIOS (Phoenix A11, on-board flash) + -> MBR boot code = GRUB boot.img, patched at byte 92 with the LBA of core.img + -> core.img in the gap at LBA 1, prefix (hd0,msdos1)/grub + -> /grub/grub.cfg on the FAT32 boot partition + -> /bzImage +``` + +`grub-mkimage` runs in the sandbox; the i386-pc modules come from the +crossdev prefix, built by the same portage package version as the host +tool, so the module ABI matches. `genimage.cfg` writes only the first 440 +bytes of `boot.img` so the disk signature and partition table survive. + +## Disk layout + +MBR, 512-byte sectors. + +| Offset | Size | Contents | +|---|---|---| +| 0 | 440 B | GRUB `boot.img` | +| 440 | 4 B | disk signature, half of the root `PARTUUID` | +| 512 | up to 1 MiB | GRUB `core.img` | +| 1 MiB | 128 MiB | boot partition, FAT32, type 0x0c, bootable | +| 129 MiB | rest | root partition, ext4, type 0x83 | + +Everything is inside the first 8.4 GB, so it is reachable whether the BIOS +answers the INT 13h LBA extension check or falls back to CHS. + +Root is named `PARTUUID=${BOOT_DISK_ID}-02`, not `/dev/sda2`: the disk is +`sda` only while it is master on the primary IDE channel, and this machine +has two channels and a CD-ROM. `root=LABEL=` cannot be used at all, with +or without a device path: `early_lookup_bdev()` in `block/early-lookup.c` +takes only `PARTUUID=`, `PARTLABEL=`, `/dev/` and a raw device number, and +`PARTLABEL` is GPT-only. There is no initramfs to resolve anything else. + +## Kernel + +Mainline v7.2, `i386_defconfig` plus two fragments. + +`kernel-config/pentium3` sets `CONFIG_MPENTIUMIII=y`. The defconfig names +no processor family, so the `arch/x86/Kconfig.cpu` choice defaults to +`M686`; MPENTIUMIII adds `-mtune=pentium3` and turns on +`X86_INTEL_USERCOPY`. + +`kernel-config/dimension-4100` adds `CONFIG_SND_INTEL8X0=y` (8086:2445, +the ICH2 AC97 controller behind Dell's "SoundMAX 2.0") and +`CONFIG_VORTEX=y` (the 3C905C-TXM Dell listed as an option), and turns +`CONFIG_DRM_I915` off. + +The defconfig already has ATA_PIIX, BLK_DEV_SD, E100, SERIAL_8250 with +its console, USB_UHCI_HCD, and EXT4_FS and VFAT_FS built in, which is what +lets the kernel mount root with no initramfs. + +## Compiler flags + +`-O2 -march=pentium3 -pipe`, arch `i686`. + +Coppermine has MMX and SSE and no SSE2. `-march=pentium3` is exactly that +ISA; every `-march` above it turns SSE2 on and an SSE2 instruction faults +on this CPU. `-mfpmath` is left at its 387 default on purpose, because +SSE1 has no double precision. `i686` rather than `i586` so that Gentoo's +`default/linux/x86/23.0/i686` profile, whose `CPU_FLAGS_X86="mmx sse"`, +tells ebuilds the same thing the compiler is emitting. + +## Write the image + +```sh +xzcat gentoo-linux-dell-dimension-4100_dev-disk-.img.xz \ + | sudo dd of=/dev/ bs=4M status=progress conv=fsync +``` + +Write it to the PATA disk in a USB enclosure or on another machine's IDE +channel, then put it back as master on the primary channel. The root +partition grows to the end of the disk on first boot (`grow-rootfs`). + +## Console + +Serial is COM1, `ttyS0` at 115200 8N1, and it is the last `console=` on +the command line, so it owns `/dev/console`. `console=tty0` comes first, +so the monitor sees the boot messages too. + +## Known not to work + +- **Integrated video is unaccelerated VGA text.** Nothing in this tree + binds the 815's GMCH: i915's `pciidlist` starts at `INTEL_I830_IDS`, + `INTEL_I815_IDS` (0x1132) is defined in `include/drm/intel/pciids.h` and + referenced nowhere under `drivers/`, and the legacy `drm/i810` driver is + gone. `CONFIG_FB_I810` still exists but does not build as shipped: + `i810_accel.c` calls `cfb_fillrect`, `cfb_copyarea` and `cfb_imageblit` + while its Kconfig entry selects `FB_IOMEM_FOPS`, which pulls in none of + them, instead of `FB_IOMEM_HELPERS`, which selects all three. That looks + like an upstream Kconfig bug. +- **Which NIC is fitted is unknown.** Both drivers are built in, the + integrated Intel 10/100 (`e100`) and the optional 3C905C-TXM + (`3c59x`). One of them is dead weight on any given unit. +- **ACPI 1.0 and an SMP kernel with LOCAL_APIC/IO_APIC.** Both are common + sources of hangs on 2000-era firmware. The second GRUB entry boots with + `acpi=off noapic nolapic`; if the machine needs it every time, that + belongs in the first entry. +- **The RTC battery is flat.** `ntpd` is enabled rather than + `ntp-client`, which would run before dhcpcd has a lease and give up. + +## Default credentials + +- root, empty password (development image, change on first login) +- sshd enabled, `PermitRootLogin yes`, `PermitEmptyPasswords yes` diff --git a/boards/dell-dimension-4100/board.conf b/boards/dell-dimension-4100/board.conf new file mode 100644 index 00000000..3bc9ff00 --- /dev/null +++ b/boards/dell-dimension-4100/board.conf @@ -0,0 +1,84 @@ +TAGS=("x86" "i686" "pentium3" "i815" "dell" "testing") +DESCRIPTION="Dell Dimension 4100 (Pentium III Coppermine, i815E + ICH2, BIOS/GRUB)" + +# Retail desktop, Socket 370. Dell's archived tech specs give the +# configuration: Pentium III at 866/933/1000 MHz, 32 KB L1 and 256 KB +# on-die L2, Intel 815E chipset, 64 MB upgradeable to 512 MB PC133 SDRAM, +# one serial port, two USB ports, Ultra ATA EIDE disk, ACPI 1.0, Phoenix +# BIOS (newest A11). The board is Dell's OEM build of Intel's D815EEA: +# FW82815 GMCH plus FW82801BA ICH2, with an SMSC LPC47M102 Super I/O +# carrying the UART. + +BOARD_ARCH="i686" +CROSS_COMPILE="i686-pc-linux-gnu-" +KERNEL_ARCH="x86" + +# Coppermine is MMX and SSE and no SSE2, which is exactly what gcc means +# by -march=pentium3: it defines __MMX__, __SSE__ and __FXSR__ and leaves +# __SSE2__ undefined. Every -march above it (pentium-m, pentium4) turns +# SSE2 on, and an SSE2 instruction faults on this CPU. +# +# -mfpmath is deliberately unset: the default is 387 and it has to stay +# 387, because SSE1 has no double precision. +# +# -O2, not the -O3 default_cflags() falls back to for an arch it has no +# entry for. -O2 is also what Gentoo's own arch/x86/i686 profile ships, +# so this is the profile's flags with its -march sharpened to the real +# CPU. i686 rather than i586 for the same reason: gentoo_profile("i686") +# selects default/linux/x86/23.0/i686, whose CPU_FLAGS_X86="mmx sse" +# matches what the compiler is actually emitting. +BOARD_CFLAGS="-O2 -march=pentium3 -pipe" + +# The pin the other mainline boards carry. The store key is the gcc PVR +# plus the CFLAGS hash, so leaving it unset moves the cache on every gcc +# bump. +BOARD_GCC_VERSION="16.1.0" + +# BIOS boot, no firmware stage of any kind: boot.img in the MBR, core.img +# in the gap before the first partition, grub.cfg on the FAT boot +# partition. GRUB_PLATFORMS is load-bearing -- bootloader/grub.rs returns +# early without it, and it is what puts the i386-pc modules in the +# crossdev prefix the post-assemble hook copies from. +GRUB_PLATFORMS="pc" +# The default pipeline would no-op its way to the same place; naming the +# one real stage says so out loud. +BOOT_PIPELINE=("grub") + +KERNEL_REPO="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git" +KERNEL_TAG="v7.2" +KERNEL_DEFCONFIG="i386_defconfig" +# Applied in this order after the defconfig, and every line is asserted +# afterwards. pentium3 is the CPU model, dimension-4100 is this machine. +KERNEL_CONFIG_FRAGMENTS="pentium3 dimension-4100" + +BOOT_HOSTNAME="gentoo-dimension-4100" + +# rootfs is partition 2, named by the MBR PARTUUID the pack step stamps +# in. BOOT_DISK_ID is exported before this file is sourced, genimage.cfg +# writes the matching disk-signature, and the kernel spells the pair +# "{sig}-02" itself (block/partitions/msdos.c), so no initramfs is +# needed. root=LABEL= is not an option at all: early_lookup_bdev() +# (block/early-lookup.c) takes only PARTUUID=, PARTLABEL=, /dev/ and a +# raw device number, and PARTLABEL is GPT-only. A /dev/ path would be +# wrong for a different reason: the disk is sda only while it is master +# on the primary IDE channel, and this machine has two and a CD-ROM. +BOOT_ROOT_DEV="PARTUUID=${BOOT_DISK_ID}-02" + +# The one serial port, COM1 on the LPC47M102 Super I/O. post-assemble.sh +# puts console=tty0 in front of this, so boot messages also reach the +# monitor this machine has; the last console= owns /dev/console. +BOOT_CONSOLE="ttyS0,115200n8" +BOOT_SERIAL_TTY="ttyS0" +BOOT_SERIAL_BAUD="115200" + +BOOT_KERNEL_NAME="bzImage" + +# ntpd, not ntp-client: the CR2032 in a machine this old is flat, so the +# RTC is wrong at every boot. ntp-client is a one-shot that runs before +# dhcpcd has a lease and gives up; ntpd keeps trying and its default -g +# steps the first offset however large. net-misc/ntp and app-admin/metalog +# are already in defaults/target-packages.txt. +BOOT_SERVICES=("sshd:default" "dhcpcd:default" "metalog:default" "ntpd:default") + +# There is no SD card in this machine; it boots off the PATA disk. +IMAGE_NAME="gentoo-linux-dell-dimension-4100_dev-disk.img" diff --git a/boards/dell-dimension-4100/genimage.cfg b/boards/dell-dimension-4100/genimage.cfg new file mode 100644 index 00000000..571684cd --- /dev/null +++ b/boards/dell-dimension-4100/genimage.cfg @@ -0,0 +1,78 @@ +config { + outputpath = . + inputpath = . + rootpath = gen + tmppath = tmp +} + +image rootfs.ext4 { + ext4 { + use-mke2fs = "true" + label = "rootfs" + } + + size = 4G + mountpoint = "/root" +} + +/* FAT32, not ext4. Nothing in this machine's firmware reads a filesystem + * (the BIOS reads sectors, GRUB reads the rest), so the format is free, and + * FAT is the one both grub's fat.mod and a DOS rescue floppy can open. */ +image bootfs.vfat { + vfat { + extraargs = "-F 32" + label = "bootfs" + } + + size = 128M + mountpoint = "/boot" +} + +image gentoo-linux-dell-dimension-4100_dev-disk.img { + hdimage { + partition-table-type = "mbr" + /* Derived from the board and exported by the pack step; genimage + * writes 0 when unset, which every other unsigned disk also claims. + * BOOT_ROOT_DEV names the PARTUUID built from this signature, so the + * value is load-bearing here, not cosmetic. */ + disk-signature = "${BOOT_DISK_SIG}" + } + + /* 1 MiB in, so LBA 1..2047 stays free for core.img (tens of KiB). Well + * inside the first 8.4 GB, so it is reachable whether the A11 BIOS + * answers the INT 13h LBA extension check or falls back to CHS. */ + partition bootfs { + image = "bootfs.vfat" + offset = "1M" + size = "128M" + partition-type = 0x0c + bootable = true + in-partition-table = "true" + } + + /* Last partition; grow-rootfs extends it to the end of the real disk on + * first boot with sfdisk ",+". */ + partition rootfs { + image = "rootfs.ext4" + offset = "129M" + size = "" + partition-type = 0x83 + in-partition-table = "true" + } + + /* GRUB i386-pc: boot.img into the MBR boot code, core.img into the gap. + * grub-boot.img and grub-core.img are staged by the bootloader step. + * + * boot.img carries the LBA of core.img at offset 92 (kernel_sector); + * grub-install patches it and nothing else does, so it is patched here. + * + * Only the first 440 bytes of boot.img are written. Bytes 440..511 are + * the disk signature, the partition table and the 0x55AA genimage has + * already laid down, and writing 512 bytes here would destroy the + * signature root= depends on. */ + exec-post = 'cp /build/grub-boot.img /build/boot-patched.img && \ + printf "\001\000\000\000\000\000\000\000" | \ + dd of=/build/boot-patched.img bs=1 seek=92 count=8 conv=notrunc 2>/dev/null && \ + dd if=/build/boot-patched.img of=$IMAGEOUTFILE bs=440 count=1 conv=notrunc 2>/dev/null && \ + dd if=/build/grub-core.img of=$IMAGEOUTFILE bs=512 seek=1 conv=notrunc 2>/dev/null' +} diff --git a/boards/dell-dimension-4100/kernel-config/dimension-4100 b/boards/dell-dimension-4100/kernel-config/dimension-4100 new file mode 100644 index 00000000..05f9a5c7 --- /dev/null +++ b/boards/dell-dimension-4100/kernel-config/dimension-4100 @@ -0,0 +1,29 @@ +# Dell Dimension 4100: the peripherals i386_defconfig does not carry. +# +# What it already carries was read out of the defconfig, not assumed: +# ATA_PIIX=y (its PCI table has 8086:244B "Intel ICH2 UDMA 100"), +# BLK_DEV_SD=y, E100=y (8086:2449 is the ICH2 integrated LAN), +# SERIAL_8250=y with SERIAL_8250_CONSOLE=y, USB_UHCI_HCD=y, and EXT4_FS=y +# and VFAT_FS=y both built in, which is what lets the kernel mount root +# with no initramfs. VGA_CONSOLE is default y on x86 and not +# user-selectable without EXPERT, so the text console needs no line here. + +# 82801BA ICH2 AC97 controller, 8086:2445 in sound/pci/intel8x0.c, which +# names it "Intel 82801BA-ICH2". Dell's solution guide calls the +# integrated audio "ADI SoundMAX 2.0", an AC-link codec snd-ac97-codec +# drives. Not SND_HDA_INTEL, which is what the defconfig carries and +# which this chipset predates by years. +CONFIG_SND_INTEL8X0=y + +# 3Com 3C905C-TXM, which Dell's tech specs list as an option alongside the +# integrated Intel 10/100 that CONFIG_E100 already covers. Only opening +# the case says which is fitted, so both are built in. +CONFIG_VORTEX=y + +# The 815's integrated video has no driver in this tree that can bind it. +# i915's pciidlist starts at INTEL_I830_IDS; INTEL_I815_IDS (0x1132) is +# defined in include/drm/intel/pciids.h and referenced by nothing under +# drivers/, and the legacy drm/i810 driver is gone. Dropping it saves +# megabytes of always-resident text on a 512 MB machine and loses nothing: +# the console is VGA text mode either way. +# CONFIG_DRM_I915 is not set diff --git a/boards/dell-dimension-4100/kernel-config/pentium3 b/boards/dell-dimension-4100/kernel-config/pentium3 new file mode 100644 index 00000000..67cb66fd --- /dev/null +++ b/boards/dell-dimension-4100/kernel-config/pentium3 @@ -0,0 +1,12 @@ +# Dell Dimension 4100: the CPU model. +# +# i386_defconfig names no processor family, so the choice in +# arch/x86/Kconfig.cpu takes its own default and lands on M686. +# MPENTIUMIII is the Coppermine entry, and two things follow from it that +# M686 does not give: Makefile_32.cpu adds -mtune=pentium3 on top of the +# same -march=i686, and X86_INTEL_USERCOPY turns on (its depends list in +# Kconfig.cpu names MPENTIUMIII and not M686). +# +# X86_L1_CACHE_SHIFT and X86_USE_PPRO_CHECKSUM come out the same either +# way, so those are not reasons. +CONFIG_MPENTIUMIII=y diff --git a/boards/dell-dimension-4100/post-assemble.sh b/boards/dell-dimension-4100/post-assemble.sh new file mode 100755 index 00000000..a2708286 --- /dev/null +++ b/boards/dell-dimension-4100/post-assemble.sh @@ -0,0 +1,50 @@ +set -e + +kver=$(ls /build/gen/root/lib/modules/ | head -1) +[ -z "$kver" ] && { echo 'Error: no kernel modules found'; exit 1; } + +# Derive chost from CROSS_COMPILE (strip trailing dash). +CHOST="${CROSS_COMPILE%-}" + +# Prefer the crossdev-prefix modules: same portage package version as the +# sandbox grub-mkimage that built core.img, so the module ABI matches. +GRUB_MODS_SRC="/usr/${CHOST}/usr/lib/grub/i386-pc" +[ -d "$GRUB_MODS_SRC" ] || GRUB_MODS_SRC="/usr/lib/grub/i386-pc" +[ -d "$GRUB_MODS_SRC" ] || { echo "Error: GRUB i386-pc modules not found in $GRUB_MODS_SRC"; exit 1; } + +# core.img was built with -p '(hd0,msdos1)/grub', so this is /grub on the +# FAT partition genimage mounts at /boot. +mkdir -p /build/gen/boot/grub/i386-pc +cp "$GRUB_MODS_SRC"/*.mod /build/gen/boot/grub/i386-pc/ + +# No `search --label`: core.img's baked-in prefix has already set $root to +# (hd0,msdos1), and mkfs.vfat upper-cases the volume label, so searching for +# "bootfs" is one more thing that can be quietly wrong. +# +# root= is whatever board.conf declared, expanded here. console=tty0 first +# and serial last: this is a desktop with a monitor, and the last console= +# is the one that owns /dev/console. +cat > /build/gen/boot/grub/grub.cfg << EXTEOF +set timeout=3 +set default=0 + +menuentry "Gentoo Linux (${kver})" { + linux /${BOOT_KERNEL_NAME} root=${BOOT_ROOT_DEV} rw rootwait rootfstype=ext4 console=tty0 console=${BOOT_CONSOLE} +} + +# Dell documents ACPI 1.0 on this machine and i386_defconfig builds an SMP +# kernel with LOCAL_APIC and IO_APIC. If either upsets the A11 BIOS this +# entry is the way in without rewriting the image. +menuentry "Gentoo Linux (${kver}) - no ACPI, no APIC" { + linux /${BOOT_KERNEL_NAME} root=${BOOT_ROOT_DEV} rw rootwait rootfstype=ext4 console=tty0 console=${BOOT_CONSOLE} acpi=off noapic nolapic +} +EXTEOF + +# The stage3 fstab is comments only, so /boot never mounts and a kernel +# update would land in the rootfs copy instead of the partition GRUB reads. +# Same PARTUUID scheme as root=, for the same reason: it does not depend on +# the disk landing as master on the primary IDE channel. +cat >> /build/gen/root/etc/fstab < Date: Wed, 26 Aug 2026 13:20:20 +0900 Subject: [PATCH 13/13] board: refuse a key a board.conf assigns twice, and fix the five that did --- boards/ky-x1/board.conf | 1 - boards/odroid-m1/board.conf | 1 - boards/odroid-m1s/board.conf | 1 - boards/odroid-m2/board.conf | 1 - boards/pentium-mmx/board.conf | 1 - crossdev-stages/src/board.rs | 72 +++++++++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/boards/ky-x1/board.conf b/boards/ky-x1/board.conf index 8b3a9834..d6bfefdb 100644 --- a/boards/ky-x1/board.conf +++ b/boards/ky-x1/board.conf @@ -21,5 +21,4 @@ BOOT_KERNEL_NAME="Image" # Boot environment BOOT_ROOT_DEV="/dev/mmcblk0p2" -BOOT_KERNEL_NAME="Image" BOOT_SERIAL_TTY="ttyS0" diff --git a/boards/odroid-m1/board.conf b/boards/odroid-m1/board.conf index 0077ae41..da99b7ee 100644 --- a/boards/odroid-m1/board.conf +++ b/boards/odroid-m1/board.conf @@ -1,4 +1,3 @@ -TAGS=("testing") INCLUDE="rk35xx" BOARD_NAME="odroid-m1" diff --git a/boards/odroid-m1s/board.conf b/boards/odroid-m1s/board.conf index 74a9c8de..deaf7ae4 100644 --- a/boards/odroid-m1s/board.conf +++ b/boards/odroid-m1s/board.conf @@ -1,4 +1,3 @@ -TAGS=("testing") INCLUDE="rk35xx" BOARD_NAME="odroid-m1s" diff --git a/boards/odroid-m2/board.conf b/boards/odroid-m2/board.conf index 0c4933bd..702b3ff4 100644 --- a/boards/odroid-m2/board.conf +++ b/boards/odroid-m2/board.conf @@ -1,4 +1,3 @@ -TAGS=("testing") INCLUDE="rk35xx" BOARD_NAME="odroid-m2" diff --git a/boards/pentium-mmx/board.conf b/boards/pentium-mmx/board.conf index 95977719..21593a94 100644 --- a/boards/pentium-mmx/board.conf +++ b/boards/pentium-mmx/board.conf @@ -33,4 +33,3 @@ BOOT_KERNEL_NAME="bzImage" BOOT_SERVICES=("sshd:default" "dhcpcd:default") -TAGS=("testing") diff --git a/crossdev-stages/src/board.rs b/crossdev-stages/src/board.rs index 1af48ac3..cff7afa4 100644 --- a/crossdev-stages/src/board.rs +++ b/crossdev-stages/src/board.rs @@ -217,11 +217,51 @@ pub fn load(boards_root: &Utf8Path, name: &str) -> Result { merged.push('\n'); } merged.push_str(&content); + + // Last-wins is the point ACROSS files: an include states a default and the + // board overrides it. Inside ONE file it is a mistake -- the second + // assignment erases the first with nothing to show for it. That is how + // boards/pentium-mmx came to declare its arch tags on line 2 and lose them + // to a bare TAGS=("testing") thirty lines later, so `board list` reported + // one tag where the file named three. + duplicate_keys(&content, &path)?; + let board = parse(name, &path, &merged)?; check_cflags(&board, &path)?; Ok(board) } +/// Reject a key assigned twice in the same file. Mirrors the parser's own +/// idea of what an assignment is, so it cannot warn about a line the parser +/// ignores or stay quiet about one it reads. +fn duplicate_keys(content: &str, path: &Utf8Path) -> Result<()> { + let mut seen = std::collections::HashSet::new(); + let mut dups: Vec<&str> = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, _)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if !seen.insert(key) && !dups.contains(&key) { + dups.push(key); + } + } + if dups.is_empty() { + return Ok(()); + } + Err(Error::BoardConfigParse { + file: path.to_string(), + msg: format!( + "assigned twice, so the first value is silently dropped: {}", + dups.join(", ") + ), + }) +} + /// The `INCLUDE` list a board declares, read before parsing because it decides /// what the parse input is. Space separated, in priority order. fn includes_of(content: &str) -> Vec { @@ -552,3 +592,35 @@ fn parse_array(inner: &str) -> Vec { } result } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_assigned_twice_in_one_file_is_refused() { + let err = duplicate_keys( + "TAGS=(\"x86\" \"i586\")\nBOARD_ARCH=\"i586\"\nTAGS=(\"testing\")\n", + Utf8Path::new("board.conf"), + ) + .unwrap_err(); + assert!(err.to_string().contains("TAGS"), "{err}"); + } + + #[test] + fn comments_and_blanks_are_not_assignments() { + duplicate_keys( + "# TAGS=(\"a\")\n\nTAGS=(\"b\")\n# TAGS=(\"c\")\n", + Utf8Path::new("board.conf"), + ) + .unwrap(); + } + + #[test] + fn the_same_key_in_two_files_is_what_include_is_for() { + // load() checks each file on its own, so an include stating a default + // and a board overriding it never reach this function together. + duplicate_keys("BOARD_CFLAGS=\"-O2\"\n", Utf8Path::new("include.conf")).unwrap(); + duplicate_keys("BOARD_CFLAGS=\"-O3\"\n", Utf8Path::new("board.conf")).unwrap(); + } +}