diff --git a/crates/iii-worker/src/cli/binary_download.rs b/crates/iii-worker/src/cli/binary_download.rs index da2f6fda8..7467d58bd 100644 --- a/crates/iii-worker/src/cli/binary_download.rs +++ b/crates/iii-worker/src/cli/binary_download.rs @@ -6,7 +6,7 @@ //! Binary worker download, checksum verification, and installation. -use super::registry::{validate_repo, validate_worker_name}; +use super::registry::validate_worker_name; use sha2::{Digest, Sha256}; use std::io::Read as _; use std::path::PathBuf; @@ -67,43 +67,6 @@ pub fn archive_extension(target: &str) -> &'static str { } } -/// Constructs the GitHub Releases download URL for a binary worker archive. -/// -/// Format: `https://github.com/{repo}/releases/download/{tag_prefix}/v{version}/{worker_name}-{target}.{ext}` -pub fn binary_download_url( - repo: &str, - tag_prefix: &str, - version: &str, - worker_name: &str, - target: &str, -) -> String { - format!( - "https://github.com/{}/releases/download/{}/v{}/{}-{}.{}", - repo, - tag_prefix, - version, - worker_name, - target, - archive_extension(target) - ) -} - -/// Constructs the GitHub Releases download URL for the SHA256 checksum file. -/// -/// Format: `https://github.com/{repo}/releases/download/{tag_prefix}/v{version}/{worker_name}-{target}.sha256` -pub fn checksum_download_url( - repo: &str, - tag_prefix: &str, - version: &str, - worker_name: &str, - target: &str, -) -> String { - format!( - "https://github.com/{}/releases/download/{}/v{}/{}-{}.sha256", - repo, tag_prefix, version, worker_name, target - ) -} - /// Extracts a named binary from a tar.gz archive. /// /// Looks for an entry whose filename matches `binary_name` (ignoring directory prefixes). @@ -167,41 +130,23 @@ pub fn verify_sha256(data: &[u8], checksum_content: &str) -> Result<(), String> } } -/// Downloads a binary worker from GitHub Releases, optionally verifies its checksum, -/// and installs it to `~/.iii/workers/{worker_name}`. +/// Downloads a binary worker from the URL provided by the API, verifies its +/// SHA256 checksum, and installs it to `~/.iii/workers/{worker_name}`. /// /// Returns the path to the installed binary on success. pub async fn download_and_install_binary( worker_name: &str, - repo: &str, - tag_prefix: &str, - version: &str, - supported_targets: &[String], - has_checksum: bool, + binary_info: &super::registry::BinaryInfo, ) -> Result { validate_worker_name(worker_name)?; - validate_repo(repo)?; - let target = current_target(); - // Check platform support when a whitelist is provided. - if !supported_targets.is_empty() && !supported_targets.iter().any(|t| t == target) { - return Err(format!( - "Platform '{}' is not supported for worker '{}'. Supported targets: {}", - target, - worker_name, - supported_targets.join(", ") - )); - } - - let url = binary_download_url(repo, tag_prefix, version, worker_name, target); - - tracing::debug!("Downloading from {}", url); + tracing::debug!("Downloading from {}", binary_info.url); let client = &super::registry::HTTP_CLIENT; // Download binary. let resp = client - .get(&url) + .get(&binary_info.url) .send() .await .map_err(|e| format!("Failed to download binary: {}", e))?; @@ -235,32 +180,8 @@ pub async fn download_and_install_binary( )); } - // Optionally verify checksum. - if has_checksum { - let checksum_url = checksum_download_url(repo, tag_prefix, version, worker_name, target); - tracing::debug!("Verifying checksum from {}", checksum_url); - - let checksum_resp = client - .get(&checksum_url) - .send() - .await - .map_err(|e| format!("Failed to download checksum: {}", e))?; - - if !checksum_resp.status().is_success() { - return Err(format!( - "Checksum verification required but checksum file unavailable (HTTP {}). \ - Refusing to install without verification.", - checksum_resp.status() - )); - } - - let checksum_content = checksum_resp - .text() - .await - .map_err(|e| format!("Failed to read checksum: {}", e))?; - - verify_sha256(&binary_data, &checksum_content)?; - } + // Verify checksum (always — the API provides sha256 for every binary). + verify_sha256(&binary_data, &binary_info.sha256)?; // Extract binary from archive. let extracted = extract_binary_from_targz(worker_name, &binary_data)?; @@ -331,51 +252,6 @@ mod tests { assert!(!target.is_empty(), "current_target() should not be empty"); } - #[test] - fn test_binary_download_url_format() { - let url = binary_download_url( - "iii-hq/workers", - "image-resize", - "0.1.2", - "image-resize", - "aarch64-apple-darwin", - ); - assert_eq!( - url, - "https://github.com/iii-hq/workers/releases/download/image-resize/v0.1.2/image-resize-aarch64-apple-darwin.tar.gz" - ); - } - - #[test] - fn test_binary_download_url_windows() { - let url = binary_download_url( - "iii-hq/workers", - "image-resize", - "0.1.2", - "image-resize", - "x86_64-pc-windows-msvc", - ); - assert_eq!( - url, - "https://github.com/iii-hq/workers/releases/download/image-resize/v0.1.2/image-resize-x86_64-pc-windows-msvc.zip" - ); - } - - #[test] - fn test_checksum_download_url_format() { - let url = checksum_download_url( - "iii-hq/workers", - "image-resize", - "0.1.2", - "image-resize", - "aarch64-apple-darwin", - ); - assert_eq!( - url, - "https://github.com/iii-hq/workers/releases/download/image-resize/v0.1.2/image-resize-aarch64-apple-darwin.sha256" - ); - } - #[test] fn test_verify_sha256_valid() { // SHA256 of "hello world" diff --git a/crates/iii-worker/src/cli/managed.rs b/crates/iii-worker/src/cli/managed.rs index a09cb0970..6734b965e 100644 --- a/crates/iii-worker/src/cli/managed.rs +++ b/crates/iii-worker/src/cli/managed.rs @@ -13,123 +13,70 @@ use super::builtin_defaults::get_builtin_default; use super::config_file::ResolvedWorkerType; use super::lifecycle::build_container_spec; use super::registry::{ - MANIFEST_PATH, RegistryV2, WorkerType, fetch_registry, parse_worker_input, resolve_image, + BinaryWorkerResponse, MANIFEST_PATH, WorkerInfoResponse, fetch_worker_info, parse_worker_input, }; use super::worker_manager::state::WorkerDef; pub use super::local_worker::{handle_local_add, is_local_path, start_local_worker}; pub async fn handle_binary_add( - input: &str, + worker_name: &str, + response: &BinaryWorkerResponse, brief: bool, - cached_registry: Option<&RegistryV2>, ) -> i32 { - let (worker_name, version_override) = parse_worker_input(input); + let target = binary_download::current_target(); if !brief { - eprintln!(" Resolving {}...", worker_name.bold()); + eprintln!(" {} Resolved to binary v{}", "✓".green(), response.version); } - let fetched; - let registry = if let Some(r) = cached_registry { - r - } else { - fetched = match fetch_registry().await { - Ok(r) => r, - Err(e) => { - eprintln!("{} {}", "error:".red(), e); - return 1; - } - }; - &fetched - }; - let entry = match registry.workers.get(&worker_name) { - Some(e) => e, - None => { + // If the worker is already running, skip download entirely + if is_worker_running(worker_name) { + if !brief { eprintln!( - "{} Worker '{}' not found in registry", - "error:".red(), - worker_name + "\n {} Worker {} already running, skipping download", + "✓".green(), + worker_name.bold(), ); - return 1; } - }; + return 0; + } - let repo = match &entry.repo { - Some(r) => r.clone(), + let binary_info = match response.binaries.get(target) { + Some(info) => info, None => { eprintln!( - "{} Registry entry for '{}' is missing 'repo' field", + "{} Platform '{}' is not supported for worker '{}'. Available: {}", "error:".red(), - worker_name + target, + worker_name, + response + .binaries + .keys() + .cloned() + .collect::>() + .join(", ") ); return 1; } }; - let tag_prefix = match &entry.tag_prefix { - Some(t) => t.clone(), - None => worker_name.clone(), - }; - - let version = version_override - .or_else(|| entry.version.clone()) - .unwrap_or_else(|| "latest".to_string()); - - let supported_targets = entry.supported_targets.clone().unwrap_or_default(); - let has_checksum = entry.has_checksum.unwrap_or(false); - - let target = binary_download::current_target(); - if !brief { - eprintln!( - " {} Resolved to {} (binary v{})", - "✓".green(), - repo.to_string().dimmed(), - version - ); - } - - // If the worker is already running, skip download entirely - if is_worker_running(&worker_name) { - if !brief { - eprintln!( - "\n {} Worker {} already running, skipping download", - "✓".green(), - worker_name.bold(), - ); - } - return 0; - } - if !brief { eprintln!(" Downloading {}...", worker_name.bold()); } - let install_path = match binary_download::download_and_install_binary( - &worker_name, - &repo, - &tag_prefix, - &version, - &supported_targets, - has_checksum, - ) - .await - { - Ok(path) => path, - Err(e) => { - eprintln!("{} {}", "error:".red(), e); - return 1; - } - }; + let install_path = + match binary_download::download_and_install_binary(worker_name, binary_info).await { + Ok(path) => path, + Err(e) => { + eprintln!("{} {}", "error:".red(), e); + return 1; + } + }; if !brief { eprintln!(" {} Downloaded successfully", "✓".green()); - - // Show metadata matching OCI worker style eprintln!(" {}: {}", "Name".bold(), worker_name); - eprintln!(" {}: {}", "Version".bold(), version); - if !entry.description.is_empty() { - eprintln!(" {}: {}", "Description".bold(), entry.description); - } + eprintln!(" {}: {}", "Version".bold(), response.version); eprintln!(" {}: {}", "Platform".bold(), target); if let Ok(metadata) = std::fs::metadata(&install_path) { eprintln!( @@ -140,13 +87,13 @@ pub async fn handle_binary_add( } } - let config_yaml = entry - .default_config - .as_ref() - .and_then(|dc| dc.get("config")) - .map(|v| serde_yaml::to_string(v).unwrap_or_default()); + let config_yaml = response + .config + .config + .as_object() + .map(|_| serde_yaml::to_string(&response.config.config).unwrap_or_default()); - if let Err(e) = super::config_file::append_worker(&worker_name, config_yaml.as_deref()) { + if let Err(e) = super::config_file::append_worker(worker_name, config_yaml.as_deref()) { eprintln!("{} {}", "error:".red(), e); return 1; } @@ -161,12 +108,11 @@ pub async fn handle_binary_add( "config.yaml".dimmed(), ); - // Auto-start if engine is running (skip if already running) if is_engine_running() { - if is_worker_running(&worker_name) { + if is_worker_running(worker_name) { eprintln!(" {} Worker already running", "✓".green()); } else { - let result = start_binary_worker(&worker_name, &install_path).await; + let result = start_binary_worker(worker_name, &install_path).await; if result == 0 { eprintln!(" {} Worker auto-started", "✓".green()); } else { @@ -189,14 +135,11 @@ pub async fn handle_managed_add_many(worker_names: &[String]) -> i32 { let brief = total > 1; let mut fail_count = 0; - // Pre-fetch registry once for all workers (avoids N HTTP roundtrips). - let registry = fetch_registry().await.ok(); - for (i, name) in worker_names.iter().enumerate() { if brief { eprintln!(" [{}/{}] Adding {}...", i + 1, total, name.bold()); } - let result = handle_managed_add(name, brief, registry.as_ref(), false, false).await; + let result = handle_managed_add(name, brief, false, false).await; if result != 0 { fail_count += 1; } @@ -220,12 +163,10 @@ pub async fn handle_managed_add_many(worker_names: &[String]) -> i32 { pub async fn handle_managed_add( image_or_name: &str, brief: bool, - cached_registry: Option<&RegistryV2>, force: bool, reset_config: bool, ) -> i32 { // Local path workers: starts with '.', '/', or '~' - // Must be checked before force-mode processing since validate_worker_name rejects paths. if super::local_worker::is_local_path(image_or_name) { return super::local_worker::handle_local_add(image_or_name, force, reset_config, brief) .await; @@ -233,10 +174,8 @@ pub async fn handle_managed_add( // --force: delete existing artifacts before re-downloading if force { - // Extract plain name (strip @version if present) - let (plain_name, _) = super::registry::parse_worker_input(image_or_name); + let (plain_name, _) = parse_worker_input(image_or_name); - // Validate name only for non-OCI references (OCI refs contain '/' or ':') let is_oci_ref = plain_name.contains('/') || plain_name.contains(':'); if !is_oci_ref { if let Err(e) = super::registry::validate_worker_name(&plain_name) { @@ -255,14 +194,12 @@ pub async fn handle_managed_add( return 1; } - // Check for engine-builtin workers — no artifacts to delete if super::builtin_defaults::get_builtin_default(&plain_name).is_some() { eprintln!( " {} '{}' is a builtin worker, no artifacts to re-download.", "info:".cyan(), plain_name, ); - // Still proceed — force on builtins just re-applies config } else { let freed = delete_worker_artifacts(&plain_name); if freed > 0 { @@ -277,65 +214,79 @@ pub async fn handle_managed_add( if reset_config { match super::config_file::remove_worker(&plain_name) { - Ok(()) => { - eprintln!(" {} Config for {} reset", "✓".green(), plain_name.bold(),); - } + Ok(()) => {} Err(e) => { - eprintln!( - " {} Could not reset config for {}: {}", - "warning:".yellow(), - plain_name.bold(), - e, - ); + tracing::debug!("remove_worker during force: {}", e); } } } } + // Direct OCI reference (contains '/' or ':') — passthrough, skip API + if image_or_name.contains('/') || image_or_name.contains(':') { + if !brief { + eprintln!(" Resolving {}...", image_or_name.bold()); + } + let name = image_or_name + .rsplit('/') + .next() + .unwrap_or(image_or_name) + .split(':') + .next() + .unwrap_or(image_or_name); + if !brief { + eprintln!(" {} Resolved to {}", "✓".green(), image_or_name.dimmed()); + } + return handle_oci_pull_and_add(name, image_or_name, brief).await; + } + + // Shorthand name — resolve via API + let (name, version) = parse_worker_input(image_or_name); + // Check for engine-builtin workers first (no network needed). - if let Some(default_yaml) = get_builtin_default(image_or_name) { - let already_exists = super::config_file::worker_exists(image_or_name); - if let Err(e) = super::config_file::append_worker(image_or_name, Some(default_yaml)) { + if let Some(default_yaml) = get_builtin_default(&name) { + let already_exists = super::config_file::worker_exists(&name); + if let Err(e) = super::config_file::append_worker(&name, Some(default_yaml)) { eprintln!("{} {}", "error:".red(), e); return 1; } if brief { if already_exists { - eprintln!(" {} {} (updated)", "✓".green(), image_or_name.bold()); + eprintln!(" {} {} (updated)", "✓".green(), name.bold()); } else { - eprintln!(" {} {}", "✓".green(), image_or_name.bold()); + eprintln!(" {} {}", "✓".green(), name.bold()); } } else { if already_exists { eprintln!( "\n {} Worker {} updated in {} (merged with builtin defaults)", "✓".green(), - image_or_name.bold(), + name.bold(), "config.yaml".dimmed(), ); } else { eprintln!( "\n {} Worker {} added to {}", "✓".green(), - image_or_name.bold(), + name.bold(), "config.yaml".dimmed(), ); } // Auto-start if engine is running (skip if already running) if is_engine_running() { - if is_worker_running(image_or_name) { + if is_worker_running(&name) { eprintln!(" {} Worker already running", "✓".green()); } else { let port = super::app::DEFAULT_PORT; - let result = handle_managed_start(image_or_name, "0.0.0.0", port).await; + let result = handle_managed_start(&name, "0.0.0.0", port).await; if result == 0 { eprintln!(" {} Worker auto-started", "✓".green()); } else { eprintln!( " {} Could not auto-start worker. Run `iii worker start {}` manually.", "⚠".yellow(), - image_or_name + name ); } } @@ -346,48 +297,27 @@ pub async fn handle_managed_add( return 0; } - // Route binary workers to handle_binary_add; for OCI workers found in the - // registry, use the cached registry or fetch once if not provided. - if !image_or_name.contains('/') && !image_or_name.contains(':') { - let (name, _) = parse_worker_input(image_or_name); - let fetched; - let registry = if let Some(r) = cached_registry { - Some(r) - } else { - fetched = fetch_registry().await.ok(); - fetched.as_ref() - }; - if let Some(registry) = registry - && let Some(entry) = registry.workers.get(&name) - { - if matches!(entry.worker_type, Some(WorkerType::Binary)) { - return handle_binary_add(image_or_name, brief, Some(registry)).await; - } - // OCI worker found in registry — use already-fetched entry - if let (Some(img), Some(ver)) = (&entry.image, &entry.latest) { - let image_ref = format!("{}:{}", img, ver); - if !brief { - eprintln!(" {} Resolved to {}", "✓".green(), image_ref.dimmed()); - } - return handle_oci_pull_and_add(&name, &image_ref, brief).await; - } - } - } - if !brief { - eprintln!(" Resolving {}...", image_or_name.bold()); + eprintln!(" Resolving {}...", name.bold()); } - let (image_ref, name) = match resolve_image(image_or_name).await { - Ok(v) => v, + + let response = match fetch_worker_info(&name, version.as_deref()).await { + Ok(r) => r, Err(e) => { eprintln!("{} {}", "error:".red(), e); return 1; } }; - if !brief { - eprintln!(" {} Resolved to {}", "✓".green(), image_ref.dimmed()); + + match response { + WorkerInfoResponse::Binary(r) => handle_binary_add(&name, &r, brief).await, + WorkerInfoResponse::Oci(r) => { + if !brief { + eprintln!(" {} Resolved to {}", "✓".green(), r.image_url.dimmed()); + } + handle_oci_pull_and_add(&r.name, &r.image_url, brief).await + } } - handle_oci_pull_and_add(&name, &image_ref, brief).await } async fn handle_oci_pull_and_add(name: &str, image_ref: &str, brief: bool) -> i32 { @@ -1008,86 +938,58 @@ pub async fn handle_managed_start(worker_name: &str, _address: &str, port: u16) " Worker '{}' not found locally, checking registry...", worker_name ); - match fetch_registry().await { - Ok(registry) => { - if let Some(entry) = registry.workers.get(worker_name) { - if matches!(entry.worker_type, Some(WorkerType::Binary)) { - // Auto-download binary worker - let repo = match &entry.repo { - Some(r) => r.clone(), - None => { - eprintln!( - "{} Registry entry for '{}' missing 'repo' field", - "error:".red(), - worker_name - ); - return 1; - } - }; - let tag_prefix = entry - .tag_prefix - .clone() - .unwrap_or_else(|| worker_name.to_string()); - let version = entry - .version - .clone() - .or_else(|| entry.latest.clone()) - .unwrap_or_else(|| "latest".to_string()); - let supported_targets = entry.supported_targets.clone().unwrap_or_default(); - let has_checksum = entry.has_checksum.unwrap_or(false); - - eprintln!(" Installing {} (binary v{})...", worker_name, version); - match binary_download::download_and_install_binary( + match fetch_worker_info(worker_name, None).await { + Ok(WorkerInfoResponse::Binary(response)) => { + let target = binary_download::current_target(); + let binary_info = match response.binaries.get(target) { + Some(info) => info, + None => { + eprintln!( + "{} Platform '{}' not supported for '{}'. Available: {}", + "error:".red(), + target, worker_name, - &repo, - &tag_prefix, - &version, - &supported_targets, - has_checksum, - ) - .await - { - Ok(installed_path) => { - eprintln!(" {} Installed successfully", "✓".green()); - return start_binary_worker(worker_name, &installed_path).await; - } - Err(e) => { - eprintln!( - "{} Failed to install '{}': {}", - "error:".red(), - worker_name, - e - ); - return 1; - } - } - } else { - // OCI/managed worker from registry — resolve image and start - let image_ref = match &entry.image { - Some(img) => { - let version = entry.latest.as_deref().unwrap_or("latest"); - format!("{}:{}", img, version) - } - None => { - eprintln!( - "{} Registry entry for '{}' missing 'image' field", - "error:".red(), - worker_name - ); - return 1; - } - }; - let worker_def = WorkerDef::Managed { - image: image_ref, - env: std::collections::HashMap::new(), - resources: None, - }; - return start_oci_worker(worker_name, &worker_def, port).await; + response + .binaries + .keys() + .cloned() + .collect::>() + .join(", ") + ); + return 1; + } + }; + + eprintln!( + " Installing {} (binary v{})...", + worker_name, response.version + ); + match binary_download::download_and_install_binary(worker_name, binary_info).await { + Ok(installed_path) => { + eprintln!(" {} Installed successfully", "✓".green()); + return start_binary_worker(worker_name, &installed_path).await; + } + Err(e) => { + eprintln!( + "{} Failed to install '{}': {}", + "error:".red(), + worker_name, + e + ); + return 1; } } } + Ok(WorkerInfoResponse::Oci(response)) => { + let worker_def = WorkerDef::Managed { + image: response.image_url, + env: std::collections::HashMap::new(), + resources: None, + }; + return start_oci_worker(worker_name, &worker_def, port).await; + } Err(e) => { - tracing::warn!("Failed to fetch registry: {}", e); + tracing::warn!("Failed to fetch worker info: {}", e); } } @@ -1912,7 +1814,7 @@ mod tests { std::fs::write(proj.join("package.json"), r#"{"name":"test"}"#).unwrap(); let path_str = proj.to_string_lossy().to_string(); - let exit_code = handle_managed_add(&path_str, false, None, false, false).await; + let exit_code = handle_managed_add(&path_str, false, false, false).await; assert_eq!(exit_code, 0, "should succeed for valid local path"); let content = std::fs::read_to_string("config.yaml").unwrap(); @@ -1934,7 +1836,7 @@ mod tests { async fn handle_managed_add_local_path_rejects_nonexistent() { in_temp_dir_async(|_dir| async move { let exit_code = - handle_managed_add("./nonexistent-path-12345", false, None, false, false).await; + handle_managed_add("./nonexistent-path-12345", false, false, false).await; assert_eq!(exit_code, 1, "should fail for nonexistent local path"); }) .await; @@ -1951,7 +1853,7 @@ mod tests { let path_str = proj.to_string_lossy().to_string(); // First add - let exit_code = handle_managed_add(&path_str, false, None, false, false).await; + let exit_code = handle_managed_add(&path_str, false, false, false).await; assert_eq!(exit_code, 0); assert!( std::fs::read_to_string("config.yaml") @@ -1960,7 +1862,7 @@ mod tests { ); // Force re-add - let exit_code = handle_managed_add(&path_str, false, None, true, false).await; + let exit_code = handle_managed_add(&path_str, false, true, false).await; assert_eq!(exit_code, 0, "force re-add should succeed"); let content = std::fs::read_to_string("config.yaml").unwrap(); diff --git a/crates/iii-worker/src/cli/registry.rs b/crates/iii-worker/src/cli/registry.rs index 95d26a916..dff563c62 100644 --- a/crates/iii-worker/src/cli/registry.rs +++ b/crates/iii-worker/src/cli/registry.rs @@ -12,8 +12,7 @@ use std::sync::LazyLock; pub const MANIFEST_PATH: &str = "/iii/worker.yaml"; -const DEFAULT_REGISTRY_URL: &str = - "https://raw.githubusercontent.com/iii-hq/workers/main/registry/index.json"; +const DEFAULT_API_URL: &str = "https://api.workers.iii.dev"; /// Shared HTTP client for registry and download operations. /// Reuses connections and TLS sessions across requests. @@ -24,38 +23,40 @@ pub(crate) static HTTP_CLIENT: LazyLock = LazyLock::new(|| { .expect("Failed to create HTTP client") }); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum WorkerType { - Binary, - Managed, +#[derive(Debug, Clone, Deserialize)] +pub struct BinaryInfo { + pub url: String, + pub sha256: String, } -#[derive(Debug, Deserialize)] -pub struct RegistryV2Entry { - #[allow(dead_code)] - pub description: String, - #[serde(rename = "type")] - pub worker_type: Option, - - // OCI/managed fields (backward compat) - pub image: Option, - pub latest: Option, - - // Binary worker fields - pub repo: Option, - pub tag_prefix: Option, - pub supported_targets: Option>, - pub has_checksum: Option, - pub default_config: Option, - pub version: Option, +#[derive(Debug, Clone, Deserialize)] +pub struct WorkerConfig { + pub name: String, + pub config: serde_json::Value, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BinaryWorkerResponse { + pub name: String, + pub version: String, + pub binaries: HashMap, + pub config: WorkerConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OciWorkerResponse { + pub name: String, + pub version: String, + pub image_url: String, } #[derive(Debug, Deserialize)] -pub struct RegistryV2 { - #[allow(dead_code)] - pub version: u32, - pub workers: HashMap, +#[serde(tag = "type")] +pub enum WorkerInfoResponse { + #[serde(rename = "binary")] + Binary(BinaryWorkerResponse), + #[serde(rename = "image")] + Oci(OciWorkerResponse), } /// Validates that a worker name is safe for use in filesystem paths and YAML content. @@ -79,26 +80,6 @@ pub fn validate_worker_name(name: &str) -> Result<(), String> { Ok(()) } -/// Validates that a repo string matches the `owner/repo` format with no traversal. -pub fn validate_repo(repo: &str) -> Result<(), String> { - let parts: Vec<&str> = repo.split('/').collect(); - if parts.len() != 2 { - return Err(format!( - "Invalid repo format '{}': expected 'owner/repo'", - repo - )); - } - for part in &parts { - if part.is_empty() || part.contains("..") { - return Err(format!( - "Invalid repo format '{}': segments must be non-empty and cannot contain '..'", - repo - )); - } - } - Ok(()) -} - /// Parse "name@version" into (name, Some(version)) or just (name, None). pub fn parse_worker_input(input: &str) -> (String, Option) { if let Some((name, version)) = input.split_once('@') { @@ -108,72 +89,53 @@ pub fn parse_worker_input(input: &str) -> (String, Option) { } } -pub async fn fetch_registry() -> Result { - let url = - std::env::var("III_REGISTRY_URL").unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string()); +pub async fn fetch_worker_info( + name: &str, + version: Option<&str>, +) -> Result { + validate_worker_name(name)?; + + let base_or_file = std::env::var("III_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()); - let body = if url.starts_with("file://") { + let body = if base_or_file.starts_with("file://") { #[cfg(not(debug_assertions))] { - return Err( - "file:// registry URLs are only supported in debug/test builds. \ - Set III_REGISTRY_URL to an HTTPS URL." - .to_string(), - ); + return Err("file:// API URLs are only supported in debug/test builds. \ + Set III_API_URL to an HTTPS URL." + .to_string()); } #[cfg(debug_assertions)] { - let path = url.strip_prefix("file://").unwrap(); + let path = base_or_file.strip_prefix("file://").unwrap(); std::fs::read_to_string(path) - .map_err(|e| format!("Failed to read local registry at {}: {}", path, e))? + .map_err(|e| format!("Failed to read local API fixture at {}: {}", path, e))? } } else { - let resp = HTTP_CLIENT - .get(&url) + let url = format!("{}/download/{}", base_or_file, name); + + let mut request = HTTP_CLIENT.get(&url); + if let Some(v) = version { + request = request.query(&[("version", v)]); + } + + let resp = request .send() .await - .map_err(|e| format!("Failed to fetch registry: {}", e))?; + .map_err(|e| format!("Failed to resolve worker: {}", e))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(format!("Worker '{}' not found", name)); + } if !resp.status().is_success() { - return Err(format!("Registry returned HTTP {}", resp.status())); + return Err(format!("Failed to resolve worker: HTTP {}", resp.status())); } + resp.text() .await - .map_err(|e| format!("Failed to read registry body: {}", e))? + .map_err(|e| format!("Failed to read API response: {}", e))? }; - serde_json::from_str(&body).map_err(|e| format!("Failed to parse registry: {}", e)) -} - -pub async fn resolve_image(input: &str) -> Result<(String, String), String> { - if input.contains('/') || input.contains(':') { - let name = input - .rsplit('/') - .next() - .unwrap_or(input) - .split(':') - .next() - .unwrap_or(input); - return Ok((input.to_string(), name.to_string())); - } - - let registry = fetch_registry().await?; - let entry = registry - .workers - .get(input) - .ok_or_else(|| format!("Worker '{}' not found in registry", input))?; - - let image = entry.image.as_ref().ok_or_else(|| { - format!( - "Worker '{}' has no image field (may be a binary worker)", - input - ) - })?; - let latest = entry - .latest - .as_ref() - .ok_or_else(|| format!("Worker '{}' has no latest version", input))?; - let image_ref = format!("{}:{}", image, latest); - Ok((image_ref, input.to_string())) + serde_json::from_str(&body).map_err(|e| format!("Failed to parse worker info: {}", e)) } #[cfg(test)] @@ -181,120 +143,86 @@ mod tests { use super::*; use std::sync::Mutex; - // Serialize tests that mutate the III_REGISTRY_URL env var to prevent races. + // Serialize tests that mutate env vars to prevent races. static ENV_LOCK: Mutex<()> = Mutex::new(()); #[tokio::test] - async fn resolve_image_full_ref_passthrough() { - let (image, name) = resolve_image("ghcr.io/iii-hq/image-resize:0.1.2") - .await - .unwrap(); - assert_eq!(image, "ghcr.io/iii-hq/image-resize:0.1.2"); - assert_eq!(name, "image-resize"); - } - - #[tokio::test] - async fn resolve_image_shorthand_uses_registry() { + async fn fetch_worker_info_binary_via_file() { let dir = tempfile::tempdir().unwrap(); - let registry_path = dir.path().join("registry.json"); - let registry_json = r#"{"version": 2, "workers": {"image-resize": {"description": "Resize images", "image": "ghcr.io/iii-hq/image-resize", "latest": "0.1.2"}}}"#; - std::fs::write(®istry_path, registry_json).unwrap(); + let json = r#"{ + "name": "image-resize", + "type": "binary", + "version": "0.1.2", + "binaries": { + "aarch64-apple-darwin": { + "sha256": "abc123", + "url": "https://example.com/image-resize-aarch64-apple-darwin.tar.gz" + } + }, + "config": { + "name": "image-resize", + "config": { "width": 200 } + } + }"#; + let response_path = dir.path().join("response.json"); + std::fs::write(&response_path, json).unwrap(); - let url = format!("file://{}", registry_path.display()); + let url = format!("file://{}", response_path.display()); let result = { let _guard = ENV_LOCK.lock().unwrap(); - // SAFETY: guarded by ENV_LOCK to prevent races with other env-var tests - unsafe { std::env::set_var("III_REGISTRY_URL", &url) }; - let r = resolve_image("image-resize").await; - unsafe { std::env::remove_var("III_REGISTRY_URL") }; + unsafe { std::env::set_var("III_API_URL", &url) }; + let r = fetch_worker_info("image-resize", None).await; + unsafe { std::env::remove_var("III_API_URL") }; r }; - let (image, name) = result.unwrap(); - assert_eq!(image, "ghcr.io/iii-hq/image-resize:0.1.2"); - assert_eq!(name, "image-resize"); + let info = result.unwrap(); + match info { + WorkerInfoResponse::Binary(b) => { + assert_eq!(b.name, "image-resize"); + assert_eq!(b.version, "0.1.2"); + } + _ => panic!("expected Binary variant"), + } } #[tokio::test] - async fn resolve_image_shorthand_not_found() { + async fn fetch_worker_info_oci_via_file() { let dir = tempfile::tempdir().unwrap(); - let registry_path = dir.path().join("registry.json"); - let registry_json = r#"{"version": 2, "workers": {}}"#; - std::fs::write(®istry_path, registry_json).unwrap(); + let json = r#"{ + "name": "todo-worker", + "type": "image", + "version": "0.1.0", + "image_url": "docker.io/andersonofl/todo-worker:0.1.0" + }"#; + let response_path = dir.path().join("response.json"); + std::fs::write(&response_path, json).unwrap(); - let url = format!("file://{}", registry_path.display()); + let url = format!("file://{}", response_path.display()); let result = { let _guard = ENV_LOCK.lock().unwrap(); - unsafe { std::env::set_var("III_REGISTRY_URL", &url) }; - let r = resolve_image("nonexistent").await; - unsafe { std::env::remove_var("III_REGISTRY_URL") }; + unsafe { std::env::set_var("III_API_URL", &url) }; + let r = fetch_worker_info("todo-worker", None).await; + unsafe { std::env::remove_var("III_API_URL") }; r }; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found in registry")); - } - - #[tokio::test] - async fn resolve_image_with_slash_no_tag() { - let (image, name) = resolve_image("ghcr.io/iii-hq/image-resize").await.unwrap(); - assert_eq!(image, "ghcr.io/iii-hq/image-resize"); - assert_eq!(name, "image-resize"); - } - - #[test] - fn parse_registry_v2_with_binary_type() { - let json = r#"{ - "version": 1, - "workers": { - "image-resize": { - "type": "binary", - "description": "Image resize worker", - "repo": "iii-hq/workers", - "tag_prefix": "image-resize", - "supported_targets": ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"], - "has_checksum": true, - "default_config": { - "name": "image-resize", - "config": { "width": 200 } - }, - "version": "0.1.2" - } + let info = result.unwrap(); + match info { + WorkerInfoResponse::Oci(o) => { + assert_eq!(o.name, "todo-worker"); + assert_eq!(o.image_url, "docker.io/andersonofl/todo-worker:0.1.0"); } - }"#; - let registry: RegistryV2 = serde_json::from_str(json).unwrap(); - let entry = registry.workers.get("image-resize").unwrap(); - assert_eq!(entry.worker_type, Some(WorkerType::Binary)); - assert_eq!(entry.repo.as_deref(), Some("iii-hq/workers")); - assert_eq!(entry.tag_prefix.as_deref(), Some("image-resize")); - assert_eq!(entry.version.as_deref(), Some("0.1.2")); - assert!(entry.has_checksum.unwrap_or(false)); - assert_eq!( - entry.supported_targets.as_ref().unwrap(), - &vec![ - "aarch64-apple-darwin".to_string(), - "x86_64-unknown-linux-gnu".to_string() - ] - ); + _ => panic!("expected Oci variant"), + } } - #[test] - fn parse_registry_v2_managed_type_default() { - let json = r#"{ - "version": 1, - "workers": { - "pdfkit": { - "description": "PDF worker", - "image": "ghcr.io/iii-hq/pdfkit", - "latest": "1.0.0" - } - } - }"#; - let registry: RegistryV2 = serde_json::from_str(json).unwrap(); - let entry = registry.workers.get("pdfkit").unwrap(); - assert_eq!(entry.worker_type, None); - assert_eq!(entry.image.as_deref(), Some("ghcr.io/iii-hq/pdfkit")); - assert_eq!(entry.latest.as_deref(), Some("1.0.0")); + #[tokio::test] + async fn fetch_worker_info_rejects_invalid_name() { + let result = fetch_worker_info("../evil", None).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("invalid characters") || err.contains("'..'")); } #[test] @@ -358,22 +286,295 @@ mod tests { } #[test] - fn validate_repo_valid() { - assert!(validate_repo("iii-hq/workers").is_ok()); - assert!(validate_repo("my-org/my-repo").is_ok()); + fn deserialize_binary_worker_response() { + let json = r#"{ + "name": "image-resize", + "type": "binary", + "version": "0.1.2", + "binaries": { + "aarch64-apple-darwin": { + "sha256": "5fdbce8e5db431ea6dddb527d3be0adf5bfac92fafac4a0c78d21e438d583f17", + "url": "https://github.com/iii-hq/workers/releases/download/image-resize/v0.1.2/image-resize-aarch64-apple-darwin.tar.gz" + }, + "x86_64-unknown-linux-gnu": { + "sha256": "37c9b004c61cc76d8041cd3645ac7e7004cacd9eccbdd6bda1d847922fa98eb4", + "url": "https://github.com/iii-hq/workers/releases/download/image-resize/v0.1.2/image-resize-x86_64-unknown-linux-gnu.tar.gz" + } + }, + "config": { + "name": "image-resize", + "config": { + "width": 200, + "height": 200, + "quality": { "jpeg": 85, "webp": 80 }, + "strategy": "scale-to-fit" + } + } + }"#; + let response: WorkerInfoResponse = serde_json::from_str(json).unwrap(); + match response { + WorkerInfoResponse::Binary(b) => { + assert_eq!(b.name, "image-resize"); + assert_eq!(b.version, "0.1.2"); + assert_eq!(b.binaries.len(), 2); + let darwin = b.binaries.get("aarch64-apple-darwin").unwrap(); + assert_eq!( + darwin.sha256, + "5fdbce8e5db431ea6dddb527d3be0adf5bfac92fafac4a0c78d21e438d583f17" + ); + assert!(darwin.url.ends_with("aarch64-apple-darwin.tar.gz")); + assert_eq!(b.config.name, "image-resize"); + } + _ => panic!("expected Binary variant"), + } + } + + #[test] + fn deserialize_oci_worker_response() { + let json = r#"{ + "name": "todo-worker", + "type": "image", + "version": "0.1.0", + "image_url": "docker.io/andersonofl/todo-worker:0.1.0" + }"#; + let response: WorkerInfoResponse = serde_json::from_str(json).unwrap(); + match response { + WorkerInfoResponse::Oci(o) => { + assert_eq!(o.name, "todo-worker"); + assert_eq!(o.version, "0.1.0"); + assert_eq!(o.image_url, "docker.io/andersonofl/todo-worker:0.1.0"); + } + _ => panic!("expected Oci variant"), + } } #[test] - fn validate_repo_rejects_traversal() { - assert!(validate_repo("../../evil/repo").is_err()); - assert!(validate_repo("owner/../evil").is_err()); + fn deserialize_unknown_type_fails() { + let json = r#"{"name": "x", "type": "wasm", "version": "1.0"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + // -- Deserialization edge cases -- + + #[test] + fn deserialize_binary_missing_type_fails() { + let json = r#"{"name": "x", "version": "1.0", "binaries": {}, "config": {"name": "x", "config": {}}}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "missing 'type' field should fail"); } #[test] - fn validate_repo_rejects_bad_format() { - assert!(validate_repo("just-a-name").is_err()); - assert!(validate_repo("a/b/c").is_err()); - assert!(validate_repo("/leading-slash").is_err()); - assert!(validate_repo("trailing/").is_err()); + fn deserialize_binary_missing_name_fails() { + let json = r#"{"type": "binary", "version": "1.0", "binaries": {}, "config": {"name": "x", "config": {}}}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "missing 'name' field should fail"); + } + + #[test] + fn deserialize_binary_missing_binaries_fails() { + let json = r#"{"name": "x", "type": "binary", "version": "1.0", "config": {"name": "x", "config": {}}}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "missing 'binaries' field should fail"); + } + + #[test] + fn deserialize_binary_missing_config_fails() { + let json = r#"{"name": "x", "type": "binary", "version": "1.0", "binaries": {}}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "missing 'config' field should fail"); + } + + #[test] + fn deserialize_oci_missing_image_url_fails() { + let json = r#"{"name": "x", "type": "image", "version": "1.0"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "missing 'image_url' field should fail"); + } + + #[test] + fn deserialize_binary_empty_binaries_map_ok() { + let json = r#"{ + "name": "empty-worker", + "type": "binary", + "version": "0.1.0", + "binaries": {}, + "config": {"name": "empty-worker", "config": {}} + }"#; + let response: WorkerInfoResponse = serde_json::from_str(json).unwrap(); + match response { + WorkerInfoResponse::Binary(b) => { + assert_eq!(b.name, "empty-worker"); + assert!(b.binaries.is_empty()); + } + _ => panic!("expected Binary variant"), + } + } + + #[test] + fn deserialize_binary_info_missing_sha256_fails() { + let json = r#"{ + "name": "x", + "type": "binary", + "version": "1.0", + "binaries": { + "aarch64-apple-darwin": {"url": "https://example.com/file.tar.gz"} + }, + "config": {"name": "x", "config": {}} + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "BinaryInfo missing sha256 should fail"); + } + + #[test] + fn deserialize_binary_info_missing_url_fails() { + let json = r#"{ + "name": "x", + "type": "binary", + "version": "1.0", + "binaries": { + "aarch64-apple-darwin": {"sha256": "abc123"} + }, + "config": {"name": "x", "config": {}} + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "BinaryInfo missing url should fail"); + } + + #[test] + fn deserialize_extra_fields_tolerated() { + let json = r#"{ + "name": "x", + "type": "image", + "version": "1.0", + "image_url": "docker.io/x:1.0", + "description": "this field is not in the struct", + "author": "someone" + }"#; + let response: WorkerInfoResponse = serde_json::from_str(json).unwrap(); + match response { + WorkerInfoResponse::Oci(o) => assert_eq!(o.name, "x"), + _ => panic!("expected Oci variant"), + } + } + + #[test] + fn deserialize_completely_invalid_json_fails() { + let json = "not json at all"; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn deserialize_empty_json_object_fails() { + let json = "{}"; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "empty object should fail (no type tag)"); + } + + #[test] + fn deserialize_config_with_null_value_ok() { + let json = r#"{ + "name": "x", + "type": "binary", + "version": "1.0", + "binaries": {}, + "config": {"name": "x", "config": null} + }"#; + let response: WorkerInfoResponse = serde_json::from_str(json).unwrap(); + match response { + WorkerInfoResponse::Binary(b) => { + assert!(b.config.config.is_null()); + } + _ => panic!("expected Binary variant"), + } + } + + // -- fetch_worker_info error paths -- + + #[tokio::test] + async fn fetch_worker_info_empty_name_rejected() { + let result = fetch_worker_info("", None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("cannot be empty")); + } + + #[tokio::test] + async fn fetch_worker_info_dotdot_name_rejected() { + let result = fetch_worker_info("..", None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("'..'")); + } + + #[tokio::test] + async fn fetch_worker_info_file_not_found() { + let url = "file:///tmp/nonexistent-iii-test-fixture-12345.json"; + let result = { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { std::env::set_var("III_API_URL", url) }; + let r = fetch_worker_info("some-worker", None).await; + unsafe { std::env::remove_var("III_API_URL") }; + r + }; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Failed to read local API fixture") + ); + } + + #[tokio::test] + async fn fetch_worker_info_malformed_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.json"); + std::fs::write(&path, "this is not json").unwrap(); + + let url = format!("file://{}", path.display()); + let result = { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { std::env::set_var("III_API_URL", &url) }; + let r = fetch_worker_info("some-worker", None).await; + unsafe { std::env::remove_var("III_API_URL") }; + r + }; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse worker info")); + } + + #[tokio::test] + async fn fetch_worker_info_empty_json_object() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty.json"); + std::fs::write(&path, "{}").unwrap(); + + let url = format!("file://{}", path.display()); + let result = { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { std::env::set_var("III_API_URL", &url) }; + let r = fetch_worker_info("some-worker", None).await; + unsafe { std::env::remove_var("III_API_URL") }; + r + }; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse worker info")); + } + + #[tokio::test] + async fn fetch_worker_info_wrong_type_in_fixture() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wasm.json"); + std::fs::write(&path, r#"{"name": "x", "type": "wasm", "version": "1.0"}"#).unwrap(); + + let url = format!("file://{}", path.display()); + let result = { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { std::env::set_var("III_API_URL", &url) }; + let r = fetch_worker_info("some-worker", None).await; + unsafe { std::env::remove_var("III_API_URL") }; + r + }; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse worker info")); } } diff --git a/crates/iii-worker/src/main.rs b/crates/iii-worker/src/main.rs index 457068a95..6e97c379b 100644 --- a/crates/iii-worker/src/main.rs +++ b/crates/iii-worker/src/main.rs @@ -27,7 +27,6 @@ async fn main() -> anyhow::Result<()> { let result = iii_worker::cli::managed::handle_managed_add( name, false, - None, force, args.reset_config, ) @@ -50,7 +49,6 @@ async fn main() -> anyhow::Result<()> { let result = iii_worker::cli::managed::handle_managed_add( name, false, - None, true, args.reset_config, ) diff --git a/crates/iii-worker/tests/binary_worker_integration.rs b/crates/iii-worker/tests/binary_worker_integration.rs index b0f648b33..3a6ee185a 100644 --- a/crates/iii-worker/tests/binary_worker_integration.rs +++ b/crates/iii-worker/tests/binary_worker_integration.rs @@ -10,10 +10,10 @@ mod common; use iii_worker::cli::binary_download::{ - archive_extension, binary_download_url, binary_worker_path, binary_workers_dir, - checksum_download_url, current_target, download_and_install_binary, extract_binary_from_targz, - verify_sha256, + archive_extension, binary_worker_path, binary_workers_dir, current_target, + download_and_install_binary, extract_binary_from_targz, verify_sha256, }; +use iii_worker::cli::registry::BinaryInfo; use sha2::{Digest, Sha256}; use std::sync::Mutex; @@ -91,77 +91,6 @@ fn current_target_contains_os_and_arch() { // Group 2: URL construction (BIN-01) // =========================================================================== -/// BIN-01: binary_download_url produces correct URL for Linux target. -#[test] -fn binary_download_url_linux_format() { - let url = binary_download_url( - "iii-hq/workers", - "my-worker", - "1.0.0", - "my-worker", - "x86_64-unknown-linux-gnu", - ); - assert_eq!( - url, - "https://github.com/iii-hq/workers/releases/download/my-worker/v1.0.0/my-worker-x86_64-unknown-linux-gnu.tar.gz" - ); -} - -/// BIN-01: binary_download_url produces correct URL for macOS target. -#[test] -fn binary_download_url_macos_format() { - let url = binary_download_url( - "iii-hq/workers", - "my-worker", - "1.0.0", - "my-worker", - "aarch64-apple-darwin", - ); - assert!( - url.ends_with("aarch64-apple-darwin.tar.gz"), - "macOS URL should end with aarch64-apple-darwin.tar.gz, got: {}", - url - ); -} - -/// BIN-01: binary_download_url uses .zip extension for Windows targets. -#[test] -fn binary_download_url_windows_uses_zip() { - let url = binary_download_url( - "iii-hq/workers", - "my-worker", - "1.0.0", - "my-worker", - "x86_64-pc-windows-msvc", - ); - assert!( - url.ends_with(".zip"), - "Windows URL should end with .zip, got: {}", - url - ); -} - -/// BIN-01: checksum_download_url produces correct .sha256 URL. -#[test] -fn checksum_download_url_format() { - let url = checksum_download_url( - "iii-hq/workers", - "my-worker", - "1.0.0", - "my-worker", - "x86_64-unknown-linux-gnu", - ); - assert!( - url.ends_with(".sha256"), - "checksum URL should end with .sha256, got: {}", - url - ); - assert_eq!( - url, - "https://github.com/iii-hq/workers/releases/download/my-worker/v1.0.0/my-worker-x86_64-unknown-linux-gnu.sha256" - ); -} - /// BIN-01: archive_extension returns "tar.gz" for non-Windows targets. #[test] fn archive_extension_non_windows() { @@ -391,32 +320,118 @@ fn executable_permissions_roundtrip() { /// BIN-04 (T-04-03): download_and_install_binary rejects empty worker name. #[tokio::test] async fn download_rejects_invalid_worker_name() { - let result = download_and_install_binary("", "owner/repo", "tag", "1.0", &[], false).await; + let info = BinaryInfo { + url: "https://example.com/fake.tar.gz".to_string(), + sha256: "abc".to_string(), + }; + let result = download_and_install_binary("", &info).await; assert!(result.is_err(), "empty worker name should be rejected"); } /// BIN-04 (T-04-03): download_and_install_binary rejects path traversal in worker name. #[tokio::test] async fn download_rejects_path_traversal_name() { - let result = - download_and_install_binary("../evil", "owner/repo", "tag", "1.0", &[], false).await; + let info = BinaryInfo { + url: "https://example.com/fake.tar.gz".to_string(), + sha256: "abc".to_string(), + }; + let result = download_and_install_binary("../evil", &info).await; assert!( result.is_err(), "path traversal in worker name should be rejected" ); } -/// BIN-04: download_and_install_binary rejects unsupported target platform. -#[tokio::test] -async fn download_rejects_unsupported_target() { - // Use a supported_targets list that definitely excludes the current platform. - let unsupported = vec!["riscv64-unknown-linux-gnu".to_string()]; - let result = - download_and_install_binary("my-worker", "owner/repo", "tag", "1.0", &unsupported, false) - .await; - assert!(result.is_err(), "unsupported target should be rejected"); +// =========================================================================== +// Group 8: Platform selection from BinaryWorkerResponse (API migration) +// =========================================================================== + +/// Verify that looking up a platform key in a BinaryWorkerResponse binaries map +/// works correctly when the platform exists. +#[test] +fn binary_response_platform_lookup_found() { + use std::collections::HashMap; + + let mut binaries = HashMap::new(); + binaries.insert( + "aarch64-apple-darwin".to_string(), + BinaryInfo { + url: "https://example.com/worker-aarch64-apple-darwin.tar.gz".to_string(), + sha256: "abc123".to_string(), + }, + ); + binaries.insert( + "x86_64-unknown-linux-gnu".to_string(), + BinaryInfo { + url: "https://example.com/worker-x86_64-unknown-linux-gnu.tar.gz".to_string(), + sha256: "def456".to_string(), + }, + ); + + let target = current_target(); + // On macOS aarch64 or Linux x86_64 this should find a match + if target == "aarch64-apple-darwin" || target == "x86_64-unknown-linux-gnu" { + let info = binaries.get(target).unwrap(); + assert!(!info.url.is_empty()); + assert!(!info.sha256.is_empty()); + } +} + +/// Verify that looking up a non-existent platform key returns None. +#[test] +fn binary_response_platform_lookup_not_found() { + use std::collections::HashMap; + + let mut binaries = HashMap::new(); + binaries.insert( + "riscv64-unknown-linux-gnu".to_string(), + BinaryInfo { + url: "https://example.com/worker-riscv64.tar.gz".to_string(), + sha256: "abc".to_string(), + }, + ); + + let target = current_target(); assert!( - result.unwrap_err().contains("not supported"), - "error should mention 'not supported'" + binaries.get(target).is_none(), + "current target '{}' should not be riscv64", + target ); } + +/// Verify that empty binaries map returns None for any platform. +#[test] +fn binary_response_empty_binaries_map() { + use std::collections::HashMap; + let binaries: HashMap = HashMap::new(); + let target = current_target(); + assert!(binaries.get(target).is_none()); +} + +// =========================================================================== +// Group 9: Checksum verification with inline sha256 (API migration) +// =========================================================================== + +/// Verify that verify_sha256 works with the inline sha256 string format +/// that the new API provides (just a hex string, no filename suffix). +#[test] +fn verify_sha256_inline_api_format() { + use sha2::{Digest, Sha256}; + let data = b"binary content from API download"; + let mut hasher = Sha256::new(); + hasher.update(data); + let hex = format!("{:x}", hasher.finalize()); + + // The API provides just the hex string — verify this works + assert!(verify_sha256(data, &hex).is_ok()); +} + +/// Verify that checksum mismatch is detected with inline format. +#[test] +fn verify_sha256_inline_api_format_mismatch() { + let data = b"binary content"; + let wrong_hex = "0000000000000000000000000000000000000000000000000000000000000000"; + let result = verify_sha256(data, wrong_hex); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("SHA256 mismatch")); +} diff --git a/crates/iii-worker/tests/config_force_integration.rs b/crates/iii-worker/tests/config_force_integration.rs index 88f827b11..ac0435cd3 100644 --- a/crates/iii-worker/tests/config_force_integration.rs +++ b/crates/iii-worker/tests/config_force_integration.rs @@ -18,16 +18,14 @@ async fn handle_managed_add_force_builtin_re_adds() { in_temp_dir_async(|| async { // First add creates config let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) - .await; + iii_worker::cli::managed::handle_managed_add("iii-http", false, false, false).await; assert_eq!(exit_code, 0); let content = std::fs::read_to_string("config.yaml").unwrap(); assert!(content.contains("- name: iii-http")); // Force re-add succeeds (builtins have no artifacts to delete) let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, false) - .await; + iii_worker::cli::managed::handle_managed_add("iii-http", false, true, false).await; assert_eq!(exit_code, 0); let content = std::fs::read_to_string("config.yaml").unwrap(); assert!(content.contains("- name: iii-http")); @@ -47,7 +45,7 @@ async fn handle_managed_add_force_reset_config_clears_overrides() { // Force with reset_config should clear user overrides and re-apply defaults let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, true) + iii_worker::cli::managed::handle_managed_add("iii-http", false, true, true) .await; assert_eq!(exit_code, 0); @@ -73,7 +71,7 @@ async fn handle_managed_add_force_without_reset_preserves_config() { // Force WITHOUT reset_config should preserve user overrides let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, true, false) + iii_worker::cli::managed::handle_managed_add("iii-http", false, true, false) .await; assert_eq!(exit_code, 0); diff --git a/crates/iii-worker/tests/config_managed_integration.rs b/crates/iii-worker/tests/config_managed_integration.rs index 51ee19f71..e9d250f1f 100644 --- a/crates/iii-worker/tests/config_managed_integration.rs +++ b/crates/iii-worker/tests/config_managed_integration.rs @@ -54,8 +54,7 @@ async fn add_many_with_invalid_worker_returns_nonzero() { async fn handle_managed_add_builtin_creates_config() { in_temp_dir_async(|| async { let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) - .await; + iii_worker::cli::managed::handle_managed_add("iii-http", false, false, false).await; assert_eq!( exit_code, 0, "expected success exit code for builtin worker" @@ -84,7 +83,7 @@ async fn handle_managed_add_builtin_merges_existing() { .unwrap(); let exit_code = - iii_worker::cli::managed::handle_managed_add("iii-http", false, None, false, false) + iii_worker::cli::managed::handle_managed_add("iii-http", false, false, false) .await; assert_eq!(exit_code, 0, "expected success exit code for merge"); @@ -106,7 +105,7 @@ async fn handle_managed_add_all_builtins_succeed() { let _ = std::fs::remove_file("config.yaml"); let exit_code = - iii_worker::cli::managed::handle_managed_add(name, false, None, false, false).await; + iii_worker::cli::managed::handle_managed_add(name, false, false, false).await; assert_eq!(exit_code, 0, "expected success for builtin '{}'", name); let content = std::fs::read_to_string("config.yaml").unwrap(); @@ -165,3 +164,124 @@ async fn remove_many_with_missing_worker_returns_nonzero() { }) .await; } + +// =========================================================================== +// OCI passthrough routing +// =========================================================================== + +/// Verify that a full OCI reference (containing '/') is treated as a direct +/// OCI passthrough — the function should NOT call the API. It will fail because +/// there's no running container runtime, but the error should be about pulling, +/// not about API resolution. +#[tokio::test] +async fn handle_managed_add_oci_ref_passthrough() { + in_temp_dir_async(|| async { + // This will fail (no container runtime), but the error path tells us + // it tried to pull an OCI image rather than calling the API + let result = iii_worker::cli::managed::handle_managed_add( + "ghcr.io/test/worker:1.0", + false, + false, + false, + ) + .await; + // Non-zero exit because pull fails, but it should NOT have tried the API + assert_ne!(result, 0); + }) + .await; +} + +/// Verify that a reference with ':' is also treated as OCI passthrough. +#[tokio::test] +async fn handle_managed_add_oci_ref_with_colon_passthrough() { + in_temp_dir_async(|| async { + let result = iii_worker::cli::managed::handle_managed_add( + "docker.io/org/image:latest", + false, + false, + false, + ) + .await; + assert_ne!(result, 0); + }) + .await; +} + +// =========================================================================== +// API resolution path (non-builtin, non-OCI-ref, non-local) +// =========================================================================== + +/// Verify that a plain worker name that is NOT a builtin triggers API resolution. +/// With no API available, it should fail with a resolution error. +#[tokio::test] +async fn handle_managed_add_plain_name_calls_api() { + in_temp_dir_async(|| async { + // Set III_API_URL to something that will fail quickly + unsafe { std::env::set_var("III_API_URL", "http://127.0.0.1:1") }; + let result = + iii_worker::cli::managed::handle_managed_add("nonexistent-worker", true, false, false) + .await; + unsafe { std::env::remove_var("III_API_URL") }; + // Should fail because API is unreachable + assert_ne!(result, 0); + }) + .await; +} + +/// Verify that a plain name matching a builtin worker succeeds without API. +#[tokio::test] +async fn handle_managed_add_builtin_skips_api() { + in_temp_dir_async(|| async { + // Set III_API_URL to something that will fail — if it tries the API, we'll know + unsafe { std::env::set_var("III_API_URL", "http://127.0.0.1:1") }; + let result = + iii_worker::cli::managed::handle_managed_add("iii-http", true, false, false).await; + unsafe { std::env::remove_var("III_API_URL") }; + // Should succeed because iii-http is a builtin — no API call needed + assert_eq!(result, 0); + }) + .await; +} + +/// Verify that local path workers are routed correctly (not to API). +#[tokio::test] +async fn handle_managed_add_local_path_skips_api() { + in_temp_dir_async(|| async { + unsafe { std::env::set_var("III_API_URL", "http://127.0.0.1:1") }; + let result = + iii_worker::cli::managed::handle_managed_add("./my-local-worker", true, false, false) + .await; + unsafe { std::env::remove_var("III_API_URL") }; + // Will fail (path doesn't exist), but should NOT have called the API + assert_ne!(result, 0); + }) + .await; +} + +/// Verify API resolution with file:// fixture returns binary worker. +#[tokio::test] +async fn handle_managed_add_binary_via_file_fixture() { + in_temp_dir_async(|| async { + let dir = tempfile::tempdir().unwrap(); + let json = r#"{ + "name": "test-binary-worker", + "type": "binary", + "version": "0.1.0", + "binaries": {}, + "config": {"name": "test-binary-worker", "config": {}} + }"#; + let path = dir.path().join("fixture.json"); + std::fs::write(&path, json).unwrap(); + + let url = format!("file://{}", path.display()); + unsafe { std::env::set_var("III_API_URL", &url) }; + let result = + iii_worker::cli::managed::handle_managed_add("test-binary-worker", true, false, false) + .await; + unsafe { std::env::remove_var("III_API_URL") }; + // Will fail because binaries map is empty (no platform match), + // but it proves the API resolution path was taken and parsed correctly + assert_ne!(result, 0); + }) + .await; +}