diff --git a/workshop-baker/src/bare.rs b/workshop-baker/src/bare.rs index 27f9c1b..3b34857 100644 --- a/workshop-baker/src/bare.rs +++ b/workshop-baker/src/bare.rs @@ -2,9 +2,9 @@ use std::path::PathBuf; use crate::cli::{BareCommands, Cli}; use crate::error::CliError; +use crate::types::resource::ResourceRegistry; use crate::{bake::bake, finalize::finalize, prebake::prebake}; use workshop_engine::{EventSender, ExecutionContext}; - fn or_workshop(path: &Option, name: &str) -> PathBuf { path .clone() @@ -18,6 +18,7 @@ pub async fn run_bare( event_tx: EventSender, pipeline: &str, build_id: &str, + registry: &ResourceRegistry, ) -> Result<(), CliError> { let (stage, config) = match command { BareCommands::Prebake { .. } => ("prebake", or_workshop(&cli.prebake, "prebake.yml")), @@ -42,7 +43,7 @@ pub async fn run_bare( bake(&config, &bake_base, &prebake_path, cli, ctx, event_tx).await?; } BareCommands::Finalize { .. } => { - finalize(&config, cli, ctx, event_tx).await?; + finalize(&config, cli, ctx, event_tx, registry).await?; } } Ok(()) diff --git a/workshop-baker/src/finalize.rs b/workshop-baker/src/finalize.rs index fd916e8..4fd814d 100644 --- a/workshop-baker/src/finalize.rs +++ b/workshop-baker/src/finalize.rs @@ -8,12 +8,10 @@ pub mod template; pub mod types; use crate::cli::Cli; -use crate::finalize::constant::FINALIZE_STANDALONE_PLUGIN_PATH; -use crate::utils::fetch_plugin::{self, FetchArgument}; +use crate::types::resource::ResourceRegistry; pub use config::*; pub use error::FinalizeError; use std::path::Path; -use std::path::PathBuf; use workshop_engine::EventSender; pub fn parse(path: &Path) -> Result { @@ -27,6 +25,7 @@ pub async fn finalize( _cli: &Cli, ctx: &mut workshop_engine::ExecutionContext, event_tx: EventSender, + registry: &ResourceRegistry, ) -> Result<(), FinalizeError> { let mut finalize = parse(finalize_path)?; let validate_result = finalize.validate(); @@ -36,33 +35,10 @@ pub async fn finalize( } return Err(FinalizeError::ValidateError(errors)); }; - if ctx.standalone { - // In standalone mode, finalize should download plugin by itself - log::debug!("Downloading {} plugins", finalize.plugin.len()); - let plugin_path = PathBuf::from(FINALIZE_STANDALONE_PLUGIN_PATH); - // if plugin_path.exists() { - // std::fs::remove_dir_all(&plugin_path).map_err(FinalizeError::IoError)?; - // } - for (name, data) in finalize.plugin.iter_mut() { - log::info!("Downloading plugin {}", name); - let (source, config) = data.iter_mut().next().unwrap_or_else(|| { - panic!( - "Internal Error: Plugin {} has no source configured, this should have been caught by validation!", - name - ) - }); - let fetch_argument = FetchArgument { - checksum: config.checksum.clone(), - shallow: config.shallow, - }; - fetch_plugin::fetch_plugin(source, name, &plugin_path, fetch_argument).await?; - config.runtime.fspath = Some(plugin_path.join(name)); - } - } - // Register Plugins + // Register Plugins (paths resolved from registry) log::info!("Registering {} plugins", finalize.plugin.len()); - let registered_plugins = plugin::register(finalize.plugin, None, &mut finalize.notification)?; + let registered_plugins = plugin::register(finalize.plugin, None, &mut finalize.notification, registry)?; // Early hook let earlyhook_result = stage::hook::hook( diff --git a/workshop-baker/src/finalize/error.rs b/workshop-baker/src/finalize/error.rs index deb1b6d..d0937e8 100644 --- a/workshop-baker/src/finalize/error.rs +++ b/workshop-baker/src/finalize/error.rs @@ -1,4 +1,3 @@ -use crate::utils::fetch::FetchError; use std::path::PathBuf; use thiserror::Error; @@ -56,12 +55,6 @@ impl HasExitCode for FinalizeError { } } -impl From for FinalizeError { - fn from(e: FetchError) -> Self { - FinalizeError::PluginError(PluginError::GeneralError(e.to_string())) - } -} - #[derive(Error, Debug)] pub enum PluginError { #[error("Unknown plugin: {0}")] diff --git a/workshop-baker/src/finalize/plugin.rs b/workshop-baker/src/finalize/plugin.rs index a532f69..943f65f 100644 --- a/workshop-baker/src/finalize/plugin.rs +++ b/workshop-baker/src/finalize/plugin.rs @@ -3,6 +3,7 @@ use crate::finalize::constant::{FINALIZE_PLUGIN_MANIFEST, FINALIZE_PLUGIN_MANIFE use crate::finalize::plugin::metadata::{PluginMetadata, PluginType}; use workshop_engine::{EventSender, ExecutionContext}; use crate::{ finalize::error::PluginError}; +use crate::types::resource::ResourceRegistry; use async_trait::async_trait; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -215,6 +216,7 @@ pub fn register( external_plugins: RawPluginMap, list: Option<&HashSet>, notification_config: &mut crate::finalize::NotificationConfig, + registry: &ResourceRegistry, ) -> Result { let mut plugins: PluginMap = HashMap::new(); let mut count = 0; @@ -256,7 +258,14 @@ pub fn register( log::warn!("Plugin {} is not available: {}", name, reason); continue; } - let metadata = read_manifest(config.runtime.fspath, &name)?; + // Resolve plugin path from registry instead of config.runtime.fspath + let plugin_path = registry.resolve(&name).ok_or_else(|| { + PluginError::PluginUnavailable { + name: name.clone(), + reason: "plugin not found in resource registry".to_string(), + } + })?; + let metadata = read_manifest(Some(plugin_path), &name)?; let plugin_type = get_plugin_type(metadata.plugin_type.clone(), &metadata.entrypoint); let entry = get_entry(plugin_type, &name)?; plugins.insert( diff --git a/workshop-baker/src/main.rs b/workshop-baker/src/main.rs index f37db0f..2408a59 100644 --- a/workshop-baker/src/main.rs +++ b/workshop-baker/src/main.rs @@ -1,3 +1,4 @@ +use workshop_baker::types::resource::ResourceRegistry; use std::path::PathBuf; use workshop_baker::{ @@ -48,12 +49,17 @@ async fn worker_main(cli: Cli) -> Result<(), CliError> { let (to_tx, to_rx) = tokio::sync::mpsc::channel(256); let (retry_tx, retry_rx) = tokio::sync::mpsc::channel(256); + // Resource registry — populated before stages, consumed by stages + let registry = ResourceRegistry::new(); + // TODO: pre-fetch plugins and templates into registry + let notify_path = finalize_path.clone(); let notify_ctx = ctx.clone(); let notify_etx = event_tx.clone(); let debug = cli.debug.clone(); + let notify_registry = registry.clone(); let notify_handle = tokio::spawn(async move { - notify(¬ify_path, &mut notify_ctx.clone(), notify_etx, to_rx, retry_tx, debug).await + notify(¬ify_path, &mut notify_ctx.clone(), notify_etx, to_rx, retry_tx, debug, ¬ify_registry).await }); let nevent_tx = to_tx.clone(); @@ -64,7 +70,7 @@ async fn worker_main(cli: Cli) -> Result<(), CliError> { let bake_base_path = or_workshop(&cli.bake_base, "bake_base.sh"); let pb_path = or_workshop(&cli.prebake, "prebake.yml"); bake(&bake_path, &bake_base_path, &pb_path, &cli, &mut ctx, event_tx.clone()).await?; - finalize(&finalize_path, &cli, &mut ctx, event_tx.clone()).await?; + finalize(&finalize_path, &cli, &mut ctx, event_tx.clone(), ®istry).await?; Ok(()) }.await; @@ -109,7 +115,8 @@ async fn main() { event_rx, pipeline.clone(), build_id.clone(), )); - let result = run_bare(&cli, command, &mut ctx, event_tx, &pipeline, &build_id).await; + let registry = ResourceRegistry::new(); + let result = run_bare(&cli, command, &mut ctx, event_tx, &pipeline, &build_id, ®istry).await; drop(ctx); let _ = event_handle.await; diff --git a/workshop-baker/src/notify.rs b/workshop-baker/src/notify.rs index 1cb3fb1..34cd43c 100644 --- a/workshop-baker/src/notify.rs +++ b/workshop-baker/src/notify.rs @@ -2,10 +2,9 @@ use crate::finalize::FinalizeConfig; use crate::finalize::NotificationConfig; use crate::finalize::NotificationTemplate; use crate::finalize::config::TriggerItemRaw; -use crate::finalize::constant::FINALIZE_STANDALONE_PLUGIN_PATH; +use crate::types::resource::ResourceRegistry; use crate::finalize::parse; use crate::finalize::plugin; -use crate::utils::fetch_plugin; use crate::notify::types::Matchable; use crate::notify::types::Method; use crate::notify::types::MethodStatus; @@ -31,7 +30,6 @@ use chrono::Utc; use globset::{Glob, GlobSetBuilder}; use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; use std::path::Path; mod error; @@ -72,6 +70,7 @@ pub async fn notify( mut rx: NotificationEventReceiver, retry_tx: NotificationEventSender, debug: Option, + registry: &ResourceRegistry, ) -> anyhow::Result<()> { let mut finalize = parse(finalize_path)?; validate(&finalize)?; @@ -101,44 +100,23 @@ pub async fn notify( } } - if ctx.standalone { - fetch_plugin::fetch_plugins( - &PathBuf::from(FINALIZE_STANDALONE_PLUGIN_PATH), - &mut finalize.plugin, - Some(&plugin_list), - ) - .await?; - } - let notify_plugins = plugin::register(finalize.plugin, Some(&plugin_list), &mut finalize.notification)?; + let notify_plugins = plugin::register(finalize.plugin, Some(&plugin_list), &mut finalize.notification, registry)?; - - - // Fetch external templates referenced by use_ (git:// / https:// URLs) - let template_cache = std::env::temp_dir().join("workshop-templates"); + // Resolve external template references from registry (pre-fetched) for (name, tmpl_def) in finalize.notification.templates.iter_mut() { - if let Some(ref url) = tmpl_def.use_ { - if url.contains("://") { - // External fetch - match crate::utils::fetch::fetch_resource(name, url, &template_cache).await { - Ok(path) => { - // Read the fetched template file - let content = std::fs::read_to_string(path.join("template.yml")) - .or_else(|_| std::fs::read_to_string(path.join("template.yaml"))); - if let Ok(content) = content { - if let Ok(fetched) = serde_yaml::from_str::(&content) { - tmpl_def.on_success = fetched.on_success.or(tmpl_def.on_success.take()); - tmpl_def.on_failure = fetched.on_failure.or(tmpl_def.on_failure.take()); - tmpl_def.on_finish_of = fetched.on_finish_of.or(tmpl_def.on_finish_of.take()); - tmpl_def.use_ = None; // Resolved - } - } - } - Err(e) => { - log::error!("Failed to fetch template '{}' from '{}': {}", name, url, e); + if tmpl_def.use_.is_some() { + if let Some(path) = registry.resolve(name) { + let content = std::fs::read_to_string(path.join("template.yml")) + .or_else(|_| std::fs::read_to_string(path.join("template.yaml"))); + if let Ok(content) = content { + if let Ok(fetched) = serde_yaml::from_str::(&content) { + tmpl_def.on_success = fetched.on_success.or(tmpl_def.on_success.take()); + tmpl_def.on_failure = fetched.on_failure.or(tmpl_def.on_failure.take()); + tmpl_def.on_finish_of = fetched.on_finish_of.or(tmpl_def.on_finish_of.take()); + tmpl_def.use_ = None; // Resolved } } } - // Non-:// use_ values are local template references → handled in Part 2 } } diff --git a/workshop-baker/src/types.rs b/workshop-baker/src/types.rs index 6e81c8a..30efcda 100644 --- a/workshop-baker/src/types.rs +++ b/workshop-baker/src/types.rs @@ -1 +1,2 @@ pub mod buildstatus; +pub mod resource; diff --git a/workshop-baker/src/types/resource.rs b/workshop-baker/src/types/resource.rs new file mode 100644 index 0000000..d7997b5 --- /dev/null +++ b/workshop-baker/src/types/resource.rs @@ -0,0 +1,119 @@ +//! Internal resource representation for the baker loop. +//! +//! A [`Resource`] is the local intermediate state of a fetched external asset. +//! It carries a logical name and a filesystem path. Resources are registered +//! into a [`ResourceRegistry`] before the pipeline starts and consumed +//! exclusively through `resolve()` — the registry is treated as read-only +//! once the pipeline is running. + +use std::collections::HashMap; +use std::path::PathBuf; + +/// A locally resolved resource with a logical name and filesystem path. +/// +/// Created by the resource fetcher (bakerd in worker mode, or the baker +/// itself in standalone mode) and consumed by pipeline stages via the +/// [`ResourceRegistry`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resource { + /// Logical name used for lookup (e.g., `"plugin/docker-push"`). + pub name: String, + /// Local filesystem path to the fetched resource. + pub path: PathBuf, +} + +impl Resource { + /// Create a new resource entry. + pub fn new(name: impl Into, path: PathBuf) -> Self { + Resource { + name: name.into(), + path, + } + } +} + +/// A read-mostly registry of resources available to the pipeline. +/// +/// Populated during the fetch phase (before prebake) and then treated as +/// immutable for the remainder of the pipeline. +#[derive(Debug, Clone, Default)] +pub struct ResourceRegistry { + map: HashMap, +} + +impl ResourceRegistry { + /// Create an empty registry. + pub fn new() -> Self { + ResourceRegistry { + map: HashMap::new(), + } + } + + /// Register a resource under its logical name. + /// + /// Overwrites any existing entry with the same name. + pub fn register(&mut self, name: impl Into, path: PathBuf) { + let name = name.into(); + self.map.insert(name.clone(), Resource::new(name, path)); + } + + /// Resolve a logical name to its filesystem path. + /// + /// Returns `None` if the resource has not been registered. + pub fn resolve(&self, name: &str) -> Option { + self.map.get(name).map(|r| r.path.clone()) + } + + /// Look up a resource entry (includes both name and path). + pub fn get(&self, name: &str) -> Option<&Resource> { + self.map.get(name) + } + + /// Returns the number of registered resources. + pub fn len(&self) -> usize { + self.map.len() + } + + /// Returns `true` if no resources are registered. + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_register_and_resolve() { + let mut reg = ResourceRegistry::new(); + reg.register("plugin/x", PathBuf::from("/cache/plugin_x")); + assert_eq!( + reg.resolve("plugin/x"), + Some(PathBuf::from("/cache/plugin_x")) + ); + } + + #[test] + fn test_resolve_missing() { + let reg = ResourceRegistry::new(); + assert_eq!(reg.resolve("nonexistent"), None); + } + + #[test] + fn test_overwrite() { + let mut reg = ResourceRegistry::new(); + reg.register("a", PathBuf::from("/first")); + reg.register("a", PathBuf::from("/second")); + assert_eq!(reg.resolve("a"), Some(PathBuf::from("/second"))); + } + + #[test] + fn test_len() { + let mut reg = ResourceRegistry::new(); + assert!(reg.is_empty()); + reg.register("a", PathBuf::from("/a")); + reg.register("b", PathBuf::from("/b")); + assert_eq!(reg.len(), 2); + } +} diff --git a/workshop-baker/src/utils.rs b/workshop-baker/src/utils.rs index 00a0ef1..e69de29 100644 --- a/workshop-baker/src/utils.rs +++ b/workshop-baker/src/utils.rs @@ -1,2 +0,0 @@ -pub mod fetch; -pub mod fetch_plugin; diff --git a/workshop-baker/src/utils/fetch/checksum.rs b/workshop-baker/src/utils/fetch/checksum.rs deleted file mode 100644 index 4c349e8..0000000 --- a/workshop-baker/src/utils/fetch/checksum.rs +++ /dev/null @@ -1,137 +0,0 @@ -// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. -use super::types::ChecksumType; -use sha2::{Digest, Sha256, Sha512}; - -/// Detects the checksum type based on the length of the checksum string. -/// -/// Returns `Ok(None)` for empty string (checksum disabled). -/// Returns `Ok(Some(ChecksumType))` for valid SHA256 (64 chars) or SHA512 (128 chars). -/// Returns `Err` for invalid lengths (32=MD5, 40=SHA1) or unsupported lengths. -pub fn detect_checksum_type(checksum: &str) -> Result, String> { - match checksum.len() { - 0 => Ok(None), - 32 => Err(format!( - "MD5 (first 8 chars: {}) is not supported for security reasons. Use SHA256 (64 chars) or SHA512 (128 chars)", - &checksum[..8.min(checksum.len())] - )), - 40 => Err(format!( - "SHA1 (first 8 chars: {}) is deprecated and not supported. Use SHA256 (64 chars) or SHA512 (128 chars)", - &checksum[..8.min(checksum.len())] - )), - 64 => Ok(Some(ChecksumType::Sha256)), - 128 => Ok(Some(ChecksumType::Sha512)), - _ => Err(format!( - "Invalid checksum length: {}. Expected 0 (disabled), 64 (SHA256), or 128 (SHA512). First 8 chars: {}", - checksum.len(), - &checksum[..8.min(checksum.len())] - )), - } -} - -/// Verifies that the data matches the expected checksum. -/// -/// Compares the computed hash of `data` against `expected` using the specified `checksum_type`. -/// Returns `Ok(())` if the checksums match, `Err` otherwise. -pub fn verify_checksum( - data: &[u8], - expected: &str, - checksum_type: ChecksumType, -) -> Result<(), String> { - let actual = match checksum_type { - ChecksumType::Sha256 => { - let mut hasher = Sha256::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) - } - ChecksumType::Sha512 => { - let mut hasher = Sha512::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) - } - }; - - if actual.eq_ignore_ascii_case(expected) { - Ok(()) - } else { - Err(format!( - "Checksum mismatch: expected {}..., got {}...", - &expected[..8.min(expected.len())], - &actual[..8.min(actual.len())] - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_detect_checksum_type_empty() { - assert_eq!(detect_checksum_type("").unwrap(), None); - } - - #[test] - fn test_detect_checksum_type_md5_rejected() { - let result = detect_checksum_type("a".repeat(32).as_str()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("MD5")); - } - - #[test] - fn test_detect_checksum_type_sha1_rejected() { - let result = detect_checksum_type("a".repeat(40).as_str()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("SHA1")); - } - - #[test] - fn test_detect_checksum_type_sha256() { - assert_eq!( - detect_checksum_type("a".repeat(64).as_str()).unwrap(), - Some(ChecksumType::Sha256) - ); - } - - #[test] - fn test_detect_checksum_type_sha512() { - assert_eq!( - detect_checksum_type("a".repeat(128).as_str()).unwrap(), - Some(ChecksumType::Sha512) - ); - } - - #[test] - fn test_detect_checksum_type_invalid_length() { - let result = detect_checksum_type("a".repeat(50).as_str()); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid checksum length")); - } - - #[test] - fn test_verify_checksum_sha256_valid() { - let data = b"hello"; - let expected = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; - assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_ok()); - } - - #[test] - fn test_verify_checksum_sha256_invalid() { - let data = b"hello"; - let expected = "0000000000000000000000000000000000000000000000000000000000000000"; - assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_err()); - } - - #[test] - fn test_verify_checksum_sha512_valid() { - let data = b"hello"; - let expected = "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"; - assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_ok()); - } - - #[test] - fn test_verify_checksum_sha512_invalid() { - let data = b"hello"; - let expected = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; - assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_err()); - } -} diff --git a/workshop-baker/src/utils/fetch/error.rs b/workshop-baker/src/utils/fetch/error.rs deleted file mode 100644 index fe4ba3d..0000000 --- a/workshop-baker/src/utils/fetch/error.rs +++ /dev/null @@ -1,23 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum FetchError { - #[error("Fetch failed for {name} ({url}): {reason}")] - FetchFailed { - name: String, - url: String, - reason: String, - }, - - #[error("Unknown URL scheme: {0}")] - UnknownScheme(String), - - #[error("Invalid file path: {0}")] - InvalidPath(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("General error: {0}")] - General(String), -} diff --git a/workshop-baker/src/utils/fetch/extract.rs b/workshop-baker/src/utils/fetch/extract.rs deleted file mode 100644 index 8a606c7..0000000 --- a/workshop-baker/src/utils/fetch/extract.rs +++ /dev/null @@ -1,169 +0,0 @@ -// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. -use super::error::FetchError; -use std::path::{Path, PathBuf}; -use tokio::task; - -/// Compression type for tar archives. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CompressionType { - /// Gzip compressed (.tar.gz, .tgz) - Gzip, - /// Zstd compressed (.tar.zst, .tar.zstd, .tzst) - Zstd, - /// Uncompressed tar (.tar) - None, -} - -impl CompressionType { - /// Detects compression type from file path extension. - /// - /// Supports: .tar.gz, .tgz (Gzip), .tar.zst, .tar.zstd, .tzst (Zstd), .tar (None). - pub fn from_path(path: &std::path::Path) -> Result { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_lowercase(); - - if name.ends_with(".tar.gz") || name.ends_with(".tgz") { - Ok(CompressionType::Gzip) - } else if name.ends_with(".tar.zst") - || name.ends_with(".tar.zstd") - || name.ends_with(".tzst") - { - Ok(CompressionType::Zstd) - } else if name.ends_with(".tar") { - Ok(CompressionType::None) - } else { - Err(format!( - "Unsupported archive format: '{}'. Supported formats: .tar, .tar.gz, .tgz, .tar.zst, .tar.zstd, .tzst", - name - )) - } - } -} - -/// Extracts a tar archive to the destination directory. -/// -/// Removes existing destination directory if present, creates a fresh directory, -/// then unpacks the archive using the appropriate decompression based on `compression`. -pub async fn extract_tar( - archive: &Path, - destdir: &PathBuf, - compression: CompressionType, -) -> Result<(), FetchError> { - if destdir.exists() { - tokio::fs::remove_dir_all(destdir).await.map_err(|e| { - FetchError::General(format!("Failed to remove existing directory: {}", e)) - })?; - } - tokio::fs::create_dir_all(destdir) - .await - .map_err(|e| FetchError::General(format!("Failed to create directory: {}", e)))?; - - let archive = archive.to_path_buf(); - let destdir = destdir.clone(); - - task::spawn_blocking(move || { - let file = std::fs::File::open(&archive) - .map_err(|e| FetchError::General(format!("Failed to open archive: {}", e)))?; - - match compression { - CompressionType::Gzip => { - let decoder = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(decoder); - archive.unpack(&destdir).map_err(|e| { - FetchError::General(format!("Failed to unpack archive: {}", e)) - })?; - } - CompressionType::Zstd => { - let decoder = zstd::stream::read::Decoder::new(file).map_err(|e| { - FetchError::General(format!("Failed to create zstd decoder: {}", e)) - })?; - let mut archive = tar::Archive::new(decoder); - archive.unpack(&destdir).map_err(|e| { - FetchError::General(format!("Failed to unpack archive: {}", e)) - })?; - } - CompressionType::None => { - let mut archive = tar::Archive::new(file); - archive.unpack(&destdir).map_err(|e| { - FetchError::General(format!("Failed to unpack archive: {}", e)) - })?; - } - }; - - Ok::<(), FetchError>(()) - }) - .await - .map_err(|e| FetchError::General(format!("Task join error: {}", e)))??; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - #[test] - fn test_from_path_tar_gz() { - assert_eq!( - CompressionType::from_path(Path::new("archive.tar.gz")).unwrap(), - CompressionType::Gzip - ); - } - - #[test] - fn test_from_path_tgz() { - assert_eq!( - CompressionType::from_path(Path::new("archive.tgz")).unwrap(), - CompressionType::Gzip - ); - } - - #[test] - fn test_from_path_tar_zst() { - assert_eq!( - CompressionType::from_path(Path::new("archive.tar.zst")).unwrap(), - CompressionType::Zstd - ); - } - - #[test] - fn test_from_path_tar_zstd() { - assert_eq!( - CompressionType::from_path(Path::new("archive.tar.zstd")).unwrap(), - CompressionType::Zstd - ); - } - - #[test] - fn test_from_path_tzst() { - assert_eq!( - CompressionType::from_path(Path::new("archive.tzst")).unwrap(), - CompressionType::Zstd - ); - } - - #[test] - fn test_from_path_tar() { - assert_eq!( - CompressionType::from_path(Path::new("archive.tar")).unwrap(), - CompressionType::None - ); - } - - #[test] - fn test_from_path_unsupported() { - assert!(CompressionType::from_path(Path::new("archive.zip")).is_err()); - } - - #[test] - fn test_from_path_case_insensitive() { - assert_eq!( - CompressionType::from_path(Path::new("archive.TAR.GZ")).unwrap(), - CompressionType::Gzip - ); - } -} diff --git a/workshop-baker/src/utils/fetch/file.rs b/workshop-baker/src/utils/fetch/file.rs deleted file mode 100644 index f349b56..0000000 --- a/workshop-baker/src/utils/fetch/file.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::path::{Path, PathBuf}; - -use super::error::FetchError; - -/// Copy a local file (resolved from file://) to the target directory. -/// Uses std::fs::copy, NOT symlink. -pub async fn fetch_file(path: &Path, name: &str, target: &Path) -> Result { - if !path.exists() { - return Err(FetchError::InvalidPath(path.display().to_string())); - } - - std::fs::create_dir_all(target).map_err(FetchError::Io)?; - - let dest = if path.is_dir() { - let dest = target.join(name); - copy_dir_all(path, &dest).map_err(FetchError::Io)?; - dest - } else { - let dest = target - .join(name) - .join(path.file_name().ok_or(FetchError::InvalidPath( - "no filename".into(), - ))?); - std::fs::create_dir_all(dest.parent().unwrap_or(target)).map_err(FetchError::Io)?; - std::fs::copy(path, &dest).map_err(FetchError::Io)?; - dest - }; - - Ok(dest) -} - -fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let ty = entry.file_type()?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if ty.is_dir() { - copy_dir_all(&src_path, &dst_path)?; - } else { - std::fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) -} diff --git a/workshop-baker/src/utils/fetch/git.rs b/workshop-baker/src/utils/fetch/git.rs deleted file mode 100644 index 5a9448b..0000000 --- a/workshop-baker/src/utils/fetch/git.rs +++ /dev/null @@ -1,185 +0,0 @@ -// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. -use super::error::FetchError; -use std::path::Path; -use tokio::process::Command; - -/// Version specification for a git repository. -#[derive(Debug, PartialEq)] -pub enum GitVersion { - /// No version specified (invalid for fetch operations) - None, - /// Single version string (tag or commit hash) - Single(String), - /// Mixed: try tag first, fallback to commit hash - Mixed(String, String), -} - -/// Parses a git URL with optional version specification. -/// -/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version). -/// Returns (base_url, GitVersion). -pub fn parse_git_url(url: &str) -> (String, GitVersion) { - if let Some((base, version)) = url.rsplit_once('@') { - if version.contains(":") { - let (tag, commit) = version.split_once(':').unwrap(); - return ( - base.to_string(), - GitVersion::Mixed(tag.to_string(), commit.to_string()), - ); - } - return (base.to_string(), GitVersion::Single(version.to_string())); - } - (url.to_string(), GitVersion::None) -} - -/// Clones a git repository and checks out the specified version. -/// -/// Removes existing destination directory, performs shallow or full clone, -/// then checks out the given version (tag or commit). -pub async fn git_clone_and_checkout( - baseurl: &str, - version: &str, - dest: &Path, - name: &str, - shallow: bool, -) -> Result<(), FetchError> { - if dest.exists() { - tokio::fs::remove_dir_all(dest) - .await - .map_err(|e| FetchError::FetchFailed { - name: name.to_string(), - url: baseurl.to_string(), - reason: format!("Failed to remove existing directory: {}", e), - })?; - } - let mut cmd = Command::new("git"); - cmd.arg("clone"); - if shallow { - cmd.arg("--depth").arg("1"); - } - cmd.arg(baseurl).arg(dest); - let output = cmd.output().await.map_err(|e| FetchError::FetchFailed { - name: name.to_string(), - url: baseurl.to_string(), - reason: e.to_string(), - })?; - if !output.status.success() { - return Err(FetchError::FetchFailed { - name: name.to_string(), - url: baseurl.to_string(), - reason: String::from_utf8_lossy(&output.stderr).to_string(), - }); - } - let mut cmd = Command::new("git"); - cmd.arg("checkout").arg(version).current_dir(dest); - let output = cmd.output().await.map_err(|e| FetchError::FetchFailed { - name: name.to_string(), - url: baseurl.to_string(), - reason: e.to_string(), - })?; - if !output.status.success() { - return Err(FetchError::FetchFailed { - name: name.to_string(), - url: baseurl.to_string(), - reason: String::from_utf8_lossy(&output.stderr).to_string(), - }); - } - Ok(()) -} - -/// Fetches a plugin from a git repository. -/// -/// Parses the URL for version info, then clones and checks out the appropriate ref. -/// For Mixed version, tries the tag first and falls back to the commit hash. -pub async fn fetch_git_plugin( - path: &str, - name: &str, - basedir: &Path, - shallow: Option, -) -> Result<(), FetchError> { - let (baseurl, version) = parse_git_url(path); - match version { - GitVersion::None => { - log::error!("Version should be specified for git plugin URL: {}", path); - Err(FetchError::FetchFailed { - name: name.to_string(), - url: path.to_string(), - reason: "Version should be specified as either a tag or a commit hash.".to_string(), - }) - } - GitVersion::Single(version) => { - log::debug!("Fetching git plugin: {}@{}", name, version); - let shallow_flag = shallow.unwrap_or(false); - git_clone_and_checkout( - &baseurl, - &version.to_string(), - &basedir.join(name), - name, - shallow_flag, - ) - .await - } - GitVersion::Mixed(tag, commit) => { - log::debug!("Fetching git plugin: {}@{}/{}", name, tag, commit); - let shallow_flag = shallow.unwrap_or(false); - let result = - git_clone_and_checkout(&baseurl, &tag, &basedir.join(name), name, shallow_flag) - .await; - if result.is_err() { - log::warn!( - "Failed to clone with tag, trying commit hash. URL: {}", - path - ); - return git_clone_and_checkout(&baseurl, &commit, &basedir.join(name), name, false) - .await; - } - result - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_git_url_no_version() { - let (url, version) = parse_git_url("https://github.com/user/repo"); - assert_eq!(url, "https://github.com/user/repo"); - assert_eq!(version, GitVersion::None); - } - - #[test] - fn test_parse_git_url_single_tag() { - let (url, version) = parse_git_url("https://github.com/user/repo@v1.0.0"); - assert_eq!(url, "https://github.com/user/repo"); - assert_eq!(version, GitVersion::Single("v1.0.0".to_string())); - } - - #[test] - fn test_parse_git_url_mixed_version() { - let (url, version) = parse_git_url("https://github.com/user/repo@main:abc123"); - assert_eq!(url, "https://github.com/user/repo"); - assert_eq!( - version, - GitVersion::Mixed("main".to_string(), "abc123".to_string()) - ); - } - - #[test] - fn test_parse_git_url_complex_url() { - let (url, version) = parse_git_url("git@github.com:user/repo.git@tag:commit"); - assert_eq!(url, "git@github.com:user/repo.git"); - assert_eq!( - version, - GitVersion::Mixed("tag".to_string(), "commit".to_string()) - ); - } - - #[test] - fn test_parse_git_url_single_with_at_in_path() { - let (url, version) = parse_git_url("https://github.com/user/repo@my-repo@v1.0"); - assert_eq!(url, "https://github.com/user/repo@my-repo"); - assert_eq!(version, GitVersion::Single("v1.0".to_string())); - } -} diff --git a/workshop-baker/src/utils/fetch/http.rs b/workshop-baker/src/utils/fetch/http.rs deleted file mode 100644 index 40913f7..0000000 --- a/workshop-baker/src/utils/fetch/http.rs +++ /dev/null @@ -1,110 +0,0 @@ -// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. -use super::checksum::{detect_checksum_type, verify_checksum}; -use super::extract::{CompressionType, extract_tar}; -use super::error::FetchError; -use std::path::Path; - -pub async fn download_to_file(url: &str, dest: &Path, name: &str) -> Result<(), FetchError> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .map_err(|e| FetchError::General(format!("Failed to build HTTP client: {}", e)))?; - - let response = client - .get(url) - .send() - .await - .map_err(|e| FetchError::FetchFailed { - name: name.to_string(), - url: url.to_string(), - reason: format!("HTTP request failed: {}", e), - })?; - - let status = response.status(); - if !status.is_success() { - return Err(FetchError::FetchFailed { - name: name.to_string(), - url: url.to_string(), - reason: format!("HTTP error: {}", status), - }); - } - - let bytes = response - .bytes() - .await - .map_err(|e| FetchError::FetchFailed { - name: name.to_string(), - url: url.to_string(), - reason: format!("Failed to read response body: {}", e), - })?; - - tokio::fs::write(dest, &bytes) - .await - .map_err(|e| FetchError::FetchFailed { - name: name.to_string(), - url: url.to_string(), - reason: format!("Failed to write file: {}", e), - })?; - - Ok(()) -} - -fn extract_archive_extension(url: &str) -> &str { - let path_part = url - .split('?') - .next() - .unwrap_or(url) - .split('#') - .next() - .unwrap_or(url); - let file_part = path_part.rsplit('/').next().unwrap_or(""); - let lower = file_part.to_lowercase(); - if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { - ".tar.gz" - } else if lower.ends_with(".tar.zst") - || lower.ends_with(".tar.zstd") - || lower.ends_with(".tzst") - { - ".tar.zst" - } else if lower.ends_with(".tar") { - ".tar" - } else { - ".unknown" - } -} - -pub async fn fetch_http_plugin( - url: &str, - name: &str, - basedir: &Path, - checksum_str: Option<&str>, -) -> Result<(), FetchError> { - let temp_dir = std::env::temp_dir().join("workshop-plugins"); - tokio::fs::create_dir_all(&temp_dir).await.ok(); - let ext = extract_archive_extension(url); - let temp_file = temp_dir.join(format!("{}-{}{}", name, uuid::Uuid::new_v4(), ext)); - - download_to_file(url, &temp_file, name).await?; - - if let Some(checksum) = checksum_str - && !checksum.is_empty() - { - let bytes = tokio::fs::read(&temp_file).await.map_err(|e| { - FetchError::General(format!("Failed to read downloaded file: {}", e)) - })?; - - let checksum_type = detect_checksum_type(checksum).map_err(FetchError::General)?; - - if let Some(ct) = checksum_type { - verify_checksum(&bytes, checksum, ct).map_err(FetchError::General)?; - } - } - - let compression = CompressionType::from_path(&temp_file).map_err(FetchError::General)?; - let destdir = basedir.join(name); - extract_tar(&temp_file, &destdir, compression).await?; - - tokio::fs::remove_file(&temp_file).await.ok(); - - Ok(()) -} diff --git a/workshop-baker/src/utils/fetch/scheme.rs b/workshop-baker/src/utils/fetch/scheme.rs deleted file mode 100644 index a9884ea..0000000 --- a/workshop-baker/src/utils/fetch/scheme.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::path::PathBuf; - -use super::error::FetchError; - -pub enum Scheme { - Git { repo: String, git_ref: String }, - Http { url: String, checksum: Option }, - File { path: PathBuf }, -} - -/// Parse a URL into a Scheme. -/// Handles: git://, http://, https://, file:// -/// workspace:// must be resolved by the caller to file:// before calling. -pub fn parse_url(url: &str) -> Result { - let (scheme_str, rest) = url - .split_once("://") - .ok_or_else(|| FetchError::UnknownScheme(url.to_string()))?; - - match scheme_str { - "git" => { - // Format: git://host/path@tag or git://host/path@tag:commit - let (base, version) = rest - .rsplit_once('@') - .map(|(b, v)| (b.to_string(), v.to_string())) - .unwrap_or_else(|| (rest.to_string(), String::new())); - Ok(Scheme::Git { - repo: format!("git://{}", base), - git_ref: version, - }) - } - "http" | "https" => Ok(Scheme::Http { - url: url.to_string(), - checksum: None, - }), - "file" => { - let path = PathBuf::from(rest); - Ok(Scheme::File { path }) - } - _ => Err(FetchError::UnknownScheme(scheme_str.to_string())), - } -} diff --git a/workshop-baker/src/utils/fetch/types.rs b/workshop-baker/src/utils/fetch/types.rs deleted file mode 100644 index d4d740c..0000000 --- a/workshop-baker/src/utils/fetch/types.rs +++ /dev/null @@ -1,84 +0,0 @@ -// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. -use serde::{Deserialize, Serialize}; - -/// Supported checksum types for verifying downloaded artifacts. -#[derive(Debug, Clone, PartialEq)] -pub enum ChecksumType { - /// SHA-256 hash (64 hex characters) - Sha256, - /// SHA-512 hash (128 hex characters) - Sha512, -} - -/// Arguments for fetching a plugin artifact. -/// -/// Contains optional checksum verification and shallow clone settings. -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct FetchArgument { - /// SHA256 or SHA512 hex string for integrity verification. - #[serde(default)] - pub checksum: Option, - /// Perform a shallow clone (single revision only). - #[serde(default)] - pub shallow: Option, -} - -impl FetchArgument { - /// Returns the checksum if present and non-empty. - pub fn get_checksum(&self) -> Option<&str> { - self.checksum.as_deref().filter(|s| !s.is_empty()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_checksum_some_non_empty() { - let arg = FetchArgument { - checksum: Some("abc123".to_string()), - shallow: None, - }; - assert_eq!(arg.get_checksum(), Some("abc123")); - } - - #[test] - fn test_get_checksum_empty_string() { - let arg = FetchArgument { - checksum: Some("".to_string()), - shallow: None, - }; - assert_eq!(arg.get_checksum(), None); - } - - #[test] - fn test_get_checksum_none() { - let arg = FetchArgument { - checksum: None, - shallow: None, - }; - assert_eq!(arg.get_checksum(), None); - } - - #[test] - fn test_serde_roundtrip() { - let arg = FetchArgument { - checksum: Some("deadbeef".to_string()), - shallow: Some(true), - }; - let json = serde_json::to_string(&arg).unwrap(); - let parsed: FetchArgument = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.checksum, arg.checksum); - assert_eq!(parsed.shallow, arg.shallow); - } - - #[test] - fn test_serde_roundtrip_empty() { - let arg = FetchArgument::default(); - let json = serde_json::to_string(&arg).unwrap(); - let parsed: FetchArgument = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.checksum, arg.checksum); - assert_eq!(parsed.shallow, arg.shallow); - } -} diff --git a/workshop-baker/src/utils/fetch_plugin.rs b/workshop-baker/src/utils/fetch_plugin.rs deleted file mode 100644 index e09aa50..0000000 --- a/workshop-baker/src/utils/fetch_plugin.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::collections::HashSet; -use std::path::Path; - -use crate::finalize::RawPluginMap; -use crate::utils::fetch::{self, FetchError}; - -pub use crate::utils::fetch::FetchArgument; - -/// Fetch a single plugin by URL. -/// -/// Supports: -/// - `workspace://rel/path` → resolved to `file:///abs/path` -/// - `git://host/repo@tag` → git clone -/// - `https://url/archive.tar.gz` → HTTP download + checksum + extract -/// - `file:///path` → local file copy -pub async fn fetch_plugin( - url: &str, - name: &str, - basedir: &Path, - _argument: FetchArgument, -) -> Result<(), FetchError> { - let resolved_url = if let Some(rel) = url.strip_prefix("workspace://") { - format!( - "file://{}", - std::env::current_dir() - .unwrap_or_default() - .join(rel) - .display() - ) - } else { - url.to_string() - }; - fetch::fetch_resource(name, &resolved_url, basedir) - .await - .map(|_| ()) -} - -/// Fetch all listed plugins from a RawPluginMap. -/// -/// Iterates over the plugin map, fetches each plugin that is in the -/// optional `list`, and updates `config.runtime.fspath` with the -/// downloaded path. -pub async fn fetch_plugins( - plugin_path: &Path, - plugin_config: &mut RawPluginMap, - list: Option<&HashSet>, -) -> Result<(), FetchError> { - log::info!("Preparing to download {} plugins", plugin_config.len()); - for (name, data) in plugin_config.iter_mut() { - if let Some(list) = list { - if !list.contains(name) { - log::debug!("Plugin {} not in list, skipping", name); - continue; - } - } - log::debug!("Downloading plugin {}", name); - let (source, config) = data.iter_mut().next().ok_or_else(|| { - FetchError::FetchFailed { - name: name.clone(), - url: String::new(), - reason: "no source configured".to_string(), - } - })?; - let fetch_argument = FetchArgument { - checksum: config.checksum.clone(), - shallow: config.shallow, - }; - fetch_plugin(source, name, plugin_path, fetch_argument).await?; - log::debug!("Storing plugin {} to {}", name, plugin_path.display()); - config.runtime.fspath = Some(plugin_path.join(name)); - } - Ok(()) -} diff --git a/workshop-getterurl/Cargo.lock b/workshop-getterurl/Cargo.lock index d7bd9cc..d422c5e 100644 --- a/workshop-getterurl/Cargo.lock +++ b/workshop-getterurl/Cargo.lock @@ -581,6 +581,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -970,6 +980,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1566,7 +1587,9 @@ name = "workshop-getterurl" version = "0.1.0" dependencies = [ "log", + "md-5", "reqwest", + "sha1", "sha2", "thiserror", "tokio", diff --git a/workshop-getterurl/Cargo.toml b/workshop-getterurl/Cargo.toml index 4cc63a9..421ddb1 100644 --- a/workshop-getterurl/Cargo.toml +++ b/workshop-getterurl/Cargo.toml @@ -5,8 +5,15 @@ edition = "2024" [dependencies] log = "0.4.29" +md-5 = { version = "0.11.0", optional = true } reqwest = { version = "0.13.3", default-features = false, features = ["rustls"] } +sha1 = { version = "0.11.0", optional = true } sha2 = "0.11.0" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["process", "io-util", "fs", "rt-multi-thread", "macros"] } url = "2.5.8" + +[features] +default = ["md5", "sha1"] +md5 = ["dep:md-5"] +sha1 = ["dep:sha1"] diff --git a/workshop-getterurl/src/builtin/mod.rs b/workshop-getterurl/src/builtin.rs similarity index 100% rename from workshop-getterurl/src/builtin/mod.rs rename to workshop-getterurl/src/builtin.rs diff --git a/workshop-getterurl/src/builtin/http.rs b/workshop-getterurl/src/builtin/http.rs index b19b489..a406295 100644 --- a/workshop-getterurl/src/builtin/http.rs +++ b/workshop-getterurl/src/builtin/http.rs @@ -1,4 +1,8 @@ use sha2::Digest; +#[cfg(feature = "md5")] +use md5::Md5; +#[cfg(feature = "sha1")] +use sha1::Sha1; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -77,28 +81,42 @@ fn extract_filename(url: &Url) -> String { .to_string() } -/// Verify data against spec like "sha256:abc123". Prefix optional (guessed by length). +/// Verify data against spec like "sha256:abc123". +/// Prefix optional — guessed by length: MD5(32), SHA1(40), SHA256(64), SHA512(128). +/// MD5 and SHA1 require `--features md5,sha1` (enabled by default). +/// Other lengths produce an explicit error. fn verify_checksum(data: &[u8], spec: &str) -> Result<(), GetterError> { let (algo, expected) = if let Some((prefix, value)) = spec.split_once(':') { (prefix, value) } else { let trimmed = spec.trim(); match trimmed.len() { + #[cfg(feature = "md5")] + 32 => ("md5", trimmed), + #[cfg(not(feature = "md5"))] + 32 => return Err(GetterError::General("MD5 not supported; enable `md5` feature".into())), + #[cfg(feature = "sha1")] + 40 => ("sha1", trimmed), + #[cfg(not(feature = "sha1"))] + 40 => return Err(GetterError::General("SHA1 not supported; enable `sha1` feature".into())), 64 => ("sha256", trimmed), 128 => ("sha512", trimmed), - _ => ("sha256", trimmed), + n => { + return Err(GetterError::General(format!( + "checksum length {} cannot be auto-detected; use prefix like sha256:, sha512:", + n + ))); + } } }; let actual_hex: String = match algo { - "sha256" => sha2::Sha256::digest(data) - .iter() - .map(|b| format!("{:02x}", b)) - .collect(), - "sha512" => sha2::Sha512::digest(data) - .iter() - .map(|b| format!("{:02x}", b)) - .collect(), + #[cfg(feature = "md5")] + "md5" => Md5::digest(data).iter().map(|b| format!("{:02x}", b)).collect(), + #[cfg(feature = "sha1")] + "sha1" => Sha1::digest(data).iter().map(|b| format!("{:02x}", b)).collect(), + "sha256" => sha2::Sha256::digest(data).iter().map(|b| format!("{:02x}", b)).collect(), + "sha512" => sha2::Sha512::digest(data).iter().map(|b| format!("{:02x}", b)).collect(), _ => return Err(GetterError::General(format!("Unknown algorithm: {}", algo))), }; @@ -142,4 +160,34 @@ mod tests { let spec = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; verify_checksum(data, spec).unwrap(); } + + #[cfg(feature = "md5")] + #[test] + fn test_verify_md5() { + let data = b"hello"; + let spec = "md5:5d41402abc4b2a76b9719d911017c592"; + verify_checksum(data, spec).unwrap(); + } + + #[cfg(feature = "sha1")] + #[test] + fn test_verify_sha1() { + let data = b"hello"; + let spec = "sha1:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"; + verify_checksum(data, spec).unwrap(); + } + + #[cfg(feature = "md5")] + #[test] + fn test_verify_md5_no_prefix() { + let data = b"hello"; + let spec = "5d41402abc4b2a76b9719d911017c592"; + verify_checksum(data, spec).unwrap(); + } + + #[test] + fn test_verify_unknown_length_errors() { + let spec = "0".repeat(50); + assert!(verify_checksum(b"data", &spec).is_err()); + } }