From 7a78b0ec824c606ef2e43b4c3a7bf8c1a681a85c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 08:19:06 +0000 Subject: [PATCH 1/8] chore(deps): Update yaml-rust2 requirement from 0.10 to 0.11 Updates the requirements on [yaml-rust2](https://github.com/Ethiraric/yaml-rust2) to permit the latest version. - [Release notes](https://github.com/Ethiraric/yaml-rust2/releases) - [Changelog](https://github.com/Ethiraric/yaml-rust2/blob/master/CHANGELOG.md) - [Commits](https://github.com/Ethiraric/yaml-rust2/compare/v0.10.0...v0.11.0) --- updated-dependencies: - dependency-name: yaml-rust2 dependency-version: 0.11.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1195e32..1b67e73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ tar = "0.4" flate2 = "1.0" reqwest = { version = "0.12", features = ["json", "stream"] } bytes = "1.0" -yaml-rust2 = "0.10" +yaml-rust2 = "0.11" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dirs = "6.0" From 9a96a08523ee01f0a803f9e696716b256f2f91df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:19:42 +0000 Subject: [PATCH 2/8] chore(deps): Update reqwest requirement from 0.12 to 0.13 Updates the requirements on [reqwest](https://github.com/seanmonstar/reqwest) to permit the latest version. - [Release notes](https://github.com/seanmonstar/reqwest/releases) - [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md) - [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.0...v0.13.1) --- updated-dependencies: - dependency-name: reqwest dependency-version: 0.13.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1195e32..2693604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ sha256 = "1.5" chrono = "0.4" tar = "0.4" flate2 = "1.0" -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.13", features = ["json", "stream"] } bytes = "1.0" yaml-rust2 = "0.10" tracing = "0.1" From 998a99188e37344a117dd40c734856d9957cad0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:27:34 +0000 Subject: [PATCH 3/8] chore(deps): Bump actions/checkout from 5.0.0 to 6.0.2 Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f08f3fa..fc35c4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: os: [ubuntu-latest, ubuntu-24.04-arm] rust: [stable, beta] steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # master with: toolchain: ${{ matrix.rust }} From 58c11fed7f99fafb585018e51c1efb5ef05142a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 08:26:12 +0000 Subject: [PATCH 4/8] chore(deps): Bump actions/cache from 4.3.0 to 5.0.3 Bumps [actions/cache](https://github.com/actions/cache) from 4.3.0 to 5.0.3. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/0057852bfaa89a56745cba8c7296529d2fc39830...cdf6c1fa76f9f475f3d7449005a359c84ca0f306) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.3 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f08f3fa..20f381d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,17 +30,17 @@ jobs: sudo apt-get update sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu - name: Cache cargo registry - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: ~/.cargo/git key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo build - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: target key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} From 452f4c8307dafa9b98d060dc479b52b742bfcae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 18:40:51 +0000 Subject: [PATCH 5/8] Fix bugs, code smells, and design issues found in code review Bug fixes: - Fix platform selection: fetch_image_data now uses the platform parameter to select the correct manifest from an image index instead of always picking the first entry - Fix parse_platform to accept 3-part platforms (linux/arm/v7, linux/arm/v6) which previously caused a hard error at image creation time - Fix variant always being None in manifest descriptors, now properly set for ARM platforms so registries can select the correct image - Fix auth tests that used matches!() without assert!(), silently passing - Fix create_test_binary using a dangling path after NamedTempFile drop Design improvements: - Implement real platform detection in get_image_platforms by fetching the image index from the registry instead of returning hardcoded defaults - Deduplicate Platform type (was defined identically in manifest and registry) - Extract shared build-and-push logic into build_and_push_platform() and push_tagged_manifest_list(), eliminating ~100 lines of duplication between Build and Resolve commands - Use consistent OCI media types throughout instead of mixing Docker v2 and OCI formats - Rename config::RegistryAuth to config::RegistryCredential to avoid naming collision with registry::RegistryAuth - Simplify push_manifest return type from (location, digest) to just digest - Remove dead Push command that only printed an error - Remove unused auth_cache field from RegistryClient Robustness: - URL-encode query parameters in token requests using reqwest::Url - Add connect (30s) and request (300s) timeouts to HTTP client https://claude.ai/code/session_01RoAUcigxrM6Lz8DESr3SkW --- src/auth/mod.rs | 14 +- src/cli/mod.rs | 6 - src/config/mod.rs | 4 +- src/image/mod.rs | 66 ++++++--- src/main.rs | 335 ++++++++++++++++++-------------------------- src/registry/mod.rs | 297 ++++++++++++++++++++++++++------------- 6 files changed, 389 insertions(+), 333 deletions(-) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 6a646d0..34c20c5 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -205,25 +205,25 @@ mod unit_tests { // Test anonymous let auth = AuthConfig::anonymous(); - matches!(auth.to_registry_auth(), RegistryAuth::Anonymous); + assert!(matches!(auth.to_registry_auth(), RegistryAuth::Anonymous)); // Test basic auth let auth = AuthConfig::new("user".to_string(), "pass".to_string()); - matches!( + assert!(matches!( auth.to_registry_auth(), - RegistryAuth::Basic { username, password } + RegistryAuth::Basic { ref username, ref password } if username == "user" && password == "pass" - ); + )); // Test bearer token let auth = AuthConfig { registry_token: Some("token123".to_string()), ..Default::default() }; - matches!( + assert!(matches!( auth.to_registry_auth(), - RegistryAuth::Bearer { token } + RegistryAuth::Bearer { ref token } if token == "token123" - ); + )); } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e621015..7063f3e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -44,12 +44,6 @@ pub enum Commands { cargo_args: Vec, }, - /// Push a built image to a container registry - Push { - /// Image reference to push - image: String, - }, - /// Resolve krust:// references in YAML files Resolve { /// Path to YAML file or directory containing YAML files diff --git a/src/config/mod.rs b/src/config/mod.rs index 9b1c948..cf65c36 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ pub struct Config { /// Registry authentication configuration #[serde(default)] - pub registries: HashMap, + pub registries: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -38,7 +38,7 @@ pub struct BuildConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistryAuth { +pub struct RegistryCredential { pub username: Option, pub password: Option, pub auth: Option, diff --git a/src/image/mod.rs b/src/image/mod.rs index 2462be0..3ee436c 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -79,6 +79,20 @@ pub struct Descriptor { pub digest: String, } +/// Parse a platform string like "linux/amd64" or "linux/arm/v7" into (os, arch, variant). +pub fn parse_platform_string(platform: &str) -> Result<(String, String, Option)> { + let parts: Vec<&str> = platform.split('/').collect(); + match parts.len() { + 2 => Ok((parts[0].to_string(), parts[1].to_string(), None)), + 3 => Ok(( + parts[0].to_string(), + parts[1].to_string(), + Some(parts[2].to_string()), + )), + _ => anyhow::bail!("Invalid platform format: {}", platform), + } +} + pub struct ImageBuilder { binary_path: PathBuf, #[allow(dead_code)] @@ -104,7 +118,7 @@ impl ImageBuilder { ) -> Result<(Vec, Vec, Manifest)> { info!("Building container image"); - let (_os, _arch) = self.parse_platform()?; + let (_os, _arch, _variant) = self.parse_platform()?; // Fetch base image data info!( @@ -133,7 +147,7 @@ impl ImageBuilder { // Add the application layer all_layers.push(Descriptor { - media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string(), + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".to_string(), size: app_layer_size, digest: app_layer_digest, }); @@ -147,9 +161,9 @@ impl ImageBuilder { // Create manifest let manifest = Manifest { schema_version: 2, - media_type: "application/vnd.docker.distribution.manifest.v2+json".to_string(), + media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), config: Descriptor { - media_type: "application/vnd.docker.container.image.v1+json".to_string(), + media_type: "application/vnd.oci.image.config.v1+json".to_string(), size: config_size, digest: config_digest, }, @@ -159,12 +173,8 @@ impl ImageBuilder { Ok((config_data, app_layer_data, manifest)) } - fn parse_platform(&self) -> Result<(String, String)> { - let parts: Vec<&str> = self.platform.split('/').collect(); - if parts.len() != 2 { - anyhow::bail!("Invalid platform format: {}", self.platform); - } - Ok((parts[0].to_string(), parts[1].to_string())) + fn parse_platform(&self) -> Result<(String, String, Option)> { + parse_platform_string(&self.platform) } fn create_layer(&self) -> Result<(Vec, String)> { @@ -264,13 +274,12 @@ mod tests { use std::path::PathBuf; use tempfile::NamedTempFile; - fn create_test_binary() -> PathBuf { + fn create_test_binary() -> (PathBuf, tempfile::TempPath) { let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(b"fake binary content").unwrap(); - let path = temp_file.path().to_path_buf(); - // Keep the file alive by converting to a regular file - std::fs::copy(&path, &path).unwrap(); - path + let temp_path = temp_file.into_temp_path(); + let path = temp_path.to_path_buf(); + (path, temp_path) } fn create_base_image_config() -> ImageConfig { @@ -318,14 +327,29 @@ mod tests { "linux/amd64".to_string(), ); - let (os, arch) = builder.parse_platform().unwrap(); + let (os, arch, variant) = builder.parse_platform().unwrap(); assert_eq!(os, "linux"); assert_eq!(arch, "amd64"); + assert_eq!(variant, None); + } + + #[test] + fn test_parse_platform_with_variant() { + let builder = ImageBuilder::new( + PathBuf::from("/tmp/test"), + "test-base".to_string(), + "linux/arm/v7".to_string(), + ); + + let (os, arch, variant) = builder.parse_platform().unwrap(); + assert_eq!(os, "linux"); + assert_eq!(arch, "arm"); + assert_eq!(variant, Some("v7".to_string())); } #[test] fn test_create_layered_config_preserves_base_environment() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -359,7 +383,7 @@ mod tests { #[test] fn test_create_layered_config_combines_diff_ids() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -382,7 +406,7 @@ mod tests { #[test] fn test_create_layered_config_combines_history() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -524,7 +548,7 @@ mod tests { #[test] fn test_create_layered_config_adds_path_if_missing() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path, "test-base".to_string(), @@ -546,7 +570,7 @@ mod tests { #[test] fn test_create_layered_config_sets_cmd() { - let binary_path = create_test_binary(); + let (binary_path, _guard) = create_test_binary(); let builder = ImageBuilder::new( binary_path.clone(), "test-base".to_string(), diff --git a/src/main.rs b/src/main.rs index 8f9a9dc..832e53d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,14 +5,14 @@ use krust::{ builder::{get_rust_target_triple, RustBuilder}, cli::{Cli, Commands}, config::Config, - image::ImageBuilder, + image::{parse_platform_string, ImageBuilder}, manifest::{ManifestDescriptor, Platform}, registry::RegistryClient, resolve::{find_krust_references, read_yaml_files, replace_krust_references}, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use tracing::{error, info}; +use tracing::info; use tracing_subscriber::EnvFilter; #[tokio::main] @@ -102,98 +102,17 @@ async fn main() -> Result<()> { let no_push_flag = no_push; let task = tokio::spawn(async move { - info!("Building for platform: {}", platform_str); + let descriptor = build_and_push_platform( + &project_path, + &base_image, + &target_repo, + &platform_str, + cargo_args, + !no_push_flag, + ) + .await?; - // Build the Rust binary for this platform - let target = get_rust_target_triple(&platform_str)?; - let builder = - RustBuilder::new(&project_path, &target).with_cargo_args(cargo_args); - - let build_result = builder.build()?; - - // Build container image for this platform - let image_builder = ImageBuilder::new( - build_result.binary_path, - base_image.clone(), - platform_str.clone(), - ); - - // Create a registry client for this task - let mut registry_client = RegistryClient::new()?; - - // Always use layered approach - registry layer will handle cross-registry blob copying - let base_auth = resolve_auth(&base_image)?; - let (config_data, layer_data, manifest) = image_builder - .build(&mut registry_client, &base_auth) - .await?; - - // Push platform-specific image if not --no-push - let manifest_descriptor = if !no_push_flag { - info!("Pushing image for platform: {}", platform_str); - - // Get auth for the target registry - let push_auth = resolve_auth(&target_repo)?; - - // Get the media type of the application layer (last layer in manifest) - let app_layer_media_type = manifest - .layers - .last() - .map(|l| l.media_type.clone()) - .unwrap_or_else(|| { - "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string() - }); - - // Push layered image by digest only (no tag) - // This will be referenced by digest in the final manifest list - let (digest_ref, manifest_size) = registry_client - .push_layered_image( - &target_repo, - config_data, - layer_data, - app_layer_media_type, - &manifest, - &push_auth, - &base_image, - &base_auth, - ) - .await?; - - // Parse platform string - let parts: Vec<&str> = platform_str.split('/').collect(); - let (os, arch) = if parts.len() >= 2 { - (parts[0].to_string(), parts[1].to_string()) - } else { - return Err(anyhow::anyhow!( - "Invalid platform format: {}", - platform_str - )); - }; - - // Extract just the digest from the full reference - let digest = digest_ref.split('@').next_back().unwrap_or("").to_string(); - - info!("Pushed platform image to: {}", digest_ref); - - // Return manifest descriptor - info!( - "Adding manifest to list - platform: {}/{}, digest: {}, size: {}", - os, arch, digest, manifest_size - ); - Some(ManifestDescriptor { - media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), - size: manifest_size as i64, - digest, - platform: Platform { - architecture: arch, - os, - variant: None, - }, - }) - } else { - None - }; - - Ok::<_, anyhow::Error>(manifest_descriptor) + Ok::<_, anyhow::Error>(descriptor) }); tasks.push(task); @@ -210,32 +129,16 @@ async fn main() -> Result<()> { // Always push manifest list if not --no-push (even for single platform) if !no_push { - info!("Creating and pushing manifest list..."); - - // Determine the target for the manifest list - let has_tag = tag.is_some(); - let manifest_target = if let Some(tag_name) = &tag { - // If --tag is specified, push to that tag - format!("{}:{}", target_repo, tag_name) - } else { - // If no tag specified, push digest-only (no tag) - target_repo.clone() - }; - - // Get auth for the final image push - let final_auth = resolve_auth(&manifest_target)?; - - let manifest_list_ref = registry_client - .push_manifest_list( - &manifest_target, - manifest_descriptors, - &final_auth, - has_tag, - ) - .await?; + let image_ref = push_tagged_manifest_list( + &mut registry_client, + &target_repo, + manifest_descriptors, + &tag, + ) + .await?; // Output the manifest list reference (always by digest) - println!("{}", manifest_list_ref); + println!("{}", image_ref); } else { info!( "Successfully built image for {} platform(s)", @@ -244,11 +147,6 @@ async fn main() -> Result<()> { info!("Skipping push (--no-push specified)"); } } - Commands::Push { image } => { - let _ = image; - error!("Push command not yet implemented"); - std::process::exit(1); - } Commands::Resolve { filenames, platform, @@ -306,6 +204,104 @@ async fn main() -> Result<()> { Ok(()) } +/// Build a binary and push an image for a single platform. +/// Returns a ManifestDescriptor if push is true, None otherwise. +async fn build_and_push_platform( + project_path: &Path, + base_image: &str, + target_repo: &str, + platform_str: &str, + cargo_args: Vec, + push: bool, +) -> Result> { + info!("Building for platform: {}", platform_str); + + // Build the Rust binary for this platform + let target = get_rust_target_triple(platform_str)?; + let builder = RustBuilder::new(project_path, &target).with_cargo_args(cargo_args); + let build_result = builder.build()?; + + // Build container image for this platform + let image_builder = ImageBuilder::new( + build_result.binary_path, + base_image.to_string(), + platform_str.to_string(), + ); + + // Create a registry client for this task + let mut registry_client = RegistryClient::new()?; + + let base_auth = resolve_auth(base_image)?; + let (config_data, layer_data, manifest) = image_builder + .build(&mut registry_client, &base_auth) + .await?; + + if !push { + return Ok(None); + } + + info!("Pushing image for platform: {}", platform_str); + + let push_auth = resolve_auth(target_repo)?; + let app_layer_media_type = manifest + .layers + .last() + .map(|l| l.media_type.clone()) + .unwrap_or_else(|| "application/vnd.oci.image.layer.v1.tar+gzip".to_string()); + + let (digest_ref, manifest_size) = registry_client + .push_layered_image( + target_repo, + config_data, + layer_data, + app_layer_media_type, + &manifest, + &push_auth, + base_image, + &base_auth, + ) + .await?; + + let (os, arch, variant) = parse_platform_string(platform_str)?; + let digest = digest_ref.split('@').next_back().unwrap_or("").to_string(); + + info!("Pushed platform image: {} ({})", digest_ref, platform_str); + + Ok(Some(ManifestDescriptor { + media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), + size: manifest_size as i64, + digest, + platform: Platform { + architecture: arch, + os, + variant, + }, + })) +} + +/// Push a manifest list, optionally tagged. +async fn push_tagged_manifest_list( + registry_client: &mut RegistryClient, + target_repo: &str, + manifest_descriptors: Vec, + tag: &Option, +) -> Result { + info!("Creating and pushing manifest list..."); + + let has_tag = tag.is_some(); + let manifest_target = if let Some(tag_name) = tag { + format!("{}:{}", target_repo, tag_name) + } else { + target_repo.to_string() + }; + + let final_auth = resolve_auth(&manifest_target)?; + + registry_client + .push_manifest_list(&manifest_target, manifest_descriptors, &final_auth, has_tag) + .await +} + /// Resolve krust:// references in YAML files async fn resolve_yaml_files( filenames: Vec, @@ -341,107 +337,50 @@ async fn resolve_yaml_files( for krust_path in all_references { info!("Building image for: krust://{}", krust_path); - // Resolve the path (could be relative like ./example/hello-krust) let project_path = PathBuf::from(&krust_path); - if !project_path.exists() { anyhow::bail!("Path does not exist: {}", krust_path); } - // Get project name let project_name = get_project_name(&project_path)?; let target_repo = format!("{}/{}", repo, project_name); - // Load project config let project_config = Config::load_project_config(&project_path)?; let base_image = project_config .base_image .unwrap_or(config.base_image.clone()); - // Determine platforms let platforms = if let Some(ref platforms) = platform { platforms.clone() } else { - // Default to linux/amd64 for resolve vec!["linux/amd64".to_string()] }; // Build for each platform let mut manifest_descriptors = Vec::new(); - for platform_str in &platforms { - info!("Building {} for platform: {}", krust_path, platform_str); - - let target = get_rust_target_triple(platform_str)?; - let builder = RustBuilder::new(&project_path, &target); - let build_result = builder.build()?; - - let image_builder = ImageBuilder::new( - build_result.binary_path, - base_image.clone(), - platform_str.clone(), - ); - - let base_auth = resolve_auth(&base_image)?; - let (config_data, layer_data, manifest) = image_builder - .build(&mut registry_client, &base_auth) - .await?; - - // Push the image - let push_auth = resolve_auth(&target_repo)?; - let app_layer_media_type = manifest - .layers - .last() - .map(|l| l.media_type.clone()) - .unwrap_or_else(|| "application/vnd.docker.image.rootfs.diff.tar.gzip".to_string()); - - let (digest_ref, manifest_size) = registry_client - .push_layered_image( - &target_repo, - config_data, - layer_data, - app_layer_media_type, - &manifest, - &push_auth, - &base_image, - &base_auth, - ) - .await?; - - // Parse platform - let parts: Vec<&str> = platform_str.split('/').collect(); - let (os, arch) = if parts.len() >= 2 { - (parts[0].to_string(), parts[1].to_string()) - } else { - return Err(anyhow::anyhow!("Invalid platform format: {}", platform_str)); - }; - - let digest = digest_ref.split('@').next_back().unwrap_or("").to_string(); - - manifest_descriptors.push(ManifestDescriptor { - media_type: "application/vnd.oci.image.manifest.v1+json".to_string(), - size: manifest_size as i64, - digest, - platform: Platform { - architecture: arch, - os, - variant: None, - }, - }); + if let Some(descriptor) = build_and_push_platform( + &project_path, + &base_image, + &target_repo, + platform_str, + Vec::new(), + true, + ) + .await? + { + manifest_descriptors.push(descriptor); + } } // Push manifest list - let has_tag = tag.is_some(); - let manifest_target = if let Some(tag_name) = &tag { - format!("{}:{}", target_repo, tag_name) - } else { - target_repo.clone() - }; - - let final_auth = resolve_auth(&manifest_target)?; - let image_ref = registry_client - .push_manifest_list(&manifest_target, manifest_descriptors, &final_auth, has_tag) - .await?; + let image_ref = push_tagged_manifest_list( + &mut registry_client, + &target_repo, + manifest_descriptors, + &tag, + ) + .await?; info!("Resolved krust://{} -> {}", krust_path, image_ref); replacements.insert(krust_path, image_ref); diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 8176833..c610c23 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -32,13 +32,7 @@ pub struct OciImageManifest { pub annotations: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Platform { - pub architecture: String, - pub os: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub variant: Option, -} +use crate::manifest::Platform; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageIndexEntry { @@ -185,20 +179,16 @@ impl ImageReference { pub struct RegistryClient { client: reqwest::Client, - #[allow(dead_code)] - auth_cache: HashMap, // registry -> token } impl RegistryClient { pub fn new() -> Result { - // Create two clients - one with redirects, one without let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(300)) .build()?; - Ok(Self { - client, - auth_cache: HashMap::new(), - }) + Ok(Self { client }) } /// Check if a blob exists in the registry using HEAD request @@ -385,10 +375,13 @@ impl RegistryClient { challenge.scope.clone() }; - let token_url = format!( - "{}?service={}&scope={}", - challenge.realm, challenge.service, scope - ); + let mut token_url = + reqwest::Url::parse(&challenge.realm).context("Invalid token realm URL")?; + token_url + .query_pairs_mut() + .append_pair("service", &challenge.service) + .append_pair("scope", &scope); + let token_url = token_url.to_string(); let response = self.client.get(&token_url).send().await?; @@ -418,10 +411,13 @@ impl RegistryClient { challenge.scope.clone() }; - let token_url = format!( - "{}?service={}&scope={}", - challenge.realm, challenge.service, scope - ); + let mut token_url = + reqwest::Url::parse(&challenge.realm).context("Invalid token realm URL")?; + token_url + .query_pairs_mut() + .append_pair("service", &challenge.service) + .append_pair("scope", &scope); + let token_url = token_url.to_string(); let auth_header = format!("{}:{}", username, password); let encoded_auth = base64::engine::general_purpose::STANDARD.encode(auth_header.as_bytes()); @@ -445,11 +441,23 @@ impl RegistryClient { } } - // Pull a manifest from the registry + // Pull a manifest from the registry, optionally filtering by platform + // when the manifest is an image index. pub async fn pull_manifest( &mut self, image_ref: &str, auth: &RegistryAuth, + ) -> Result<(OciImageManifest, String)> { + self.pull_manifest_for_platform(image_ref, auth, None).await + } + + // Pull a manifest from the registry, selecting the given platform from + // an image index if present. + async fn pull_manifest_for_platform( + &mut self, + image_ref: &str, + auth: &RegistryAuth, + platform: Option<&str>, ) -> Result<(OciImageManifest, String)> { debug!("Parsing image reference: {}", image_ref); let reference = ImageReference::parse(image_ref)?; @@ -476,7 +484,7 @@ impl RegistryClient { let mut req = self.client .get(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); + .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json"); if let Some(token) = token { req = req.header("Authorization", format!("Bearer {}", token)); @@ -499,58 +507,109 @@ impl RegistryClient { debug!("Manifest response body: {}", String::from_utf8_lossy(&body)); // Try to parse as either image manifest or image index - let manifest: OciImageManifest = if let Ok(image_manifest) = - serde_json::from_slice::(&body) - { - image_manifest - } else if let Ok(image_index) = serde_json::from_slice::(&body) { - // If it's an image index, we need to find the specific platform manifest - // For now, just take the first one (this should be enhanced to match platform) - if let Some(first_manifest) = image_index.manifests.first() { - // Pull the platform-specific manifest directly - let platform_digest = &first_manifest.digest; - let url = format!( - "https://{}/v2/{}/manifests/{}", - reference.registry, reference.repository, platform_digest - ); - - debug!("Pulling platform-specific manifest from URL: {}", url); - - let mut req = self.client - .get(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); - - // Re-authenticate for the platform-specific request - let platform_token = self - .authenticate(&reference.registry, &reference.repository, auth) - .await?; - if let Some(token) = platform_token { - req = req.header("Authorization", format!("Bearer {}", token)); + let manifest: OciImageManifest = + if let Ok(image_manifest) = serde_json::from_slice::(&body) { + // Check if this is actually an image index by looking at the media type + if image_manifest.media_type.contains("index") { + // Re-parse as index + let image_index: OciImageIndex = serde_json::from_slice(&body)?; + self.select_platform_manifest(&reference, &image_index, auth, platform) + .await? + } else { + image_manifest } - - let response = req.send().await?; - - if !response.status().is_success() { - anyhow::bail!("Failed to pull platform manifest: {}", response.status()); - } - - let platform_body = response.bytes().await?; - debug!( - "Platform manifest response body: {}", - String::from_utf8_lossy(&platform_body) - ); - - serde_json::from_slice::(&platform_body)? + } else if let Ok(image_index) = serde_json::from_slice::(&body) { + self.select_platform_manifest(&reference, &image_index, auth, platform) + .await? } else { - anyhow::bail!("Image index has no manifests"); - } - } else { - anyhow::bail!("Response is neither a valid image manifest nor image index"); - }; + anyhow::bail!("Response is neither a valid image manifest nor image index"); + }; Ok((manifest, digest)) } + /// Select and pull a platform-specific manifest from an image index. + async fn select_platform_manifest( + &mut self, + reference: &ImageReference, + image_index: &OciImageIndex, + auth: &RegistryAuth, + platform: Option<&str>, + ) -> Result { + let selected = if let Some(platform_str) = platform { + // Parse the requested platform + let parts: Vec<&str> = platform_str.split('/').collect(); + let (req_os, req_arch, req_variant) = match parts.len() { + 2 => (parts[0], parts[1], None), + 3 => (parts[0], parts[1], Some(parts[2])), + _ => anyhow::bail!("Invalid platform format: {}", platform_str), + }; + + // Find a matching manifest entry + image_index + .manifests + .iter() + .find(|entry| { + if let Some(p) = &entry.platform { + let os_match = p.os == req_os; + let arch_match = p.architecture == req_arch; + let variant_match = match req_variant { + Some(v) => p.variant.as_deref() == Some(v), + None => true, + }; + os_match && arch_match && variant_match + } else { + false + } + }) + .ok_or_else(|| { + anyhow::anyhow!( + "No manifest found for platform {} in image index", + platform_str + ) + })? + } else { + // No platform specified, take the first entry + image_index + .manifests + .first() + .ok_or_else(|| anyhow::anyhow!("Image index has no manifests"))? + }; + + let platform_digest = &selected.digest; + let url = format!( + "https://{}/v2/{}/manifests/{}", + reference.registry, reference.repository, platform_digest + ); + + debug!("Pulling platform-specific manifest from URL: {}", url); + + let mut req = self.client + .get(&url) + .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); + + let platform_token = self + .authenticate(&reference.registry, &reference.repository, auth) + .await?; + if let Some(token) = platform_token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + let response = req.send().await?; + + if !response.status().is_success() { + anyhow::bail!("Failed to pull platform manifest: {}", response.status()); + } + + let platform_body = response.bytes().await?; + debug!( + "Platform manifest response body: {}", + String::from_utf8_lossy(&platform_body) + ); + + Ok(serde_json::from_slice::(&platform_body)?) + } + // Pull a blob from the registry pub async fn pull_blob( &mut self, @@ -805,13 +864,13 @@ impl RegistryClient { anyhow::bail!("Failed to upload blob: {} - {}", monolithic_status, body) } - // Push a manifest to the registry + // Push a manifest to the registry, returns the digest string pub async fn push_manifest( &mut self, image_ref: &str, manifest: &OciImageManifest, auth: &RegistryAuth, - ) -> Result<(String, String)> { + ) -> Result { let reference = ImageReference::parse(image_ref)?; let manifest_json = serde_json::to_vec_pretty(manifest)?; let manifest_digest = format!("sha256:{}", sha256::digest(&manifest_json)); @@ -827,11 +886,7 @@ impl RegistryClient { .await? { debug!("Manifest {} already exists, skipping push", manifest_digest); - let digest_ref = format!( - "{}/{}@{}", - reference.registry, reference.repository, manifest_digest - ); - return Ok((digest_ref, manifest_digest)); + return Ok(manifest_digest); } info!("Pushing manifest with digest: {}", manifest_digest); @@ -871,16 +926,10 @@ impl RegistryClient { let digest = headers .get("docker-content-digest") .and_then(|h| h.to_str().ok()) - .unwrap_or("") + .unwrap_or(&manifest_digest) .to_string(); - let location = headers - .get("location") - .and_then(|h| h.to_str().ok()) - .unwrap_or(&url) - .to_string(); - - Ok((location, digest)) + Ok(digest) } // Legacy methods for compatibility with existing code @@ -929,7 +978,7 @@ impl RegistryClient { annotations: None, }; - let (_, digest) = self.push_manifest(repository, &manifest, auth).await?; + let digest = self.push_manifest(repository, &manifest, auth).await?; let reference = ImageReference::parse(repository)?; let digest_ref = format!("{}/{}@{}", reference.registry, reference.repository, digest); let manifest_size = serde_json::to_vec(&manifest)?.len(); @@ -940,10 +989,12 @@ impl RegistryClient { pub async fn fetch_image_data( &mut self, image_ref: &str, - _platform: &str, + platform: &str, auth: &RegistryAuth, ) -> Result<(OciImageManifest, crate::image::ImageConfig)> { - let (manifest, _digest) = self.pull_manifest(image_ref, auth).await?; + let (manifest, _digest) = self + .pull_manifest_for_platform(image_ref, auth, Some(platform)) + .await?; if let Some(config_descriptor) = &manifest.config { let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; @@ -956,12 +1007,64 @@ impl RegistryClient { pub async fn get_image_platforms( &mut self, - _image_ref: &str, - _auth: &RegistryAuth, + image_ref: &str, + auth: &RegistryAuth, ) -> Result> { - // For now, return default platforms - this would need to be enhanced - // to actually fetch and parse image indexes - Ok(vec!["linux/amd64".to_string(), "linux/arm64".to_string()]) + let reference = ImageReference::parse(image_ref)?; + let token = self + .authenticate(&reference.registry, &reference.repository, auth) + .await?; + + let manifest_ref = if let Some(digest) = &reference.digest { + digest.clone() + } else { + reference.tag.as_deref().unwrap_or("latest").to_string() + }; + + let url = format!( + "https://{}/v2/{}/manifests/{}", + reference.registry, reference.repository, manifest_ref + ); + + let mut req = self.client + .get(&url) + .header("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"); + + if let Some(token) = token { + req = req.header("Authorization", format!("Bearer {}", token)); + } + + let response = req.send().await?; + + if !response.status().is_success() { + anyhow::bail!( + "Failed to fetch manifest for platform detection: {}", + response.status() + ); + } + + let body = response.bytes().await?; + + // Try to parse as an image index + if let Ok(image_index) = serde_json::from_slice::(&body) { + let platforms: Vec = image_index + .manifests + .iter() + .filter_map(|entry| { + entry.platform.as_ref().map(|p| { + if let Some(variant) = &p.variant { + format!("{}/{}/{}", p.os, p.architecture, variant) + } else { + format!("{}/{}", p.os, p.architecture) + } + }) + }) + .collect(); + Ok(platforms) + } else { + // Single-platform image, no index available + Ok(vec![]) + } } /// Push a layered image where only the top layer is new @@ -1055,7 +1158,7 @@ impl RegistryClient { annotations: None, }; - let (_, digest) = self.push_manifest(repository, &oci_manifest, auth).await?; + let digest = self.push_manifest(repository, &oci_manifest, auth).await?; let digest_ref = format!( "{}/{}@{}", target_reference.registry, target_reference.repository, digest @@ -1092,11 +1195,7 @@ impl RegistryClient { media_type: m.media_type.clone(), digest: m.digest.clone(), size: m.size, - platform: Some(Platform { - architecture: m.platform.architecture.clone(), - os: m.platform.os.clone(), - variant: m.platform.variant.clone(), - }), + platform: Some(m.platform.clone()), annotations: None, }) .collect(); From 54519cd6edeb2a4bd8d741e51994eda4a4af366f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 19:02:20 +0000 Subject: [PATCH 6/8] Address PR #72 review comments - Fix digest mismatch: select_platform_manifest now returns the platform-specific digest from docker-content-digest header instead of reusing the index-level digest - Fix empty platform list: get_image_platforms fetches config blob for single-platform images to determine actual os/architecture - Fix missing Accept header: add Docker manifest list media type (application/vnd.docker.distribution.manifest.list.v2+json) to Accept headers for broader registry compatibility https://claude.ai/code/session_01RoAUcigxrM6Lz8DESr3SkW --- src/registry/mod.rs | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/registry/mod.rs b/src/registry/mod.rs index c610c23..bc975b3 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -484,7 +484,7 @@ impl RegistryClient { let mut req = self.client .get(&url) - .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json"); + .header("Accept", "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json"); if let Some(token) = token { req = req.header("Authorization", format!("Bearer {}", token)); @@ -496,7 +496,7 @@ impl RegistryClient { anyhow::bail!("Failed to pull manifest: {}", response.status()); } - let digest = response + let index_digest = response .headers() .get("docker-content-digest") .and_then(|h| h.to_str().ok()) @@ -507,16 +507,18 @@ impl RegistryClient { debug!("Manifest response body: {}", String::from_utf8_lossy(&body)); // Try to parse as either image manifest or image index - let manifest: OciImageManifest = + let (manifest, digest) = if let Ok(image_manifest) = serde_json::from_slice::(&body) { // Check if this is actually an image index by looking at the media type - if image_manifest.media_type.contains("index") { + if image_manifest.media_type.contains("index") + || image_manifest.media_type.contains("manifest.list") + { // Re-parse as index let image_index: OciImageIndex = serde_json::from_slice(&body)?; self.select_platform_manifest(&reference, &image_index, auth, platform) .await? } else { - image_manifest + (image_manifest, index_digest) } } else if let Ok(image_index) = serde_json::from_slice::(&body) { self.select_platform_manifest(&reference, &image_index, auth, platform) @@ -529,13 +531,14 @@ impl RegistryClient { } /// Select and pull a platform-specific manifest from an image index. + /// Returns the manifest and its digest (from the platform-specific response). async fn select_platform_manifest( &mut self, reference: &ImageReference, image_index: &OciImageIndex, auth: &RegistryAuth, platform: Option<&str>, - ) -> Result { + ) -> Result<(OciImageManifest, String)> { let selected = if let Some(platform_str) = platform { // Parse the requested platform let parts: Vec<&str> = platform_str.split('/').collect(); @@ -601,13 +604,23 @@ impl RegistryClient { anyhow::bail!("Failed to pull platform manifest: {}", response.status()); } + let platform_digest = response + .headers() + .get("docker-content-digest") + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string(); + let platform_body = response.bytes().await?; debug!( "Platform manifest response body: {}", String::from_utf8_lossy(&platform_body) ); - Ok(serde_json::from_slice::(&platform_body)?) + Ok(( + serde_json::from_slice::(&platform_body)?, + platform_digest, + )) } // Pull a blob from the registry @@ -1061,8 +1074,21 @@ impl RegistryClient { }) .collect(); Ok(platforms) + } else if let Ok(manifest) = serde_json::from_slice::(&body) { + // Single-platform image — read the config to determine its platform + if let Some(config_descriptor) = &manifest.config { + let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; + if let Ok(config) = + serde_json::from_slice::(&config_data) + { + Ok(vec![format!("{}/{}", config.os, config.architecture)]) + } else { + Ok(vec![]) + } + } else { + Ok(vec![]) + } } else { - // Single-platform image, no index available Ok(vec![]) } } From 871ac2fb8f56d9533797c70254ae22a60afd6245 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 19:14:39 +0000 Subject: [PATCH 7/8] Address second round of PR #72 review comments - Deduplicate platform parsing: select_platform_manifest now uses crate::image::parse_platform_string instead of reimplementing the split/match logic inline - Fix empty digest fallback: when docker-content-digest header is missing, compute sha256 digest from the response body instead of using an empty string - Surface parse failures: get_image_platforms now returns errors instead of silently returning Ok(vec![]) when config parsing fails or the response is an unrecognized format https://claude.ai/code/session_01RoAUcigxrM6Lz8DESr3SkW --- src/registry/mod.rs | 56 ++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/registry/mod.rs b/src/registry/mod.rs index bc975b3..1ade6fd 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -496,14 +496,17 @@ impl RegistryClient { anyhow::bail!("Failed to pull manifest: {}", response.status()); } - let index_digest = response + let header_digest = response .headers() .get("docker-content-digest") .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()); let body = response.bytes().await?; + + // Use header digest if available, otherwise compute from body + let index_digest = + header_digest.unwrap_or_else(|| format!("sha256:{}", sha256::digest(body.as_ref()))); debug!("Manifest response body: {}", String::from_utf8_lossy(&body)); // Try to parse as either image manifest or image index @@ -540,13 +543,9 @@ impl RegistryClient { platform: Option<&str>, ) -> Result<(OciImageManifest, String)> { let selected = if let Some(platform_str) = platform { - // Parse the requested platform - let parts: Vec<&str> = platform_str.split('/').collect(); - let (req_os, req_arch, req_variant) = match parts.len() { - 2 => (parts[0], parts[1], None), - 3 => (parts[0], parts[1], Some(parts[2])), - _ => anyhow::bail!("Invalid platform format: {}", platform_str), - }; + // Parse the requested platform using the shared parser + let (req_os, req_arch, req_variant) = + crate::image::parse_platform_string(platform_str)?; // Find a matching manifest entry image_index @@ -556,8 +555,8 @@ impl RegistryClient { if let Some(p) = &entry.platform { let os_match = p.os == req_os; let arch_match = p.architecture == req_arch; - let variant_match = match req_variant { - Some(v) => p.variant.as_deref() == Some(v), + let variant_match = match &req_variant { + Some(v) => p.variant.as_deref() == Some(v.as_str()), None => true, }; os_match && arch_match && variant_match @@ -604,14 +603,17 @@ impl RegistryClient { anyhow::bail!("Failed to pull platform manifest: {}", response.status()); } - let platform_digest = response + let header_digest = response .headers() .get("docker-content-digest") .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()); let platform_body = response.bytes().await?; + + // Use header digest if available, otherwise compute from body + let platform_digest = header_digest + .unwrap_or_else(|| format!("sha256:{}", sha256::digest(platform_body.as_ref()))); debug!( "Platform manifest response body: {}", String::from_utf8_lossy(&platform_body) @@ -1076,20 +1078,18 @@ impl RegistryClient { Ok(platforms) } else if let Ok(manifest) = serde_json::from_slice::(&body) { // Single-platform image — read the config to determine its platform - if let Some(config_descriptor) = &manifest.config { - let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; - if let Ok(config) = - serde_json::from_slice::(&config_data) - { - Ok(vec![format!("{}/{}", config.os, config.architecture)]) - } else { - Ok(vec![]) - } - } else { - Ok(vec![]) - } + let config_descriptor = manifest.config.as_ref().ok_or_else(|| { + anyhow::anyhow!("Single-platform manifest has no config descriptor") + })?; + let config_data = self.pull_blob(image_ref, config_descriptor, auth).await?; + let config = serde_json::from_slice::(&config_data) + .context("Failed to parse image config for platform detection")?; + Ok(vec![format!("{}/{}", config.os, config.architecture)]) } else { - Ok(vec![]) + anyhow::bail!( + "Response is neither a valid image index nor image manifest; \ + cannot detect platforms" + ) } } From fba01253e5c7bca42319a4e3687bac4d713c4417 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 19:38:16 +0000 Subject: [PATCH 8/8] Add builder tests for --bin/--example args and get_binary_subdir cargo-mutants revealed that get_binary_name's --bin/--example argument parsing and get_binary_subdir had zero test coverage. These new tests reduce missed mutants in the builder module from 34 to 10 (the remaining 10 are in RustBuilder::build which runs cargo and can't be unit tested). https://claude.ai/code/session_01W3QJhFvej6fS1GevQm2vp7 --- src/builder/tests.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/builder/tests.rs b/src/builder/tests.rs index cd6c3a4..ae13c03 100644 --- a/src/builder/tests.rs +++ b/src/builder/tests.rs @@ -108,4 +108,75 @@ version = "0.1.0" assert_eq!(builder.cargo_args, vec!["--features", "foo"]); } + + #[test] + fn test_get_binary_name_with_bin_arg() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--bin".to_string(), "my-binary".to_string()]); + let name = builder.get_binary_name().unwrap(); + assert_eq!(name, "my-binary"); + } + + #[test] + fn test_get_binary_name_with_example_arg() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--example".to_string(), "my-example".to_string()]); + let name = builder.get_binary_name().unwrap(); + assert_eq!(name, "my-example"); + } + + #[test] + fn test_get_binary_name_bin_arg_at_end_without_value() { + let dir = tempdir().unwrap(); + // --bin at end with no following value should fall through to Cargo.toml + let cargo_toml = dir.path().join("Cargo.toml"); + fs::write( + &cargo_toml, + r#" +[package] +name = "fallback-name" +version = "0.1.0" +"#, + ) + .unwrap(); + + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--bin".to_string()]); + let name = builder.get_binary_name().unwrap(); + assert_eq!(name, "fallback-name"); + } + + #[test] + fn test_get_binary_subdir_with_example() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--example".to_string(), "my-example".to_string()]); + assert_eq!(builder.get_binary_subdir(), Some("examples")); + } + + #[test] + fn test_get_binary_subdir_without_example() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl"); + assert_eq!(builder.get_binary_subdir(), None); + } + + #[test] + fn test_get_binary_subdir_with_bin() { + let dir = tempdir().unwrap(); + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--bin".to_string(), "my-bin".to_string()]); + assert_eq!(builder.get_binary_subdir(), None); + } + + #[test] + fn test_get_binary_subdir_example_at_end_without_value() { + let dir = tempdir().unwrap(); + // --example at end with no value should not match (needs i+1 < len) + let builder = RustBuilder::new(dir.path(), "x86_64-unknown-linux-musl") + .with_cargo_args(vec!["--example".to_string()]); + assert_eq!(builder.get_binary_subdir(), None); + } }