diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 903813c..b70693d 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1198,6 +1198,14 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ ) })?; + // Device-tree overlays declared by this runtime's extensions (ENG-2134). + // Empty section - and no build-time work - unless at least one is declared. + let device_tree_overlay_section = { + let overlays = + crate::utils::device_tree_overlay::collect_for_runtime(&merged_runtime, parsed)?; + crate::utils::device_tree_overlay::render_build_section(&overlays)? + }; + // Extract extension names from the `extensions` array let mut required_extensions = HashSet::new(); let _extension_type_overrides: HashMap> = HashMap::new(); @@ -2411,6 +2419,8 @@ if [ -n "${{AVOCADO_STONE_INCLUDE_PATHS:-}}" ]; then fi STONE_INCLUDE_FLAGS="$STONE_INCLUDE_FLAGS -i $STONE_INPUT_DIR" +STONE_OVERLAY_FLAG="" +{device_tree_overlay_section} echo -e "\033[94m[INFO]\033[0m Running stone bundle." echo -e " Manifest: $STONE_MANIFEST" echo -e " Output: $STONE_AOS_OUTPUT" @@ -2427,6 +2437,7 @@ stone bundle \ $STONE_INITRD_FLAG \ -m "$STONE_MANIFEST" \ $STONE_INCLUDE_FLAGS \ + $STONE_OVERLAY_FLAG \ --partition-size "var=$FINAL_SIZE" \ -o "$STONE_AOS_OUTPUT" \ --build-dir "$STONE_BUILD_DIR" @@ -2528,6 +2539,7 @@ sign_amf "$AVOCADO_MANIFEST_PATH" manifest_section = manifest_section, update_authority_section = update_authority_section, docker_section = docker_section, + device_tree_overlay_section = device_tree_overlay_section, ); Ok(script) @@ -3041,6 +3053,9 @@ runtimes: assert!(script.contains("VAR_DIR=$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/var-staging")); assert!(script.contains("stone bundle")); assert!(script.contains("mkfs.btrfs")); + // No overlays declared: the device-tree overlay section is inert. + assert!(!script.contains("device-tree overlays (ENG-2134)")); + assert!(script.contains("STONE_OVERLAY_FLAG=\"\"")); } #[test] @@ -3088,6 +3103,61 @@ extensions: assert!(!script.contains("$VAR_DIR/lib/avocado/extensions/")); } + #[test] + fn test_create_build_script_with_device_tree_overlays() { + let temp_dir = TempDir::new().unwrap(); + let config_content = r#" +sdk: + image: "test-image" + +connect: + org: test + +runtimes: + test-runtime: + target: "x86_64" + extensions: + - test-ext + +extensions: + test-ext: + version: "1.0.0" + types: + - sysext + device_tree_overlays: + - name: my-spi + src: overlays/my-spi.dtso +"#; + let config_path = create_test_config_file(&temp_dir, config_content); + let parsed: serde_yaml::Value = serde_yaml::from_str(config_content).unwrap(); + let cmd = RuntimeBuildCommand::new( + "test-runtime".to_string(), + config_path, + false, + Some("x86_64".to_string()), + None, + None, + ); + + let config = Config::load(&cmd.config_path).unwrap(); + let resolved_extensions = vec!["test-ext-1.0.0".to_string()]; + let script = cmd + .create_build_script(&config, &parsed, "x86_64", &resolved_extensions) + .unwrap(); + + // The overlay section is emitted, builds the declared overlay via the + // SDK wrapper, and wires the delivery hook + stone --overlay before the + // bundle call. + assert!(script.contains("device-tree overlays (ENG-2134)")); + assert!(script.contains( + "avocado-dtc-overlay --name \"my-spi\" --src \"/opt/src/overlays/my-spi.dtso\"" + )); + assert!(script.contains("device-tree-overlay-deliver")); + assert!(script.contains("STONE_OVERLAY_FLAG=\"--overlay $DTO_FRAGMENT\"")); + // And the bundle call consumes the flag. + assert!(script.contains("$STONE_OVERLAY_FLAG \\")); + } + #[test] fn test_create_build_script_with_extension_types() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/commands/sdk/install.rs b/src/commands/sdk/install.rs index f90c982..ee57545 100644 --- a/src/commands/sdk/install.rs +++ b/src/commands/sdk/install.rs @@ -344,7 +344,17 @@ impl SdkInstallCommand { // the parallel sysroot installs so all four can run concurrently. let active_compile_sections = find_active_compile_sections(&composed.merged_value, active_extensions); - let need_target_dev = config.has_compile_sections() && !active_compile_sections.is_empty(); + // ENG-2134: an extension declaring device-tree overlays needs the delivery + // hook in the target-sysroot, so provision the target-sysroot even when + // there is no compile section (a no-#include overlay needs no kernel-devsrc). + let provision_dto = + !crate::utils::device_tree_overlay::active_extensions_declaring_overlays( + &composed.merged_value, + active_extensions, + ) + .is_empty(); + let need_target_dev = + (config.has_compile_sections() && !active_compile_sections.is_empty()) || provision_dto; // Prepare target-dev install command if needed (CPU-only prep, no container calls) let target_dev_command = if need_target_dev { @@ -389,6 +399,21 @@ impl SdkInstallCommand { target_sysroot_config_version, ); + // ENG-2134: the per-BSP delivery hook lands in the target-sysroot so the + // runtime build can invoke it. A BSP that ships no hook package fails the + // install here, earlier and clearer than the build-time backstop. + let dto_hook_pkg = if provision_dto { + build_package_spec_with_lock( + lock_file, + target, + &SysrootType::TargetSysroot, + crate::utils::device_tree_overlay::DELIVERY_HOOK_PACKAGE, + "*", + ) + } else { + String::new() + }; + let command = format!( r#" unset RPM_CONFIGDIR @@ -396,13 +421,14 @@ RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ $DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ --disablerepo=${{AVOCADO_TARGET}}-target-ext \ {} {} {} --installroot ${{AVOCADO_PREFIX}}/sdk/target-sysroot \ - install {} {} + install {} {} {} "#, dnf_args_str, exclude_str, yes, target_sysroot_pkg, - all_compile_packages.join(" ") + all_compile_packages.join(" "), + dto_hook_pkg ); Some(( @@ -439,6 +465,7 @@ $DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ config, &bootstrap.sdk_dependencies, &bootstrap.extension_sdk_dependencies, + provision_dto, &mut sdk_pkg_lock, &bootstrap.all_sdk_package_names, &bootstrap.sdk_sysroot, @@ -1673,6 +1700,7 @@ fi config: &Config, sdk_dependencies: &Option>, extension_sdk_dependencies: &HashMap>, + provision_dto: bool, lock_file: &mut LockFile, bootstrap_package_names: &[String], sdk_sysroot: &SysrootType, @@ -1712,6 +1740,22 @@ fi sdk_package_names.extend(self.extract_package_names(ext_deps)); } + // ENG-2134: provision the SDK overlay build wrapper when any active + // extension declares device-tree overlays. Its RDEPENDS pulls + // nativesdk-dtc, and stone is a baseline SDK package, so neither is + // provisioned separately. + if provision_dto { + sdk_packages.push(build_package_spec_with_lock( + lock_file, + target, + sdk_sysroot, + crate::utils::device_tree_overlay::SDK_OVERLAY_PACKAGE, + "*", + )); + sdk_package_names + .push(crate::utils::device_tree_overlay::SDK_OVERLAY_PACKAGE.to_string()); + } + // Track all SDK package names for version query (bootstrap pkgs + new deps) let mut all_sdk_package_names: Vec = bootstrap_package_names.to_vec(); diff --git a/src/utils/device_tree_overlay.rs b/src/utils/device_tree_overlay.rs new file mode 100644 index 0000000..e22c9ec --- /dev/null +++ b/src/utils/device_tree_overlay.rs @@ -0,0 +1,574 @@ +//! Collection and validation of device-tree overlay declarations. +//! +//! An extension declares the device-tree overlays it ships under +//! `extensions..device_tree_overlays`, each entry an object: +//! +//! ```yaml +//! ext: +//! my-board: +//! device_tree_overlays: +//! - name: my-spi +//! src: overlays/my-spi.dtso +//! params: +//! speed: "12000000" +//! ``` +//! +//! `name` is authoritative: it is the stone `out` basename +//! (`overlays/.dtbo`), the RPi `config.txt` `dtoverlay=` argument, +//! and the u-boot overlay entry. It must therefore be a safe basename. This is +//! deliberately NOT the pre-existing filesystem `overlay` key (see +//! `overlay_preprocess`), which copies files into an image; a device-tree +//! overlay is compiled and delivered to the boot medium. + +use anyhow::{anyhow, bail, Result}; +use serde_yaml::Value; +use std::collections::BTreeMap; + +/// One device-tree overlay declared by an extension enabled on a runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceTreeOverlay { + /// Authoritative basename used for the .dtbo, the config.txt argument, and + /// the u-boot entry. + pub name: String, + /// Source `.dtso`/`.dts`, relative to the project root (bind-mounted at + /// `/opt/src` in the SDK container). + pub src: String, + /// Optional per-overlay parameters (e.g. `dtoverlay=name,key=value`). + pub params: BTreeMap, + /// The extension that declared this overlay. + pub ext_name: String, +} + +/// Collect the device-tree overlays declared by every extension enabled on the +/// runtime, in declaration order. `merged_runtime` is the resolved runtime +/// value (its `extensions` sequence lists the enabled extensions); `parsed` is +/// the whole config (it holds `extensions..device_tree_overlays`). +/// +/// Names must be unique across the whole runtime: two overlays sharing a name +/// would collide on the same `overlays/.dtbo` output and boot-selection +/// argument, so a duplicate is a hard error rather than a silent +/// last-one-wins. +pub fn collect_for_runtime( + merged_runtime: &Value, + parsed: &Value, +) -> Result> { + let mut out: Vec = Vec::new(); + let mut origin: BTreeMap = BTreeMap::new(); + + let Some(ext_list) = merged_runtime + .get("extensions") + .and_then(|e| e.as_sequence()) + else { + return Ok(out); + }; + + for ext_val in ext_list { + let Some(spec) = + crate::utils::runtime_extension::RuntimeExtensionSpec::parse_entry(ext_val) + else { + continue; + }; + let ext_name = spec.name.as_str(); + + let Some(decls) = parsed + .get("extensions") + .and_then(|e| e.get(ext_name)) + .and_then(|e| e.get("device_tree_overlays")) + else { + continue; + }; + + let seq = decls.as_sequence().ok_or_else(|| { + anyhow!("extension '{ext_name}': device_tree_overlays must be a list") + })?; + + for (idx, entry) in seq.iter().enumerate() { + let overlay = parse_entry(entry, ext_name, idx)?; + if let Some(prev) = origin.insert(overlay.name.clone(), ext_name.to_string()) { + bail!( + "device-tree overlay name '{}' is declared by both '{}' and '{}'; \ + names must be unique across the runtime", + overlay.name, + prev, + ext_name + ); + } + out.push(overlay); + } + } + + Ok(out) +} + +/// Names of the active extensions that declare at least one device-tree overlay. +/// +/// Unlike [`collect_for_runtime`], which scopes to a single runtime's enabled +/// extensions, this scans the whole active-extension set: the SDK install spans +/// every runtime, so the overlay build wrapper and delivery hook must be +/// provisioned whenever any active extension declares overlays. Returns the +/// declaring extension names sorted; an empty result means the feature is inert. +pub fn active_extensions_declaring_overlays( + parsed: &Value, + active_extensions: &std::collections::HashSet, +) -> Vec { + let mut out: Vec = active_extensions + .iter() + .filter(|ext| { + parsed + .get("extensions") + .and_then(|e| e.get(ext.as_str())) + .and_then(|e| e.get("device_tree_overlays")) + .and_then(|d| d.as_sequence()) + .is_some_and(|s| !s.is_empty()) + }) + .cloned() + .collect(); + out.sort(); + out +} + +fn parse_entry(entry: &Value, ext_name: &str, idx: usize) -> Result { + let name = entry + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow!("extension '{ext_name}': device_tree_overlays[{idx}] missing 'name'") + })? + .to_string(); + validate_name(&name, ext_name)?; + + let src = entry + .get("src") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + anyhow!( + "extension '{ext_name}': device-tree overlay '{name}' missing a non-empty 'src'" + ) + })? + .to_string(); + + let params = parse_params(entry.get("params"), ext_name, &name)?; + + Ok(DeviceTreeOverlay { + name, + src, + params, + ext_name: ext_name.to_string(), + }) +} + +/// A name is used verbatim as a filename and a boot-loader argument, so it must +/// be a safe basename: no path separators, no whitespace, not a directory dot. +fn validate_name(name: &str, ext_name: &str) -> Result<()> { + if name.is_empty() { + bail!("extension '{ext_name}': device-tree overlay name must not be empty"); + } + if name == "." || name == ".." || name.contains('/') || name.contains(char::is_whitespace) { + bail!( + "extension '{ext_name}': device-tree overlay name '{name}' is not a valid basename \ + (no '/', whitespace, '.' or '..')" + ); + } + Ok(()) +} + +fn parse_params( + value: Option<&Value>, + ext_name: &str, + name: &str, +) -> Result> { + let mut params = BTreeMap::new(); + let Some(value) = value else { + return Ok(params); + }; + let mapping = value.as_mapping().ok_or_else(|| { + anyhow!("extension '{ext_name}': device-tree overlay '{name}' params must be a mapping") + })?; + for (k, v) in mapping { + let key = k.as_str().ok_or_else(|| { + anyhow!( + "extension '{ext_name}': device-tree overlay '{name}' has a non-string param key" + ) + })?; + let val = scalar_to_string(v).ok_or_else(|| { + anyhow!( + "extension '{ext_name}': device-tree overlay '{name}' param '{key}' must be a scalar" + ) + })?; + params.insert(key.to_string(), val); + } + Ok(params) +} + +fn scalar_to_string(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Bool(b) => Some(b.to_string()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// Fixed sysroot-relative path of the single per-BSP delivery hook. Each BSP +/// layer installs its own implementation here; because only one BSP's layers +/// are present in a given machine build, exactly one is ever installed. The CLI +/// runs this exact path - it never scans a directory. +pub const DELIVERY_HOOK_RELATIVE: &str = "usr/libexec/avocado/device-tree-overlay-deliver"; + +/// nativesdk package providing the `avocado-dtc-overlay` SDK build wrapper. +/// Its RDEPENDS pulls `nativesdk-dtc`, and `stone` is a baseline SDK package +/// (stone bundle runs on every runtime build), so neither is provisioned +/// separately. +pub const SDK_OVERLAY_PACKAGE: &str = "nativesdk-avocado-dtc-overlay"; + +/// Target package providing the per-BSP `device-tree-overlay-deliver` hook +/// (the executable at [`DELIVERY_HOOK_RELATIVE`]). Installed into the +/// target-sysroot so the runtime build can invoke it. +pub const DELIVERY_HOOK_PACKAGE: &str = "avocado-dtc-overlay-deliver"; + +// In-container guard: fail the build if the hook staged overlays but left any +// unclaimed (declared and compiled, yet not delivered to the boot medium). +const CLAIMED_CHECK_PY: &str = r#"python3 - "$DTBO_STAGING/overlays.manifest.json" <<'AVOCADO_DTO_CHECK_EOF' +import json, sys +data = json.load(open(sys.argv[1])) +if data.get("version") != 1: + sys.exit("[ERROR] overlays.manifest.json: unsupported version") +unclaimed = [o.get("name") for o in data.get("overlays", []) if not o.get("claimed_by")] +if unclaimed: + sys.exit("[ERROR] device-tree overlays staged but not delivered by the BSP hook: " + ", ".join(unclaimed)) +AVOCADO_DTO_CHECK_EOF +"#; + +/// Build the CLI-authored `overlays.manifest.json`. `claimed_by` starts null; +/// the BSP hook sets it per overlay it delivers, and the in-container check +/// then fails on any that stayed null. +fn build_manifest_json(overlays: &[DeviceTreeOverlay]) -> Result { + let arr: Vec = overlays + .iter() + .map(|o| { + serde_json::json!({ + "name": o.name, + "file": format!("{}.dtbo", o.name), + "params": o.params, + "claimed_by": serde_json::Value::Null, + }) + }) + .collect(); + let doc = serde_json::json!({ "version": 1, "overlays": arr }); + Ok(serde_json::to_string_pretty(&doc)?) +} + +/// Render the in-container shell block that builds, stages, delivers, and +/// validates the device-tree overlays, for injection into the runtime build +/// script immediately before `stone bundle`. Empty string when there are no +/// overlays, so the feature is entirely inert unless declared. +/// +/// The block assumes `$STONE_MANIFEST` and `$STONE_INCLUDE_FLAGS` are already +/// set (they are by the time the build reaches the stone step) and leaves +/// `$STONE_INCLUDE_FLAGS` / `$STONE_OVERLAY_FLAG` updated for the bundle call. +pub fn render_build_section(overlays: &[DeviceTreeOverlay]) -> Result { + if overlays.is_empty() { + return Ok(String::new()); + } + + let manifest_json = build_manifest_json(overlays)?; + let mut s = String::new(); + + s.push_str("# --- device-tree overlays (ENG-2134) ---\n"); + s.push_str( + "DTBO_STAGING=\"$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/device-tree-overlays\"\n", + ); + s.push_str("rm -rf \"$DTBO_STAGING\"\n"); + s.push_str("mkdir -p \"$DTBO_STAGING\"\n"); + s.push_str(&format!( + "echo -e \"\\033[94m[INFO]\\033[0m Building {} device-tree overlay(s).\"\n", + overlays.len() + )); + + // name is a validated basename; src is user config, so double-quote it. + for o in overlays { + s.push_str(&format!( + "avocado-dtc-overlay --name \"{}\" --src \"/opt/src/{}\" --out \"$DTBO_STAGING/{}.dtbo\"\n", + o.name, o.src, o.name + )); + } + + s.push_str("cat > \"$DTBO_STAGING/overlays.manifest.json\" <<'AVOCADO_DTO_MANIFEST_EOF'\n"); + s.push_str(&manifest_json); + s.push_str("\nAVOCADO_DTO_MANIFEST_EOF\n"); + + s.push_str(&format!( + "DTO_HOOK=\"$OECORE_TARGET_SYSROOT/{DELIVERY_HOOK_RELATIVE}\"\n" + )); + s.push_str("DTO_FRAGMENT=\"$DTBO_STAGING/delivery.overlay.json\"\n"); + // D1: overlays declared but no hook installed for this BSP is a hard error, + // not a silent skip - otherwise the build succeeds and ships nothing. + s.push_str("if [ ! -x \"$DTO_HOOK\" ]; then\n"); + s.push_str( + " echo \"[ERROR] device-tree overlays are declared but this target provides no delivery hook.\" >&2\n", + ); + s.push_str( + " echo \" Expected an executable at $DTO_HOOK (installed by the BSP layer).\" >&2\n", + ); + s.push_str(" exit 1\n"); + s.push_str("fi\n"); + s.push_str("AVOCADO_DTBO_STAGING=\"$DTBO_STAGING\" \\\n"); + s.push_str("AVOCADO_OVERLAYS_MANIFEST=\"$DTBO_STAGING/overlays.manifest.json\" \\\n"); + s.push_str("AVOCADO_DELIVERY_FRAGMENT=\"$DTO_FRAGMENT\" \\\n"); + s.push_str("AVOCADO_STONE_MANIFEST=\"$STONE_MANIFEST\" \\\n"); + s.push_str(" \"$DTO_HOOK\"\n"); + + s.push_str(CLAIMED_CHECK_PY); + + // D2 (2c): pass the hook-emitted fragment to stone via --overlay, and add + // the staging dir to the -i include path so `in: .dtbo` resolves. + s.push_str("STONE_INCLUDE_FLAGS=\"$STONE_INCLUDE_FLAGS -i $DTBO_STAGING\"\n"); + s.push_str("if [ -f \"$DTO_FRAGMENT\" ]; then\n"); + s.push_str(" STONE_OVERLAY_FLAG=\"--overlay $DTO_FRAGMENT\"\n"); + s.push_str("fi\n"); + s.push_str("# --- end device-tree overlays ---\n"); + + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(yaml: &str) -> Value { + serde_yaml::from_str(yaml).expect("valid test yaml") + } + + // merged_runtime with the given enabled extension names. + fn runtime_with(exts: &[&str]) -> Value { + let list: String = exts.iter().map(|e| format!(" - {e}\n")).collect(); + cfg(&format!("extensions:\n{list}")) + } + + #[test] + fn collects_overlays_in_declaration_order_with_params() { + let parsed = cfg(r#" +extensions: + board-a: + device_tree_overlays: + - name: spi-fast + src: overlays/spi-fast.dtso + params: + speed: "12000000" + cs: 0 + - name: uart-rts + src: overlays/uart-rts.dtso + board-b: + device_tree_overlays: + - name: gpio-leds + src: dts/leds.dtso +"#); + let rt = runtime_with(&["board-a", "board-b"]); + let got = collect_for_runtime(&rt, &parsed).unwrap(); + + assert_eq!( + got.iter().map(|o| o.name.as_str()).collect::>(), + vec!["spi-fast", "uart-rts", "gpio-leds"] + ); + assert_eq!(got[0].src, "overlays/spi-fast.dtso"); + assert_eq!(got[0].ext_name, "board-a"); + assert_eq!(got[0].params.get("speed").unwrap(), "12000000"); + assert_eq!(got[0].params.get("cs").unwrap(), "0"); + assert!(got[1].params.is_empty()); + assert_eq!(got[2].ext_name, "board-b"); + } + + #[test] + fn only_enabled_extensions_contribute() { + let parsed = cfg(r#" +extensions: + enabled: + device_tree_overlays: + - name: yes-overlay + src: a.dtso + disabled: + device_tree_overlays: + - name: no-overlay + src: b.dtso +"#); + let rt = runtime_with(&["enabled"]); + let got = collect_for_runtime(&rt, &parsed).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, "yes-overlay"); + } + + #[test] + fn no_extensions_or_no_declarations_is_empty() { + let parsed = cfg("extensions:\n board:\n packages:\n - vim\n"); + assert!(collect_for_runtime(&cfg("{}"), &parsed).unwrap().is_empty()); + assert!(collect_for_runtime(&runtime_with(&["board"]), &parsed) + .unwrap() + .is_empty()); + } + + #[test] + fn duplicate_name_across_extensions_is_error() { + let parsed = cfg(r#" +extensions: + a: + device_tree_overlays: + - name: clash + src: a.dtso + b: + device_tree_overlays: + - name: clash + src: b.dtso +"#); + let err = collect_for_runtime(&runtime_with(&["a", "b"]), &parsed).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("clash"), "message names the overlay: {msg}"); + assert!( + msg.contains('a') && msg.contains('b'), + "names both exts: {msg}" + ); + } + + #[test] + fn missing_name_or_src_is_error() { + let no_name = cfg("extensions:\n a:\n device_tree_overlays:\n - src: a.dtso\n"); + assert!(collect_for_runtime(&runtime_with(&["a"]), &no_name) + .unwrap_err() + .to_string() + .contains("missing 'name'")); + + let no_src = cfg("extensions:\n a:\n device_tree_overlays:\n - name: x\n"); + assert!(collect_for_runtime(&runtime_with(&["a"]), &no_src) + .unwrap_err() + .to_string() + .contains("'src'")); + } + + #[test] + fn unsafe_name_is_rejected() { + for bad in ["../escape", "sub/dir", "has space", ".", ".."] { + let parsed = cfg(&format!( + "extensions:\n a:\n device_tree_overlays:\n - name: \"{bad}\"\n src: a.dtso\n" + )); + assert!( + collect_for_runtime(&runtime_with(&["a"]), &parsed).is_err(), + "name {bad:?} should be rejected" + ); + } + } + + #[test] + fn declarations_must_be_a_list() { + let parsed = + cfg("extensions:\n a:\n device_tree_overlays:\n name: x\n src: a.dtso\n"); + assert!(collect_for_runtime(&runtime_with(&["a"]), &parsed) + .unwrap_err() + .to_string() + .contains("must be a list")); + } + + fn overlay(name: &str, src: &str) -> DeviceTreeOverlay { + DeviceTreeOverlay { + name: name.to_string(), + src: src.to_string(), + params: BTreeMap::new(), + ext_name: "board".to_string(), + } + } + + #[test] + fn render_is_empty_without_overlays() { + assert_eq!(render_build_section(&[]).unwrap(), ""); + } + + #[test] + fn render_emits_wrapper_hook_and_wiring() { + let overlays = vec![ + overlay("spi-fast", "overlays/spi-fast.dtso"), + overlay("uart-rts", "dts/uart.dtso"), + ]; + let sh = render_build_section(&overlays).unwrap(); + + assert!(sh.contains( + "avocado-dtc-overlay --name \"spi-fast\" --src \"/opt/src/overlays/spi-fast.dtso\" --out \"$DTBO_STAGING/spi-fast.dtbo\"" + )); + assert!( + sh.contains("avocado-dtc-overlay --name \"uart-rts\" --src \"/opt/src/dts/uart.dtso\"") + ); + // Single fixed-path hook, guarded, not a directory scan. + assert!(sh.contains(DELIVERY_HOOK_RELATIVE)); + assert!(sh.contains("if [ ! -x \"$DTO_HOOK\" ]; then")); + // Claim validation is present. + assert!(sh.contains("staged but not delivered by the BSP hook")); + // Stone wiring: fragment via --overlay and staging dir on the -i path. + assert!(sh.contains("STONE_OVERLAY_FLAG=\"--overlay $DTO_FRAGMENT\"")); + assert!(sh.contains("-i $DTBO_STAGING")); + } + + #[test] + fn manifest_json_starts_unclaimed_with_params() { + let mut params = BTreeMap::new(); + params.insert("speed".to_string(), "12000000".to_string()); + let overlays = vec![DeviceTreeOverlay { + name: "spi".to_string(), + src: "a.dtso".to_string(), + params, + ext_name: "board".to_string(), + }]; + let json = build_manifest_json(&overlays).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["version"], 1); + assert_eq!(v["overlays"][0]["name"], "spi"); + assert_eq!(v["overlays"][0]["file"], "spi.dtbo"); + assert_eq!(v["overlays"][0]["params"]["speed"], "12000000"); + assert!( + v["overlays"][0]["claimed_by"].is_null(), + "claimed_by must start null so the hook must set it" + ); + } + + #[test] + fn declaring_overlays_scans_active_set_not_per_runtime() { + let parsed = cfg(r#" +extensions: + board-a: + device_tree_overlays: + - name: spi + src: a.dtso + board-b: + packages: + - vim + board-c: + device_tree_overlays: + - name: gpio + src: c.dtso +"#); + let active: std::collections::HashSet = ["board-a", "board-b"] + .iter() + .map(|s| s.to_string()) + .collect(); + // board-a declares and is active -> included; board-b active but declares + // none -> excluded; board-c declares but is inactive -> excluded. + assert_eq!( + active_extensions_declaring_overlays(&parsed, &active), + vec!["board-a".to_string()] + ); + } + + #[test] + fn declaring_overlays_empty_when_none_active_declare() { + let parsed = cfg("extensions:\n a:\n packages:\n - vim\n"); + let active: std::collections::HashSet = + ["a"].iter().map(|s| s.to_string()).collect(); + assert!(active_extensions_declaring_overlays(&parsed, &active).is_empty()); + } + + #[test] + fn declaring_overlays_ignores_empty_declaration_list() { + let parsed = cfg("extensions:\n a:\n device_tree_overlays: []\n"); + let active: std::collections::HashSet = + ["a"].iter().map(|s| s.to_string()).collect(); + assert!(active_extensions_declaring_overlays(&parsed, &active).is_empty()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 221a8ed..b4768c3 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod config_edit; pub mod container; +pub mod device_tree_overlay; #[cfg(target_os = "macos")] pub mod disk_writer; pub mod ext_fetch; diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index 764ca77..26f38f6 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -1427,6 +1427,33 @@ pub fn compute_runtime_build_input_hash( docker_images.clone(), ); } + // Device-tree overlays are compiled and delivered at runtime-build + // time, so a declaration change (or an edit to a .dtso the + // declaration points at) must invalidate this stamp. Fold both the + // declaration value and each source's content hash, mirroring how + // the overlay/post_build keys track file contents. + if let Some(dtos) = parsed + .get("extensions") + .and_then(|e| e.get(ext_name)) + .and_then(|ext| ext.get("device_tree_overlays")) + { + hash_data.insert( + serde_yaml::Value::String(format!("ext.{ext_name}.device_tree_overlays")), + dtos.clone(), + ); + if let Some(seq) = dtos.as_sequence() { + for entry in seq { + if let Some(src) = entry.get("src").and_then(|v| v.as_str()) { + hash_data.insert( + serde_yaml::Value::String(format!( + "ext.{ext_name}.dtso_content.{src}" + )), + script_hash_value(project_root, src), + ); + } + } + } + } } } } @@ -3164,6 +3191,93 @@ extensions: ); } + #[test] + fn test_runtime_input_hash_includes_device_tree_overlays() { + let runtime: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-runtime: "*" +extensions: + - board +"#, + ) + .unwrap(); + + let parsed_without: serde_yaml::Value = serde_yaml::from_str( + r#" +extensions: + board: + version: "1.0.0" +"#, + ) + .unwrap(); + + let parsed_with: serde_yaml::Value = serde_yaml::from_str( + r#" +extensions: + board: + version: "1.0.0" + device_tree_overlays: + - name: spi-fast + src: overlays/spi-fast.dtso +"#, + ) + .unwrap(); + + let hash_without = + compute_runtime_build_input_hash(&runtime, "dev", &parsed_without, Path::new(".")) + .unwrap(); + let hash_with = + compute_runtime_build_input_hash(&runtime, "dev", &parsed_with, Path::new(".")) + .unwrap(); + + assert_ne!( + hash_without.config_hash, hash_with.config_hash, + "declaring a device-tree overlay must change the runtime input hash" + ); + } + + #[test] + fn test_runtime_input_hash_tracks_dtso_content() { + let runtime: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-runtime: "*" +extensions: + - board +"#, + ) + .unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str( + r#" +extensions: + board: + version: "1.0.0" + device_tree_overlays: + - name: spi-fast + src: overlays/spi-fast.dtso +"#, + ) + .unwrap(); + + let tmp = tempfile::TempDir::new().unwrap(); + let dtso = tmp.path().join("overlays/spi-fast.dtso"); + std::fs::create_dir_all(dtso.parent().unwrap()).unwrap(); + + std::fs::write(&dtso, "/dts-v1/;\n/plugin/;\n/ { /* v1 */ };\n").unwrap(); + let hash_v1 = + compute_runtime_build_input_hash(&runtime, "dev", &parsed, tmp.path()).unwrap(); + + std::fs::write(&dtso, "/dts-v1/;\n/plugin/;\n/ { /* v2 edited */ };\n").unwrap(); + let hash_v2 = + compute_runtime_build_input_hash(&runtime, "dev", &parsed, tmp.path()).unwrap(); + + assert_ne!( + hash_v1.config_hash, hash_v2.config_hash, + "editing a .dtso's contents must change the runtime input hash" + ); + } + #[test] fn test_runtime_input_hash_includes_var_files() { let runtime_without: serde_yaml::Value = serde_yaml::from_str(