From e338804dcb0a0cbb5c5477240c876a4f1d74915a Mon Sep 17 00:00:00 2001 From: nicksinas Date: Fri, 16 Oct 2026 17:01:56 -0500 Subject: [PATCH 1/6] runtime build: write TUF staging under src_dir and fail on a lost copy (ENG-2758) The staging files were written under the config file's directory, but the copy script reads them from /opt/src, which is the resolved src_dir. With src_dir set the copy failed, and both staging functions dropped the bool from run_container_command, so the build printed the success line and shipped a runtime with no tuf-staging/targets.json. project_root is now the staging root, and a failed copy returns an error, which the caller already reports as "Skipping TUF delegation staging". --- src/commands/runtime/build.rs | 30 ++++++++++++++++++++++++------ src/utils/config.rs | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 24ffc320..8c5db3df 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1152,9 +1152,11 @@ impl RuntimeBuildCommand { // modes, so it would otherwise vanish exactly where builds usually // run. TUI queues it above the task region; JSON mode writes it to // stderr without corrupting the NDJSON stream. - let project_dir = std::path::Path::new(&self.config_path) - .parent() - .unwrap_or(std::path::Path::new(".")); + // The staging files are handed to the container through `/opt/src`, + // which is the resolved `src_dir`, so they have to be written under + // that root and not under the config file's directory. The two differ + // whenever `src_dir` is set. + let project_dir = config.project_root(&self.config_path); if let Err(e) = self .generate_tuf_staging( config, @@ -1165,7 +1167,7 @@ impl RuntimeBuildCommand { repo_release, container_helper, runs_on_context, - project_dir, + &project_dir, ) .await { @@ -1430,11 +1432,19 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ env_vars: self.runtime_env_vars(), ..Default::default() }; - run_container_command(container_helper, copy_run_config, runs_on_context).await?; + let copied = + run_container_command(container_helper, copy_run_config, runs_on_context).await?; // Clean up host temp files. let _ = std::fs::remove_dir_all(&tmp_dir); + if !copied { + return Err(anyhow::anyhow!( + "failed to copy the delegation staging files into the build volume for runtime '{}'", + self.runtime_name + )); + } + print_info( &format!( "Generated deployment delegation staging for runtime '{}'.", @@ -1547,10 +1557,18 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ env_vars: self.runtime_env_vars(), ..Default::default() }; - run_container_command(container_helper, copy_run_config, runs_on_context).await?; + let copied = + run_container_command(container_helper, copy_run_config, runs_on_context).await?; let _ = std::fs::remove_dir_all(&tmp_dir); + if !copied { + return Err(anyhow::anyhow!( + "failed to copy the content delegation staging files into the build volume for runtime '{}'", + self.runtime_name + )); + } + print_info( &format!( "Generated content delegation staging for runtime '{}' (Level 1).", diff --git a/src/utils/config.rs b/src/utils/config.rs index 0baaf676..d322c45c 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -6895,6 +6895,27 @@ mod tests { ); } + /// With `src_dir` set, the project root is `src_dir` and not the config + /// file's directory. The runtime build writes its TUF staging files under + /// the root and the container reads them back through `/opt/src`, which + /// is `src_dir`; a root taken from the config path put the two out of step. + #[test] + fn project_root_follows_src_dir_above_the_config_dir() { + let dir = std::env::temp_dir().join(format!( + "avocado-project-root-src-dir-{}", + std::process::id() + )); + let config_dir = dir.join("project").join("avocado"); + std::fs::create_dir_all(&config_dir).unwrap(); + let config_path = config_dir.join("avocado.yaml"); + + let config = super::Config::load_from_yaml_str("src_dir: ..\nextensions: {}\n").unwrap(); + let root = config.project_root(&config_path); + assert_eq!(root, dir.join("project").canonicalize().unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + /// Recording the feed set must be a no-op when there is nothing to record. /// /// Two ways to get this wrong, and the first shipped: comparing the record's From 612aad354a5b1dc4217cbbf75512651bde420f6f Mon Sep 17 00:00:00 2001 From: nicksinas Date: Fri, 16 Oct 2026 17:01:57 -0500 Subject: [PATCH 2/6] config: read the volume state from the working directory (ENG-2759) get_or_create_volume writes .avocado-state in the process working directory. merge_installed_remote_extensions and get_extensions_dir read it from src_dir, so a src_dir: .. project run from its config directory found no volume, could not read its fetched extension configs, and install failed with "has not been merged". The stamps code already keys on cwd. VolumeState::load_from_current_dir is the one reader for that key. --- src/utils/config.rs | 10 ++++++---- src/utils/volume.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/utils/config.rs b/src/utils/config.rs index d322c45c..ce009b0e 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -2382,14 +2382,15 @@ impl Config { return Ok(RemoteExtensionMerge::default()); } - // Get src_dir for loading volume state + // `src_dir` is the base for `path:` sources. The volume state is keyed + // on the working directory, not on `src_dir`. let config_path_str = config_path.to_string_lossy(); let src_dir = temp_config .get_resolved_src_dir::<&str>(config_path_str.as_ref()) .unwrap_or_else(|| config_file_dir(config_path).to_path_buf()); // Try to load volume state for container-based config reading - let volume_state = crate::utils::volume::VolumeState::load_from_dir(&src_dir) + let volume_state = crate::utils::volume::VolumeState::load_from_current_dir() .ok() .flatten(); @@ -5505,8 +5506,9 @@ impl Config { .to_path_buf() }); - // Try to load volume state and get the mountpoint - if let Ok(Some(volume_state)) = crate::utils::volume::VolumeState::load_from_dir(&src_dir) { + // Try to load volume state and get the mountpoint. The state file is + // keyed on the working directory, not on `src_dir`. + if let Ok(Some(volume_state)) = crate::utils::volume::VolumeState::load_from_current_dir() { // Use synchronous Docker inspect to get the mountpoint if let Ok(mountpoint) = crate::utils::ext_source_reader::volume_mountpoint(&volume_state) diff --git a/src/utils/volume.rs b/src/utils/volume.rs index f781daad..244ad495 100644 --- a/src/utils/volume.rs +++ b/src/utils/volume.rs @@ -52,6 +52,17 @@ impl VolumeState { Ok(Some(state)) } + /// Load the volume state the container layer keys on. + /// + /// `get_or_create_volume` writes `.avocado-state` in the process working + /// directory (`SdkContainer::cwd`), not in `src_dir`. Readers that looked in + /// `src_dir` found nothing whenever the two differed, so every reader of + /// the state file goes through this one key. + pub fn load_from_current_dir() -> Result> { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + Self::load_from_dir(&cwd) + } + /// Save volume state to .avocado-state file in the given directory pub fn save_to_dir(&self, dir_path: &Path) -> Result<()> { let state_file = dir_path.join(".avocado-state"); @@ -629,4 +640,32 @@ mod tests { avo_abandonment_reason("avo-different-uuid", Some(&state.source_path)).unwrap(); assert!(reason.contains("not this volume")); } + + /// The state file lives in the working directory, which is where + /// `get_or_create_volume` writes it. A copy in `src_dir` is not consulted, + /// so a `src_dir: ..` project run from its config directory finds the + /// volume the container layer created there. + #[test] + #[serial_test::serial] + fn load_from_current_dir_reads_the_working_directory_only() { + let temp_dir = TempDir::new().unwrap(); + let src_dir = temp_dir.path().canonicalize().unwrap(); + let config_dir = src_dir.join("avocado"); + fs::create_dir_all(&config_dir).unwrap(); + VolumeState::new(src_dir.clone(), "docker".to_string()) + .save_to_dir(&src_dir) + .unwrap(); + + // Restore the cwd before asserting, so a failure here does not leave + // later tests running from a deleted directory. + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&config_dir).unwrap(); + let from_config_dir = VolumeState::load_from_current_dir(); + std::env::set_current_dir(&src_dir).unwrap(); + let from_src_dir = VolumeState::load_from_current_dir(); + std::env::set_current_dir(prev).unwrap(); + + assert!(from_config_dir.unwrap().is_none()); + assert!(from_src_dir.unwrap().is_some()); + } } From e4355d0b5817ef2ee489ba442350227be72f7a0b Mon Sep 17 00:00:00 2001 From: nicksinas Date: Fri, 16 Oct 2026 17:25:58 -0500 Subject: [PATCH 3/6] chore(deps): bump quinn-proto to 0.11.19 and rustls to 0.23.46 Lockfile only. Clears the nine cargo audit findings that fail the Security Audit job on every PR: RUSTSEC-2026-0338 to 0342 (quinn-proto, fixed in 0.11.18) and RUSTSEC-2026-0346 to 0349 (rustls, fixed in 0.23.46). Both crates are transitive, pulled in through reqwest. (cherry picked from commit 0cd54a78ef17d5f431bb8b284daf186727514304) --- Cargo.lock | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b75e5424..84045d16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,11 +991,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1790,15 +1792,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1891,6 +1894,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -2070,9 +2082,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", From 72f1081e68e129b161c73498ca1618bc21fc6300 Mon Sep 17 00:00:00 2001 From: nicksinas Date: Fri, 16 Oct 2026 17:37:27 -0500 Subject: [PATCH 4/6] runtime build: one staging temp dir per call Review follow-up on #303. Builds that share a src_dir shared one .tuf-staging-tmp; overlapping runs could overwrite or delete each other's files. Each call now gets its own tempfile directory under the project root, the copy script reads that directory, and the handle removes it on drop. --- src/commands/runtime/build.rs | 61 +++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 8c5db3df..5459f103 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1390,9 +1390,10 @@ echo -n '}}' )?; // Phase 3: Write signed files into the build volume via a container run. - // We write them to a temp dir under the project directory (accessible inside - // the container as /opt/src/.tuf-staging-tmp/), then copy into the volume. - let tmp_dir = project_dir.join(".tuf-staging-tmp"); + // They go to a temp dir under the project root, then a container copy + // moves them into the volume. + let (staging, staging_dir) = tuf_staging_dir(project_dir)?; + let tmp_dir = staging.path(); let tmp_delegations = tmp_dir.join("delegations"); std::fs::create_dir_all(&tmp_delegations) .context("Failed to create TUF staging temp directory")?; @@ -1411,8 +1412,8 @@ echo -n '}}' r#"set -euo pipefail DEST="$AVOCADO_PREFIX/runtimes/{runtime_name}/var-staging/lib/avocado/tuf-staging" mkdir -p "$DEST/delegations" -cp /opt/src/.tuf-staging-tmp/targets.json "$DEST/targets.json" -cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ +cp "/opt/src/{staging_dir}/targets.json" "$DEST/targets.json" +cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ "$DEST/delegations/runtime-{runtime_uuid}.json" "# ); @@ -1436,7 +1437,7 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ run_container_command(container_helper, copy_run_config, runs_on_context).await?; // Clean up host temp files. - let _ = std::fs::remove_dir_all(&tmp_dir); + drop(staging); if !copied { return Err(anyhow::anyhow!( @@ -1517,7 +1518,8 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ )?; // Phase 3: Write files into the build volume. - let tmp_dir = project_dir.join(".tuf-staging-tmp"); + let (staging, staging_dir) = tuf_staging_dir(project_dir)?; + let tmp_dir = staging.path(); let tmp_delegations = tmp_dir.join("delegations"); std::fs::create_dir_all(&tmp_delegations) .context("Failed to create TUF staging temp directory")?; @@ -1536,8 +1538,8 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ r#"set -euo pipefail DEST="$AVOCADO_PREFIX/runtimes/{runtime_name}/var-staging/lib/avocado/tuf-staging" mkdir -p "$DEST/delegations" -cp /opt/src/.tuf-staging-tmp/targets.json "$DEST/targets.json" -cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ +cp "/opt/src/{staging_dir}/targets.json" "$DEST/targets.json" +cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ "$DEST/delegations/runtime-{runtime_uuid}.json" "# ); @@ -1560,7 +1562,7 @@ cp /opt/src/.tuf-staging-tmp/delegations/runtime-{runtime_uuid}.json \ let copied = run_container_command(container_helper, copy_run_config, runs_on_context).await?; - let _ = std::fs::remove_dir_all(&tmp_dir); + drop(staging); if !copied { return Err(anyhow::anyhow!( @@ -3176,6 +3178,24 @@ fn collect_runtime_compile_entries(merged_runtime: &serde_yaml::Value) -> Vec/`. Unique per call, so +/// two builds sharing a `src_dir` cannot overwrite or delete each other's +/// files; removed when the handle drops. +fn tuf_staging_dir(project_dir: &std::path::Path) -> Result<(tempfile::TempDir, String)> { + let staging = tempfile::Builder::new() + .prefix(".tuf-staging-") + .tempdir_in(project_dir) + .context("Failed to create TUF staging temp directory")?; + let name = staging + .path() + .file_name() + .and_then(|n| n.to_str()) + .context("TUF staging temp directory has no name")? + .to_string(); + Ok((staging, name)) +} + async fn run_container_command( container_helper: &SdkContainer, config: RunConfig, @@ -3335,6 +3355,27 @@ fi #[cfg(test)] mod tests { + /// Each staging call gets its own directory under the project root, so + /// two builds sharing a `src_dir` never write or delete each other's + /// files. The name is what the copy script appends to `/opt/src`. + #[test] + fn tuf_staging_dir_is_unique_per_call_and_removed_on_drop() { + let root = tempfile::TempDir::new().unwrap(); + let (a, a_name) = super::tuf_staging_dir(root.path()).unwrap(); + let (b, b_name) = super::tuf_staging_dir(root.path()).unwrap(); + assert_ne!(a_name, b_name); + assert!(a_name.starts_with(".tuf-staging-")); + assert_eq!(a.path(), root.path().join(&a_name)); + assert!(a.path().is_dir() && b.path().is_dir()); + let a_path = a.path().to_path_buf(); + drop(a); + assert!( + !a_path.exists(), + "dropping the handle removes the directory" + ); + assert!(b.path().is_dir(), "the other call's directory is untouched"); + } + #[test] fn fit_assembly_script_is_gated_and_handles_signing_both_ways() { let s = super::generate_fit_assembly_script(); From af2112b1c77cc877617ed5a6199987dddeb5e6a4 Mon Sep 17 00:00:00 2001 From: nicksinas Date: Mon, 19 Oct 2026 17:02:05 -0500 Subject: [PATCH 5/6] src_dir: stage under the mounted dir, test the fixed call sites, correct the comments The TUF staging root is now SdkContainer::mounted_src_dir, the directory the container mounts at /opt/src, which every mount site also uses. This also covers a config in a subdirectory run with -C and no src_dir. Both staging paths share stage_into_volume, which writes the files, runs the copy and turns a failed copy into an error. Tests go through it, through get_extensions_dir and through merge_installed_remote_extensions. The load_from_current_dir doc now says which readers key on what. The run_container_command doc is back on its function. --- src/commands/runtime/build.rs | 294 +++++++++++++++++++++++----------- src/utils/config.rs | 96 +++++++++-- src/utils/container.rs | 39 ++++- src/utils/volume.rs | 11 +- 4 files changed, 322 insertions(+), 118 deletions(-) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 5459f103..918d8ef3 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1152,11 +1152,6 @@ impl RuntimeBuildCommand { // modes, so it would otherwise vanish exactly where builds usually // run. TUI queues it above the task region; JSON mode writes it to // stderr without corrupting the NDJSON stream. - // The staging files are handed to the container through `/opt/src`, - // which is the resolved `src_dir`, so they have to be written under - // that root and not under the config file's directory. The two differ - // whenever `src_dir` is set. - let project_dir = config.project_root(&self.config_path); if let Err(e) = self .generate_tuf_staging( config, @@ -1167,7 +1162,6 @@ impl RuntimeBuildCommand { repo_release, container_helper, runs_on_context, - &project_dir, ) .await { @@ -1267,7 +1261,6 @@ echo -n '}}' repo_release: Option<&String>, container_helper: &SdkContainer, runs_on_context: Option<&RunsOnContext>, - project_dir: &std::path::Path, ) -> Result<()> { // Resolve signing keys. At Level 0 (Connect, no local key), skip TUF staging entirely. let signing_key_name = config.get_runtime_signing_key_name(&self.runtime_name); @@ -1309,7 +1302,6 @@ echo -n '}}' repo_release, container_helper, runs_on_context, - project_dir, &content_signer, ) .await; @@ -1390,38 +1382,9 @@ echo -n '}}' )?; // Phase 3: Write signed files into the build volume via a container run. - // They go to a temp dir under the project root, then a container copy - // moves them into the volume. - let (staging, staging_dir) = tuf_staging_dir(project_dir)?; - let tmp_dir = staging.path(); - let tmp_delegations = tmp_dir.join("delegations"); - std::fs::create_dir_all(&tmp_delegations) - .context("Failed to create TUF staging temp directory")?; - - std::fs::write(tmp_dir.join("targets.json"), &repo_metadata.targets_json) - .context("Failed to write targets.json to temp dir")?; - std::fs::write( - tmp_delegations.join(format!("runtime-{}.json", collection.runtime_uuid)), - &repo_metadata.delegated_targets_json, - ) - .context("Failed to write delegated targets to temp dir")?; - - let runtime_name = &self.runtime_name; - let runtime_uuid = &collection.runtime_uuid; - let copy_script = format!( - r#"set -euo pipefail -DEST="$AVOCADO_PREFIX/runtimes/{runtime_name}/var-staging/lib/avocado/tuf-staging" -mkdir -p "$DEST/delegations" -cp "/opt/src/{staging_dir}/targets.json" "$DEST/targets.json" -cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ - "$DEST/delegations/runtime-{runtime_uuid}.json" -"# - ); - let copy_run_config = RunConfig { container_image: container_image.to_string(), target: target_arch.to_string(), - command: copy_script, verbose: false, source_environment: true, interactive: false, @@ -1433,18 +1396,25 @@ cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ env_vars: self.runtime_env_vars(), ..Default::default() }; - let copied = - run_container_command(container_helper, copy_run_config, runs_on_context).await?; - - // Clean up host temp files. - drop(staging); - - if !copied { - return Err(anyhow::anyhow!( - "failed to copy the delegation staging files into the build volume for runtime '{}'", - self.runtime_name - )); - } + stage_into_volume( + container_helper, + &self.runtime_name, + &collection.runtime_uuid, + &repo_metadata.targets_json, + &repo_metadata.delegated_targets_json, + "delegation", + |command| { + run_container_command( + container_helper, + RunConfig { + command, + ..copy_run_config + }, + runs_on_context, + ) + }, + ) + .await?; print_info( &format!( @@ -1470,7 +1440,6 @@ cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ repo_release: Option<&String>, container_helper: &SdkContainer, runs_on_context: Option<&RunsOnContext>, - project_dir: &std::path::Path, content_signer: &crate::utils::update_signing::TufSigner, ) -> Result<()> { // Phase 1: Collect artifact hashes from inside the build volume. @@ -1518,36 +1487,9 @@ cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ )?; // Phase 3: Write files into the build volume. - let (staging, staging_dir) = tuf_staging_dir(project_dir)?; - let tmp_dir = staging.path(); - let tmp_delegations = tmp_dir.join("delegations"); - std::fs::create_dir_all(&tmp_delegations) - .context("Failed to create TUF staging temp directory")?; - - std::fs::write(tmp_dir.join("targets.json"), &metadata.targets_json) - .context("Failed to write targets.json to temp dir")?; - std::fs::write( - tmp_delegations.join(format!("runtime-{}.json", collection.runtime_uuid)), - &metadata.delegated_targets_json, - ) - .context("Failed to write delegated targets to temp dir")?; - - let runtime_name = &self.runtime_name; - let runtime_uuid = &collection.runtime_uuid; - let copy_script = format!( - r#"set -euo pipefail -DEST="$AVOCADO_PREFIX/runtimes/{runtime_name}/var-staging/lib/avocado/tuf-staging" -mkdir -p "$DEST/delegations" -cp "/opt/src/{staging_dir}/targets.json" "$DEST/targets.json" -cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ - "$DEST/delegations/runtime-{runtime_uuid}.json" -"# - ); - let copy_run_config = RunConfig { container_image: container_image.to_string(), target: target_arch.to_string(), - command: copy_script, verbose: false, source_environment: true, interactive: false, @@ -1559,17 +1501,25 @@ cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ env_vars: self.runtime_env_vars(), ..Default::default() }; - let copied = - run_container_command(container_helper, copy_run_config, runs_on_context).await?; - - drop(staging); - - if !copied { - return Err(anyhow::anyhow!( - "failed to copy the content delegation staging files into the build volume for runtime '{}'", - self.runtime_name - )); - } + stage_into_volume( + container_helper, + &self.runtime_name, + &collection.runtime_uuid, + &metadata.targets_json, + &metadata.delegated_targets_json, + "content delegation", + |command| { + run_container_command( + container_helper, + RunConfig { + command, + ..copy_run_config + }, + runs_on_context, + ) + }, + ) + .await?; print_info( &format!( @@ -3177,15 +3127,14 @@ fn collect_runtime_compile_entries(merged_runtime: &serde_yaml::Value) -> Vec/`. Unique per call, so -/// two builds sharing a `src_dir` cannot overwrite or delete each other's -/// files; removed when the handle drops. -fn tuf_staging_dir(project_dir: &std::path::Path) -> Result<(tempfile::TempDir, String)> { +/// A fresh staging directory under `root` for one copy into the volume. +/// Unique per call, so two builds sharing a `src_dir` cannot overwrite or +/// delete each other's files; removed when the handle drops. Returns the +/// handle and the directory's name. +fn tuf_staging_dir(root: &std::path::Path) -> Result<(tempfile::TempDir, String)> { let staging = tempfile::Builder::new() .prefix(".tuf-staging-") - .tempdir_in(project_dir) + .tempdir_in(root) .context("Failed to create TUF staging temp directory")?; let name = staging .path() @@ -3196,6 +3145,61 @@ fn tuf_staging_dir(project_dir: &std::path::Path) -> Result<(tempfile::TempDir, Ok((staging, name)) } +/// Write the signed `targets.json` and delegated targets under the host +/// directory the container mounts at `/opt/src`, then hand `copy` the script +/// that moves them into the build volume. `copy` returns whether the +/// container run succeeded; a failed copy is an error naming the runtime. +async fn stage_into_volume( + container_helper: &SdkContainer, + runtime_name: &str, + runtime_uuid: &str, + targets_json: &str, + delegated_targets_json: &str, + kind: &str, + copy: F, +) -> Result<()> +where + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, +{ + let (staging, staging_dir) = tuf_staging_dir(container_helper.mounted_src_dir())?; + let tmp_dir = staging.path(); + let tmp_delegations = tmp_dir.join("delegations"); + std::fs::create_dir_all(&tmp_delegations) + .context("Failed to create TUF staging temp directory")?; + + std::fs::write(tmp_dir.join("targets.json"), targets_json) + .context("Failed to write targets.json to temp dir")?; + std::fs::write( + tmp_delegations.join(format!("runtime-{runtime_uuid}.json")), + delegated_targets_json, + ) + .context("Failed to write delegated targets to temp dir")?; + + let copy_script = format!( + r#"set -euo pipefail +DEST="$AVOCADO_PREFIX/runtimes/{runtime_name}/var-staging/lib/avocado/tuf-staging" +mkdir -p "$DEST/delegations" +cp "/opt/src/{staging_dir}/targets.json" "$DEST/targets.json" +cp "/opt/src/{staging_dir}/delegations/runtime-{runtime_uuid}.json" \ + "$DEST/delegations/runtime-{runtime_uuid}.json" +"# + ); + + let copied = copy(copy_script).await?; + + // Clean up host temp files. + drop(staging); + + if !copied { + return Err(anyhow::anyhow!( + "failed to copy the {kind} staging files into the build volume for runtime '{runtime_name}'" + )); + } + Ok(()) +} + +/// Helper function to run a container command, using shared context if available async fn run_container_command( container_helper: &SdkContainer, config: RunConfig, @@ -3355,6 +3359,8 @@ fi #[cfg(test)] mod tests { + use super::{Config, SdkContainer}; + /// Each staging call gets its own directory under the project root, so /// two builds sharing a `src_dir` never write or delete each other's /// files. The name is what the copy script appends to `/opt/src`. @@ -3376,6 +3382,104 @@ mod tests { assert!(b.path().is_dir(), "the other call's directory is untouched"); } + /// A project whose config sits in `/project/avocado/` with + /// `src_dir: ..`. The container mounts `/project` at `/opt/src`. + fn src_dir_above_config_dir() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let root = tempfile::TempDir::new().unwrap(); + let project = root.path().canonicalize().unwrap().join("project"); + let config_dir = project.join("avocado"); + std::fs::create_dir_all(&config_dir).unwrap(); + let config_path = config_dir.join("avocado.yaml"); + (root, project, config_path) + } + + fn staging_dirs(dir: &std::path::Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(".tuf-staging-")) + }) + .collect() + } + + /// The copy script reads `/opt/src//`, so the files must be on + /// the host under the directory the container mounts there: `src_dir` + /// when set, else the working directory. Never the config file's + /// directory unless one of those is it. + #[tokio::test] + async fn stage_into_volume_writes_under_the_mounted_src_dir() { + let (_root, project, config_path) = src_dir_above_config_dir(); + let config_str = config_path.to_str().unwrap(); + let config = Config::load_from_yaml_str("src_dir: ..\nextensions: {}\n").unwrap(); + let with_src_dir = SdkContainer::from_config(config_str, &config).unwrap(); + + // No `src_dir`, run from the parent with `-C project/avocado/avocado.yaml`. + let config = Config::load_from_yaml_str("extensions: {}\n").unwrap(); + let mut from_parent = SdkContainer::from_config(config_str, &config).unwrap(); + from_parent.cwd = project.clone(); + + for container in [with_src_dir, from_parent] { + super::stage_into_volume( + &container, + "dev", + "uuid-1", + "{\"targets\":1}", + "{\"delegated\":1}", + "delegation", + |script| { + let staged = staging_dirs(&project); + assert_eq!( + staged.len(), + 1, + "one staging dir under {}", + project.display() + ); + let name = staged[0].file_name().unwrap().to_str().unwrap(); + assert!(script.contains(&format!("/opt/src/{name}/targets.json"))); + assert_eq!( + std::fs::read_to_string(staged[0].join("targets.json")).unwrap(), + "{\"targets\":1}" + ); + assert!(staged[0].join("delegations/runtime-uuid-1.json").is_file()); + assert!(staging_dirs(config_path.parent().unwrap()).is_empty()); + async { Ok(true) } + }, + ) + .await + .unwrap(); + assert!(staging_dirs(&project).is_empty(), "staging is removed"); + } + } + + /// A failed copy leaves the runtime without `tuf-staging/`, so it must + /// be an error naming the runtime and not a success line. + #[tokio::test] + async fn stage_into_volume_fails_when_the_copy_fails() { + let (_root, project, config_path) = src_dir_above_config_dir(); + let config_str = config_path.to_str().unwrap(); + let config = Config::load_from_yaml_str("src_dir: ..\nextensions: {}\n").unwrap(); + let container = SdkContainer::from_config(config_str, &config).unwrap(); + + let err = super::stage_into_volume( + &container, + "dev", + "uuid-1", + "{}", + "{}", + "content delegation", + |_| async { Ok(false) }, + ) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("content delegation staging files"), "{err}"); + assert!(err.contains("runtime 'dev'"), "{err}"); + assert!(staging_dirs(&project).is_empty(), "staging is removed"); + } + #[test] fn fit_assembly_script_is_gated_and_handles_signing_both_ways() { let s = super::generate_fit_assembly_script(); diff --git a/src/utils/config.rs b/src/utils/config.rs index ce009b0e..857c97b2 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -6897,25 +6897,93 @@ mod tests { ); } - /// With `src_dir` set, the project root is `src_dir` and not the config - /// file's directory. The runtime build writes its TUF staging files under - /// the root and the container reads them back through `/opt/src`, which - /// is `src_dir`; a root taken from the config path put the two out of step. - #[test] - fn project_root_follows_src_dir_above_the_config_dir() { - let dir = std::env::temp_dir().join(format!( - "avocado-project-root-src-dir-{}", - std::process::id() - )); - let config_dir = dir.join("project").join("avocado"); + /// A `src_dir: ..` project run from its config directory: the config and + /// the `.avocado-state` that `get_or_create_volume` wrote sit in + /// `/project/avocado`, and `src_dir` is `/project`. The state + /// names a fake container tool whose `volume inspect` prints + /// `/mount`, which holds a fetched `remote-ext`. Returns the root, + /// the config dir and the config path. + #[cfg(unix)] + fn state_in_config_dir_below_src_dir( + target: &str, + ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::TempDir::new().unwrap(); + let base = root.path().canonicalize().unwrap(); + let config_dir = base.join("project").join("avocado"); std::fs::create_dir_all(&config_dir).unwrap(); let config_path = config_dir.join("avocado.yaml"); + let mount = base.join("mount"); + let ext_dir = mount.join(target).join("includes").join("remote-ext"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write( + ext_dir.join("avocado.yaml"), + "extensions:\n remote-ext:\n types: [sysext]\n", + ) + .unwrap(); + + let tool = base.join("fake-docker"); + std::fs::write(&tool, format!("#!/bin/sh\necho {}\n", mount.display())).unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + crate::utils::volume::VolumeState::new( + config_dir.clone(), + tool.to_string_lossy().to_string(), + ) + .save_to_dir(&config_dir) + .unwrap(); + + (root, config_dir, config_path) + } + + /// The extensions dir comes from the volume named in the working + /// directory's state file, not the `src_dir` fallback. + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn get_extensions_dir_reads_the_volume_state_from_the_working_directory() { + let target = "qemux86-64"; + let (root, config_dir, config_path) = state_in_config_dir_below_src_dir(target); let config = super::Config::load_from_yaml_str("src_dir: ..\nextensions: {}\n").unwrap(); - let root = config.project_root(&config_path); - assert_eq!(root, dir.join("project").canonicalize().unwrap()); - let _ = std::fs::remove_dir_all(&dir); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&config_dir).unwrap(); + let dir = config.get_extensions_dir(config_path.to_str().unwrap(), target); + std::env::set_current_dir(prev).unwrap(); + + let base = root.path().canonicalize().unwrap(); + assert_eq!(dir, base.join("mount").join(target).join("includes")); + } + + /// A fetched remote extension is read out of the volume named in the + /// working directory's state file, so install does not stop with "has + /// not been merged". + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn remote_extensions_merge_from_the_volume_in_the_working_directory() { + let target = "qemux86-64"; + let (_root, config_dir, config_path) = state_in_config_dir_below_src_dir(target); + let mut main_config: serde_yaml::Value = serde_yaml::from_str( + "src_dir: ..\nextensions:\n remote-ext:\n source: { type: package, version: \"1.0.0\" }\n", + ) + .unwrap(); + + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&config_dir).unwrap(); + let merged = super::Config::merge_installed_remote_extensions( + &mut main_config, + &config_path, + Some(target), + ); + std::env::set_current_dir(prev).unwrap(); + + let merged = merged.unwrap(); + assert!( + merged.extension_sources.contains_key("remote-ext"), + "remote-ext was not read from the volume: {:?}", + merged.extension_sources + ); } /// Recording the feed set must be a no-op when there is nothing to record. diff --git a/src/utils/container.rs b/src/utils/container.rs index f35b7e1f..f3be03a9 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -1347,6 +1347,12 @@ impl SdkContainer { self } + /// The host directory the container mounts at `/opt/src`: `src_dir` when + /// set, else the working directory. + pub fn mounted_src_dir(&self) -> &Path { + self.src_dir.as_deref().unwrap_or(&self.cwd) + } + /// Create a new SdkContainer with configuration from config file pub fn from_config(config_path: &str, config: &crate::utils::config::Config) -> Result { let src_dir = config.get_resolved_src_dir(config_path); @@ -1517,7 +1523,7 @@ impl SdkContainer { let volume_manager = VolumeManager::new(self.container_tool.clone(), self.verbose); let volume_state = volume_manager.get_or_create_volume(&self.cwd).await?; - let src_dir = self.src_dir.as_ref().unwrap_or(&self.cwd); + let src_dir = self.mounted_src_dir(); print_info( &format!("Setting up remote execution on {runs_on}..."), @@ -1739,7 +1745,7 @@ impl SdkContainer { let volume_manager = VolumeManager::new(self.container_tool.clone(), self.verbose); let volume_state = volume_manager.get_or_create_volume(&self.cwd).await?; - let src_dir = self.src_dir.as_ref().unwrap_or(&self.cwd); + let src_dir = self.mounted_src_dir(); print_info( &format!("Setting up remote execution on {runs_on}..."), @@ -2027,7 +2033,7 @@ impl SdkContainer { // Volume mounts: docker volume for persistent state, bind mount for source // Source is mounted to /mnt/src, then bindfs remounts it to /opt/src with permission translation container_cmd.push("-v".to_string()); - let src_path = self.src_dir.as_ref().unwrap_or(&self.cwd); + let src_path = self.mounted_src_dir(); // Every host-path bind goes through translate_bind_for_vm: when VM // routing is active it rewrites the host path to its in-VM // /run/workspace/ equivalent; otherwise it's a pass-through. @@ -3108,7 +3114,7 @@ impl SdkContainer { // Volume mounts: docker volume for persistent state, bind mount for source. // Host path is translated to /run/workspace/ when VM routing is on. container_cmd.push("-v".to_string()); - let src_path = self.src_dir.as_ref().unwrap_or(&self.cwd); + let src_path = self.mounted_src_dir(); container_cmd.push(format!( "{}:/mnt/src:rw", translate_bind_for_vm(src_path).display() @@ -5258,4 +5264,29 @@ extensions: "every os-release read must pipe through `tail -n 1`" ); } + + /// `/opt/src` is `src_dir` when the config sets one, resolved against the + /// config file's directory, and the working directory otherwise. + #[test] + #[serial_test::serial] + fn mounted_src_dir_is_src_dir_or_the_working_directory() { + let root = tempfile::TempDir::new().unwrap(); + let project = root.path().canonicalize().unwrap().join("project"); + std::fs::create_dir_all(project.join("avocado")).unwrap(); + let config_path = project.join("avocado").join("avocado.yaml"); + let config_str = config_path.to_str().unwrap(); + + let config = + crate::utils::config::Config::load_from_yaml_str("src_dir: ..\nextensions: {}\n") + .unwrap(); + let container = SdkContainer::from_config(config_str, &config).unwrap(); + assert_eq!(container.mounted_src_dir(), project); + + let config = crate::utils::config::Config::load_from_yaml_str("extensions: {}\n").unwrap(); + let container = SdkContainer::from_config(config_str, &config).unwrap(); + assert_eq!( + container.mounted_src_dir(), + env::current_dir().unwrap().as_path() + ); + } } diff --git a/src/utils/volume.rs b/src/utils/volume.rs index 244ad495..ef71c05a 100644 --- a/src/utils/volume.rs +++ b/src/utils/volume.rs @@ -52,12 +52,13 @@ impl VolumeState { Ok(Some(state)) } - /// Load the volume state the container layer keys on. + /// Load the volume state from the process working directory. /// - /// `get_or_create_volume` writes `.avocado-state` in the process working - /// directory (`SdkContainer::cwd`), not in `src_dir`. Readers that looked in - /// `src_dir` found nothing whenever the two differed, so every reader of - /// the state file goes through this one key. + /// `SdkContainer` keys the volume on its `cwd`, so install, build and the + /// stamps code find `.avocado-state` in the working directory, not in + /// `src_dir`. Not every reader agrees: runtime provision keys its state + /// file copy on `src_dir`, and `profiles`, `save` and `load` read from the + /// config file's directory. pub fn load_from_current_dir() -> Result> { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); Self::load_from_dir(&cwd) From a892995b27e01732a4119668104b771852b6ee47 Mon Sep 17 00:00:00 2001 From: nicksinas Date: Mon, 19 Oct 2026 17:06:21 -0500 Subject: [PATCH 6/6] tests: keep in-process config loads away from a stray .avocado-state The package-sourced version test relies on the src_dir dev fallback, which is taken only when the working directory has no .avocado-state. The integration tests leave one at the crate root, so a second cargo test run failed. The test now loads from an empty temp directory of its own. --- tests/ext_version_source.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/ext_version_source.rs b/tests/ext_version_source.rs index ef213fbf..be8bd7f3 100644 --- a/tests/ext_version_source.rs +++ b/tests/ext_version_source.rs @@ -166,7 +166,10 @@ fn path_sourced_extension_resolves_a_bare_version_file() { /// /// The dev-fallback root (`/.avocado//includes/`) stands /// in for the installed payload; it is the same directory layout DNF produces. +/// The fallback is taken only when the working directory has no +/// `.avocado-state`, so the loads run from an empty directory of their own. #[test] +#[serial_test::serial] fn package_sourced_payload_resolves_to_the_same_version_as_a_path_source() { let dir = TestDir::new("package_parity"); write_cargo_extension(&dir, "my-ext", "avocado-ext-prog", "3.1.4"); @@ -189,6 +192,9 @@ fn package_sourced_payload_resolves_to_the_same_version_as_a_path_source() { type: path\n \ path: ../my-ext\n", ); + let cwd = tempfile::TempDir::new().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(cwd.path()).unwrap(); let path_version = composed_ext_version(&via_path, "avocado-ext-prog"); let via_package = dir.write( @@ -202,20 +208,22 @@ fn package_sourced_payload_resolves_to_the_same_version_as_a_path_source() { ); let package_version = composed_ext_version(&via_package, "avocado-ext-prog"); - assert_eq!(path_version.as_deref(), Some("3.1.4")); - assert_eq!( - path_version, package_version, - "switching source kind changed the composed version" - ); - // Prove the package leg really read the installed payload rather than // happening to agree with the sibling checkout: bump only the payload. dir.write( "project/.avocado/qemux86-64/includes/avocado-ext-prog/Cargo.toml", "[package]\nname = \"prog\"\nversion = \"3.2.0\"\nedition = \"2021\"\n", ); + let bumped_version = composed_ext_version(&via_package, "avocado-ext-prog"); + std::env::set_current_dir(prev).unwrap(); + + assert_eq!(path_version.as_deref(), Some("3.1.4")); + assert_eq!( + path_version, package_version, + "switching source kind changed the composed version" + ); assert_eq!( - composed_ext_version(&via_package, "avocado-ext-prog").as_deref(), + bumped_version.as_deref(), Some("3.2.0"), "package source did not resolve from the installed payload" );