diff --git a/Cargo.lock b/Cargo.lock index dd048e1..72a8c22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,7 @@ dependencies = [ "sha2", "tar", "tempfile", + "thiserror", "tokio", "users", "which", diff --git a/crates/ecr-cli/src/main.rs b/crates/ecr-cli/src/main.rs index af9cbee..0b1bf74 100644 --- a/crates/ecr-cli/src/main.rs +++ b/crates/ecr-cli/src/main.rs @@ -96,7 +96,7 @@ fn main() -> Result<()> { log::debug!("Cleanup complete."); } - result + result.map_err(anyhow::Error::from) } else { // Namespace/chroot mode let exit_code = namespace_mode(&args, rootfs.dir(), &config, &arch)?; @@ -122,7 +122,7 @@ fn namespace_mode(args: &Args, rootfs: &Path, config: &Config, arch: &str) -> Re working_dir, }; - let exit_code = ecr::exec(rootfs, &opts); + let exit_code = ecr::exec(rootfs, &opts).map_err(anyhow::Error::from); // Cleanup happens automatically via tempfile if exit_code.is_ok() { diff --git a/crates/ecr/Cargo.toml b/crates/ecr/Cargo.toml index 6ce0b09..ae884ad 100644 --- a/crates/ecr/Cargo.toml +++ b/crates/ecr/Cargo.toml @@ -35,6 +35,7 @@ tempfile = "3" # Error handling anyhow = "1" +thiserror = "2" # Utilities dirs = "6" diff --git a/crates/ecr/src/chroot.rs b/crates/ecr/src/chroot.rs index d6b1811..aa62cfb 100644 --- a/crates/ecr/src/chroot.rs +++ b/crates/ecr/src/chroot.rs @@ -1,4 +1,6 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; + +use crate::Result; use nix::unistd::{chroot, execve}; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; @@ -59,7 +61,7 @@ pub fn run_chroot( std::ffi::CString::new(s.as_str()) .with_context(|| format!("Argument contains a null byte: {:?}", s)) }) - .collect::>>()?; + .collect::>>()?; (program, args) } _ => { @@ -79,7 +81,7 @@ pub fn run_chroot( std::ffi::CString::new(format!("{}={}", k, v)) .with_context(|| format!("Environment variable contains a null byte: {}={}", k, v)) }) - .collect::>>()?; + .collect::>>()?; std::env::set_current_dir(working_dir).with_context(|| { format!( @@ -105,14 +107,14 @@ pub fn run_chroot( match result { Ok(_) => Ok(()), // Never reached - Err(e) => Err(anyhow!("Failed to exec {}: {}", program_path.display(), e)), + Err(e) => Err(anyhow!("Failed to exec {}: {}", program_path.display(), e).into()), } } /// Resolve the program to exec. Commands containing a '/' are used as-is /// (existing check gives a clearer error than a raw ENOENT); a bare command /// name is looked up in the caller-composed PATH, like execvp(3) would. -fn resolve_program(program: &str, env: &[(String, String)]) -> Result { +fn resolve_program(program: &str, env: &[(String, String)]) -> anyhow::Result { if program.contains('/') { let path = PathBuf::from(program); if !path.exists() { diff --git a/crates/ecr/src/config.rs b/crates/ecr/src/config.rs index 836ac1f..403da61 100644 --- a/crates/ecr/src/config.rs +++ b/crates/ecr/src/config.rs @@ -1,4 +1,6 @@ -use anyhow::{Context, Result}; +use anyhow::Context; + +use crate::Result; use serde::Deserialize; #[derive(Debug, Deserialize, Default)] diff --git a/crates/ecr/src/distro.rs b/crates/ecr/src/distro.rs index 84c3f9c..3c5984f 100644 --- a/crates/ecr/src/distro.rs +++ b/crates/ecr/src/distro.rs @@ -1,4 +1,6 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; + +use crate::Result; /// Known distributions with optimized direct tarball downloads #[derive(Debug, Clone, Copy, PartialEq)] @@ -29,7 +31,7 @@ impl Distro { match name.to_lowercase().as_str() { "ubuntu" => Ok(Distro::Ubuntu), "alpine" => Ok(Distro::Alpine), - _ => Err(anyhow!("Unknown distribution: {}", name)), + _ => Err(anyhow!("Unknown distribution: {}", name).into()), } } } @@ -48,12 +50,12 @@ pub fn parse_image_ref(input: &str, arch: &str) -> Result { .strip_prefix("docker://") .or_else(|| input.strip_prefix("oci://")) { - return parse_oci_ref(rest, arch); + return Ok(parse_oci_ref(rest, arch)?); } // Check for registry prefix (contains /) if input.contains('/') { - return parse_oci_ref(input, arch); + return Ok(parse_oci_ref(input, arch)?); } // Try to parse as known distro @@ -89,12 +91,12 @@ pub fn parse_image_ref(input: &str, arch: &str) -> Result { }) } // Default to Docker Hub for unknown distros (debian, fedora, etc.) - _ => parse_oci_ref(input, arch), + _ => Ok(parse_oci_ref(input, arch)?), } } /// Parse OCI image reference (registry/repo:tag or repo:tag) -fn parse_oci_ref(input: &str, arch: &str) -> Result { +fn parse_oci_ref(input: &str, arch: &str) -> anyhow::Result { // Split off the tag. A ':' is a tag separator only when it appears after // the last '/'; any ':' before a '/' is a port number // (e.g. localhost:5000/image). With no '/' the single ':' is the tag. @@ -158,10 +160,10 @@ pub fn map_arch(distro: Distro, arch: &str) -> String { /// Resolve the download URL for a known distro (optimized path) pub fn resolve_distro_url(distro: &Distro, version: Option<&str>, arch: &str) -> Result { - match distro { + Ok(match distro { Distro::Ubuntu => resolve_ubuntu_url(version, arch), Distro::Alpine => resolve_alpine_url(version, arch), - } + }?) } /// URL of the checksum artifact covering the tarball at `tarball_url`, when @@ -179,7 +181,7 @@ pub fn checksum_artifact_url(distro: &Distro, tarball_url: &str) -> Option Result { +fn fetch_ubuntu_codename(meta_release_url: &str) -> anyhow::Result { let text = reqwest::blocking::get(meta_release_url) .with_context(|| format!("Failed to fetch {}", meta_release_url))? .text() @@ -211,7 +213,7 @@ fn fetch_ubuntu_codename(meta_release_url: &str) -> Result { /// tells us the exact version number needed for the tarball filename. /// Fetch the latest minirootfs version for a given Alpine CDN branch. /// `branch` is the directory name on the CDN, e.g. `"latest-stable"` or `"v3.23"`. -fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result { +fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> anyhow::Result { #[derive(serde::Deserialize)] struct AlpineRelease { file: Option, @@ -259,13 +261,13 @@ fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result )) } -fn fetch_alpine_latest_version(arch: &str) -> Result { +fn fetch_alpine_latest_version(arch: &str) -> anyhow::Result { fetch_alpine_version_from_branch("latest-stable", arch) } /// Resolve a `major.minor` Alpine series (e.g. `"3.24"`) to its current /// patch release by querying the CDN branch `v{minor}`. -fn fetch_alpine_minor_version(minor: &str, arch: &str) -> Result { +fn fetch_alpine_minor_version(minor: &str, arch: &str) -> anyhow::Result { fetch_alpine_version_from_branch(&format!("v{}", minor), arch) } @@ -277,16 +279,16 @@ pub fn resolve_distro_version( version: Option<&str>, arch: &str, ) -> Result { - match distro { + Ok(match distro { Distro::Ubuntu => resolve_ubuntu_version(version), Distro::Alpine => resolve_alpine_version(version, arch), - } + }?) } /// Build a map of YY.MM version strings to codenames by parsing the Ubuntu /// meta-release file (e.g. "24.04" → "noble", "22.04" → "jammy"). /// Uses the full meta-release (not -lts) so non-LTS versions are also covered. -fn fetch_ubuntu_version_map() -> Result> { +fn fetch_ubuntu_version_map() -> anyhow::Result> { let text = reqwest::blocking::get("https://changelogs.ubuntu.com/meta-release") .context("Failed to fetch Ubuntu meta-release")? .text() @@ -318,7 +320,7 @@ fn fetch_ubuntu_version_map() -> Result) -> Result { +fn resolve_ubuntu_version(version: Option<&str>) -> anyhow::Result { let version = version.unwrap_or("latest"); match version { "latest" => fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release"), @@ -358,7 +360,7 @@ fn is_ubuntu_version_number(s: &str) -> bool { ) } -fn resolve_alpine_version(version: Option<&str>, arch: &str) -> Result { +fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result { let alpine_arch = map_arch(Distro::Alpine, arch); Ok(match version.unwrap_or("latest") { "latest" | "stable" => fetch_alpine_latest_version(&alpine_arch)?, @@ -380,7 +382,7 @@ fn resolve_alpine_version(version: Option<&str>, arch: &str) -> Result { }) } -fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result { +fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> anyhow::Result { let codename = resolve_ubuntu_version(version)?; let arch = map_arch(Distro::Ubuntu, arch); Ok(format!( @@ -389,7 +391,7 @@ fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result { )) } -fn resolve_alpine_url(version: Option<&str>, arch: &str) -> Result { +fn resolve_alpine_url(version: Option<&str>, arch: &str) -> anyhow::Result { let version_str = version.unwrap_or("latest"); let alpine_arch = map_arch(Distro::Alpine, arch); let version_num = resolve_alpine_version(Some(version_str), arch)?; diff --git a/crates/ecr/src/download.rs b/crates/ecr/src/download.rs index e1208a1..c643596 100644 --- a/crates/ecr/src/download.rs +++ b/crates/ecr/src/download.rs @@ -1,5 +1,5 @@ use crate::progress::{Progress, Unit}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; use futures_util::StreamExt; use reqwest::Client; use serde_json::Value; @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use crate::distro::{Distro, ImageSource}; +use crate::Result; /// Path of the digest sidecar file for a cached OCI image. /// e.g. `foo.tar.gz` → `foo.tar.gz.digest` @@ -42,7 +43,7 @@ impl DownloadHasher { /// a bare sha256 hex string (distro checksum files); None verifies nothing. /// Algorithms outside sha256/sha512 cannot be checked: warn and accept /// rather than reject images a registry may legitimately serve. - fn verify(&self, expected: Option<&str>) -> Result<()> { + fn verify(&self, expected: Option<&str>) -> anyhow::Result<()> { let Some(expected) = expected else { return Ok(()); }; @@ -84,12 +85,17 @@ fn hex_encode(bytes: &[u8]) -> String { /// layers. The registry returns the content-addressable digest in the /// `Docker-Content-Digest` response header of any manifest GET. pub fn fetch_oci_digest(registry: &str, repository: &str, tag: &str) -> Result { - tokio::runtime::Runtime::new() + let digest = tokio::runtime::Runtime::new() .context("Failed to create Tokio runtime")? - .block_on(fetch_oci_digest_async(registry, repository, tag)) + .block_on(fetch_oci_digest_async(registry, repository, tag))?; + Ok(digest) } -async fn fetch_oci_digest_async(registry: &str, repository: &str, tag: &str) -> Result { +async fn fetch_oci_digest_async( + registry: &str, + repository: &str, + tag: &str, +) -> anyhow::Result { let client = Client::builder() .timeout(Duration::from_secs(30)) .build() @@ -153,7 +159,7 @@ pub fn download_image( let url = crate::distro::resolve_distro_url(distro, version.as_deref(), arch)?; log::debug!("Resolved URL: {}", url); if !url.starts_with("http://") && !url.starts_with("https://") { - return Err(anyhow!("Unsupported URL scheme: {}", url)); + return Err(anyhow!("Unsupported URL scheme: {}", url).into()); } // Verify the tarball against the checksum the distro publishes // next to it. A fetch failure (missing artifact, network error) @@ -170,12 +176,12 @@ pub fn download_image( None } }; - rt.block_on(download_file_async( + Ok(rt.block_on(download_file_async( &url, dest, progress, expected.as_deref(), - )) + ))?) } ImageSource::OciImage { registry, @@ -184,20 +190,20 @@ pub fn download_image( architecture, } => { log::debug!("Pulling OCI image: {}/{}:{}", registry, repository, tag); - rt.block_on(download_oci_image_async( + Ok(rt.block_on(download_oci_image_async( registry, repository, tag, architecture, dest, progress, - )) + ))?) } } } /// Fetch the sha256 the distro publishes for the tarball at `tarball_url`. -async fn fetch_published_checksum(distro: &Distro, tarball_url: &str) -> Result { +async fn fetch_published_checksum(distro: &Distro, tarball_url: &str) -> anyhow::Result { let Some(checksum_url) = crate::distro::checksum_artifact_url(distro, tarball_url) else { return Err(anyhow!("no checksum artifact for {}", tarball_url)); }; @@ -252,7 +258,7 @@ async fn download_file_async( dest: &Path, progress: &dyn Progress, expected: Option<&str>, -) -> Result<()> { +) -> anyhow::Result<()> { log::debug!("Downloading: {}", url); let client = Client::builder() @@ -292,7 +298,7 @@ async fn download_file_async( // reaches the cache; on any failure after the temp file is created we // remove it so stale .partial files don't accumulate. let mut hasher = DownloadHasher::new(); - let result: Result<()> = async { + let result: anyhow::Result<()> = async { let mut file = tokio::fs::File::create(&temp_dest) .await .context("Failed to create temporary file")?; @@ -335,7 +341,7 @@ async fn download_oci_image_async( arch: &str, dest: &Path, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let client = Client::builder() .timeout(Duration::from_secs(300)) .build() @@ -361,7 +367,7 @@ async fn try_download_oci_image( arch: &str, dest: &Path, progress: &dyn Progress, -) -> Result { +) -> anyhow::Result { // Get authentication token for the registry let token = get_auth_token(client, registry, repository).await?; @@ -560,7 +566,7 @@ async fn try_download_oci_image( // using the tar + flate2 crates — no external `tar` binary required. let temp_dest = dest.with_extension("tar.partial"); - let bundle_result: Result<()> = (|| { + let bundle_result: anyhow::Result<()> = (|| { use flate2::{write::GzEncoder, Compression}; let out_file = @@ -597,7 +603,11 @@ async fn try_download_oci_image( Ok(tag_digest) } -async fn get_auth_token(client: &Client, registry: &str, repository: &str) -> Result { +async fn get_auth_token( + client: &Client, + registry: &str, + repository: &str, +) -> anyhow::Result { // Docker Hub has a well-known, stable auth endpoint. if registry == "docker.io" { let url = format!( @@ -697,7 +707,7 @@ fn parse_www_authenticate(header: &str) -> Option<(String, String)> { } /// Fetch a bearer token from a fully-constructed token URL. -async fn fetch_bearer_token(client: &Client, token_url: &str) -> Result { +async fn fetch_bearer_token(client: &Client, token_url: &str) -> anyhow::Result { let response = client .get(token_url) .send() @@ -728,7 +738,7 @@ async fn download_blob_with_auth( label: &str, expected_digest: &str, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let mut request = client.get(url); if !token.is_empty() { request = request.header("Authorization", format!("Bearer {}", token)); diff --git a/crates/ecr/src/error.rs b/crates/ecr/src/error.rs new file mode 100644 index 0000000..3486a82 --- /dev/null +++ b/crates/ecr/src/error.rs @@ -0,0 +1,95 @@ +//! Typed error for the public library API. +//! +//! Public functions return [`Result`] carrying [`Error`] so consumers can +//! match on the failure modes that call for different handling — missing +//! host prerequisites, an architecture the registry does not serve — versus +//! ordinary failures. Everything without a semantic variant surfaces as +//! [`Error::Other`], which preserves the full anyhow context chain. + +/// Errors returned by the ecr library API. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Unprivileged user namespaces are disabled on this host. The + /// remediation names the sysctl to set (the condition has two causes: + /// `kernel.unprivileged_userns_clone=0` and `user.max_user_namespaces=0`). + #[error("user namespaces not available\n\n{remediation}")] + UserNamespacesUnavailable { remediation: String }, + + /// binfmt_misc is not registered for the target architecture, so + /// foreign-architecture binaries cannot run without QEMU user emulation. + #[error( + "binfmt_misc not registered for {arch}\n\n\ + Install QEMU user emulation:\n\ + \x20 Ubuntu/Debian: sudo apt install qemu-user-static\n\ + \x20 Arch: sudo pacman -S qemu-user-static-binfmt\n\ + \x20 Alpine: sudo apk add qemu-user-static" + )] + BinfmtNotRegistered { arch: String }, + + /// The registry publishes no linux manifest for the requested + /// architecture; `available` lists the architectures it does serve. + #[error("No manifest found for architecture '{arch}'. Available: {available}")] + ArchNotAvailable { arch: String, available: String }, + + /// /etc/subuid or /etc/subgid has no usable entry for this user (either + /// absent or with fewer than two mappable ids). + #[error( + "{0}\n\n\ + Add a matching entry to /etc/subuid and /etc/subgid, e.g.:\n\ + \x20 :100000:65536" + )] + MissingSubidRange(String), + + /// Any other failure, preserving the full context chain. Errors raised + /// inside the namespace child (mounts, resolv.conf, chroot exec) also + /// surface here: they cross the process boundary as formatted chains, + /// losing their variant. + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Result alias for the public library API: `Result` carries [`Error`]. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variants_render_remediation() { + let e = Error::UserNamespacesUnavailable { + remediation: "sysctl -w kernel.unprivileged_userns_clone=1".to_string(), + }; + let msg = e.to_string(); + assert!(msg.contains("user namespaces not available")); + assert!(msg.contains("sysctl -w")); + + let e = Error::BinfmtNotRegistered { + arch: "riscv64".to_string(), + }; + let msg = e.to_string(); + assert!(msg.contains("binfmt_misc not registered for riscv64")); + assert!(msg.contains("qemu-user-static")); + } + + #[test] + fn other_preserves_chain_through_sources() { + let inner = anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing file", + )) + .context("extract failed"); + let e = Error::Other(inner); + // Walk the source chain: the io error must remain reachable for + // error-report crates, even though Display shows only the top. + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&e); + let mut found = false; + while let Some(err) = source { + if err.to_string().contains("missing file") { + found = true; + } + source = err.source(); + } + assert!(found, "chain root lost through Error::Other"); + } +} diff --git a/crates/ecr/src/exec.rs b/crates/ecr/src/exec.rs index 4e14027..8c6748b 100644 --- a/crates/ecr/src/exec.rs +++ b/crates/ecr/src/exec.rs @@ -10,13 +10,14 @@ use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::Context; use crate::chroot; use crate::mount::{self, BindTarget}; use crate::namespace; use crate::qemu; use crate::utils::{self, Arch}; +use crate::Result; /// Options for [`exec`]. #[derive(Debug, Clone)] diff --git a/crates/ecr/src/extract.rs b/crates/ecr/src/extract.rs index 872c92e..7645775 100644 --- a/crates/ecr/src/extract.rs +++ b/crates/ecr/src/extract.rs @@ -1,5 +1,7 @@ use crate::progress::{Progress, Unit}; -use anyhow::{Context, Result}; +use anyhow::Context; + +use crate::Result; use std::fs::File; use std::io::{BufReader, Read}; use std::os::unix::fs::PermissionsExt; @@ -15,15 +17,19 @@ pub fn extract_tarball(tarball: &Path, dest: &Path, progress: &dyn Progress) -> // reading the entire compressed archive twice for large OCI images). if filename.starts_with("oci-") { log::debug!("Detected multi-layer OCI image, extracting layers..."); - return extract_multi_layer_oci(tarball, dest, progress); + return Ok(extract_multi_layer_oci(tarball, dest, progress)?); } - extract_single_archive(tarball, dest, progress) + Ok(extract_single_archive(tarball, dest, progress)?) } /// Extract a plain rootfs tarball, detecting the compression format from /// the filename with a magic-byte fallback. -fn extract_single_archive(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> { +fn extract_single_archive( + tarball: &Path, + dest: &Path, + progress: &dyn Progress, +) -> anyhow::Result<()> { let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); let file = File::open(tarball) @@ -79,7 +85,11 @@ fn extract_single_archive(tarball: &Path, dest: &Path, progress: &dyn Progress) } /// Extract a multi-layer OCI image -fn extract_multi_layer_oci(tarball: &Path, dest: &Path, progress: &dyn Progress) -> Result<()> { +fn extract_multi_layer_oci( + tarball: &Path, + dest: &Path, + progress: &dyn Progress, +) -> anyhow::Result<()> { let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or(""); // Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files) @@ -148,7 +158,7 @@ fn extract_oci_layer( dest: &Path, fixups: &mut DeferredFixups, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { use std::io::Read; let layer_name = layer_path .file_name() @@ -196,7 +206,7 @@ fn extract_gz( dest: &Path, fixups: &mut DeferredFixups, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let gz_decoder = flate2::read::GzDecoder::new(reader); let archive = tar::Archive::new(gz_decoder); @@ -210,7 +220,7 @@ fn extract_xz( dest: &Path, fixups: &mut DeferredFixups, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let xz_decoder = xz2::read::XzDecoder::new(reader); let archive = tar::Archive::new(xz_decoder); @@ -224,7 +234,7 @@ fn extract_zst( dest: &Path, fixups: &mut DeferredFixups, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let zst_decoder = zstd::Decoder::new(reader)?; let archive = tar::Archive::new(zst_decoder); @@ -238,7 +248,7 @@ fn extract_tar( dest: &Path, fixups: &mut DeferredFixups, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let archive = tar::Archive::new(reader); extract_archive(archive, dest, "Extracting tar archive", fixups, progress)?; @@ -295,7 +305,7 @@ fn normalize_within(dest: &Path, target: &Path) -> Option { } /// Apply hard links and directory modes collected during extraction. -fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> Result<()> { +fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> anyhow::Result<()> { for (dst, target) in fixups.hard_links.drain(..) { apply_hard_link(dest, &dst, &target)?; } @@ -311,7 +321,7 @@ fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> Result<()> { /// exists on disk; otherwise the entry is preserved verbatim as a symlink — /// some images (Fedora's ca-trust directory-hash) ship links whose targets /// are generated after linking and never exist anywhere in the archive. -fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> Result<()> { +fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> anyhow::Result<()> { if dst.symlink_metadata().is_ok() { std::fs::remove_file(dst) .with_context(|| format!("Failed to replace {}", dst.display()))?; @@ -339,7 +349,7 @@ fn extract_archive( label: &str, fixups: &mut DeferredFixups, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { progress.begin(label, Unit::Files, None); archive.set_preserve_permissions(true); diff --git a/crates/ecr/src/kernel.rs b/crates/ecr/src/kernel.rs index eee7a97..1d63b9a 100644 --- a/crates/ecr/src/kernel.rs +++ b/crates/ecr/src/kernel.rs @@ -18,7 +18,9 @@ //! only ELF, uImage and raw images. use crate::progress::{Progress, Unit}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; + +use crate::Result; use std::io::{Read, Seek}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -42,7 +44,7 @@ fn alpine_kernel_arch(arch: &str) -> &'static str { /// Get the Alpine version branch for kernel downloads /// We use the latest stable branch -fn get_alpine_branch() -> Result { +fn get_alpine_branch() -> anyhow::Result { // Fetch the latest-stable branch from Alpine CDN // The URL redirects to the current stable version let client = reqwest::blocking::Client::builder() @@ -71,7 +73,7 @@ fn get_alpine_branch() -> Result { fetch_alpine_branch_from_yaml() } -fn fetch_alpine_branch_from_yaml() -> Result { +fn fetch_alpine_branch_from_yaml() -> anyhow::Result { #[derive(serde::Deserialize)] struct AlpineRelease { version: Option, @@ -104,7 +106,7 @@ fn fetch_alpine_branch_from_yaml() -> Result { /// package index. Returns the package name and version. The index is /// scanned once; whichever candidate flavor appears with the highest /// preference (lowest index) wins. -fn find_kernel_package(branch: &str, arch: &str) -> Result<(&'static str, String)> { +fn find_kernel_package(branch: &str, arch: &str) -> anyhow::Result<(&'static str, String)> { // Alpine package index URL let url = format!( "https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz", @@ -241,7 +243,7 @@ fn download_alpine_kernel( arch: &str, dest: &Path, progress: &dyn Progress, -) -> Result<()> { +) -> anyhow::Result<()> { let (package, version) = find_kernel_package(branch, arch)?; log::debug!("Found {} version: {}", package, version); @@ -268,7 +270,11 @@ fn download_alpine_kernel( result } -async fn download_kernel_async(url: &str, dest: &Path, progress: &dyn Progress) -> Result<()> { +async fn download_kernel_async( + url: &str, + dest: &Path, + progress: &dyn Progress, +) -> anyhow::Result<()> { use futures_util::StreamExt; let client = reqwest::Client::builder() .timeout(Duration::from_secs(300)) @@ -327,7 +333,7 @@ async fn download_kernel_async(url: &str, dest: &Path, progress: &dyn Progress) } /// Extract the kernel image from an Alpine APK file -fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> Result<()> { +fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> anyhow::Result<()> { // The kernel binary is named after the flavor: boot/vmlinuz-virt for // linux-virt, boot/vmlinuz-lts for linux-lts let flavor = package @@ -366,7 +372,7 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> Resul /// loader understands only ELF, uImage and raw images — a gzipped Image /// is loaded verbatim and never boots. Uncompressed formats (x86 bzImage, /// ppc64le ELF, raw Image) are left untouched. -fn decompress_kernel_if_gzipped(path: &Path) -> Result<()> { +fn decompress_kernel_if_gzipped(path: &Path) -> anyhow::Result<()> { let mut file = std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; diff --git a/crates/ecr/src/lib.rs b/crates/ecr/src/lib.rs index ea9c7bf..ffa4ebd 100644 --- a/crates/ecr/src/lib.rs +++ b/crates/ecr/src/lib.rs @@ -9,12 +9,16 @@ //! The main entry points are [`rootfs`] (cache-aware rootfs preparation and //! persistence) and [`exec`] (running a command inside a prepared rootfs //! with a caller-composed environment, explicit bind targets and a target -//! architecture). +//! architecture). Public functions return [`Result`] carrying the [`Error`] +//! enum, so consumers can match on host-readiness failures (`Error` +//! variants) versus ordinary failures ([`Error::Other`], which preserves the +//! full context chain). pub mod chroot; pub mod config; pub mod distro; pub mod download; +pub mod error; pub mod exec; pub mod extract; pub mod kernel; @@ -26,6 +30,7 @@ pub mod qemu_vm; pub mod rootfs; pub mod utils; +pub use error::{Error, Result}; pub use exec::{exec, ExecOptions}; pub use mount::BindTarget; pub use progress::{NoProgress, Progress, Unit}; diff --git a/crates/ecr/src/mount.rs b/crates/ecr/src/mount.rs index 6f4d443..586940b 100644 --- a/crates/ecr/src/mount.rs +++ b/crates/ecr/src/mount.rs @@ -1,4 +1,6 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; + +use crate::Result; use nix::mount::{mount, MsFlags}; use std::path::Path; use tempfile::TempDir; @@ -10,7 +12,7 @@ use tempfile::TempDir; /// path would silently split the option string at the wrong boundary and /// produce a cryptic kernel error; a bare backslash would be mis-interpreted /// as starting an escape sequence. -fn escape_overlay_path(path: &Path) -> Result { +fn escape_overlay_path(path: &Path) -> anyhow::Result { let s = path.to_str().ok_or_else(|| { anyhow!( "Overlay path '{}' contains non-UTF-8 characters", @@ -48,7 +50,8 @@ pub fn in_rootfs(rootfs: &Path, target: &Path) -> Result { return Err(anyhow!( "Path '{}' escapes the rootfs: '..' components are not allowed", target.display() - )); + ) + .into()); } Ok(rootfs.join(relative)) } @@ -96,7 +99,7 @@ pub fn setup_mounts(rootfs: &Path, binds: &[BindTarget]) -> Result> Ok(overlay_temps) } -fn mount_proc(rootfs: &Path) -> Result<()> { +fn mount_proc(rootfs: &Path) -> anyhow::Result<()> { let proc_path = rootfs.join("proc"); std::fs::create_dir_all(&proc_path)?; @@ -108,7 +111,7 @@ fn mount_proc(rootfs: &Path) -> Result<()> { Ok(()) } -fn mount_sys(rootfs: &Path) -> Result<()> { +fn mount_sys(rootfs: &Path) -> anyhow::Result<()> { let sys_path = rootfs.join("sys"); std::fs::create_dir_all(&sys_path)?; @@ -147,7 +150,7 @@ fn mount_sys(rootfs: &Path) -> Result<()> { Ok(()) } -fn mount_dev(rootfs: &Path) -> Result<()> { +fn mount_dev(rootfs: &Path) -> anyhow::Result<()> { let dev_path = rootfs.join("dev"); std::fs::create_dir_all(&dev_path)?; @@ -164,7 +167,7 @@ fn mount_dev(rootfs: &Path) -> Result<()> { Ok(()) } -fn mount_devpts(rootfs: &Path) -> Result<()> { +fn mount_devpts(rootfs: &Path) -> anyhow::Result<()> { let devpts_path = rootfs.join("dev/pts"); std::fs::create_dir_all(&devpts_path)?; @@ -184,7 +187,7 @@ fn mount_devpts(rootfs: &Path) -> Result<()> { /// Setup overlay mount for a workspace directory /// Returns a TempDir that must be kept alive for the overlay to work -fn setup_overlay(source: &Path, mount_point: &Path) -> Result { +fn setup_overlay(source: &Path, mount_point: &Path) -> anyhow::Result { std::fs::create_dir_all(mount_point)?; // Create temp directories for overlay @@ -220,7 +223,7 @@ fn setup_overlay(source: &Path, mount_point: &Path) -> Result { } /// Setup read-write bind mount -fn setup_bind_rw(source: &Path, mount_point: &Path) -> Result<()> { +fn setup_bind_rw(source: &Path, mount_point: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(mount_point)?; let source = source.canonicalize()?; diff --git a/crates/ecr/src/namespace.rs b/crates/ecr/src/namespace.rs index d2eb7b8..0c56f77 100644 --- a/crates/ecr/src/namespace.rs +++ b/crates/ecr/src/namespace.rs @@ -1,7 +1,8 @@ -use anyhow::{anyhow, Context, Result}; +use crate::{Error, Result}; +use anyhow::{anyhow, Context}; use nix::sched::{clone, CloneFlags}; use nix::sys::signal::Signal; -use nix::unistd::{getgid, getuid, Pid}; +use nix::unistd::{getgid, getuid, sethostname, Pid}; /// RAII wrapper that closes a raw file descriptor on drop. /// Guarantees all pipe fds are closed on every return path, including @@ -33,12 +34,12 @@ pub fn check_user_namespace() -> Result<()> { // Check kernel.unprivileged_userns_clone on systems that have it if let Ok(content) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") { if content.trim() == "0" { - return Err(anyhow!( - "User namespaces not available\n\n\ - Enable with:\n\ - sysctl -w kernel.unprivileged_userns_clone=1\n\n\ - Or check AppArmor profile restrictions." - )); + return Err(Error::UserNamespacesUnavailable { + remediation: "Enable with:\n \ + sysctl -w kernel.unprivileged_userns_clone=1\n\n\ + Or check AppArmor profile restrictions." + .to_string(), + }); } } @@ -46,11 +47,11 @@ pub fn check_user_namespace() -> Result<()> { if let Ok(content) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") { if let Ok(max) = content.trim().parse::() { if max == 0 { - return Err(anyhow!( - "User namespaces not available\n\n\ - Enable with:\n\ - sysctl -w user.max_user_namespaces=10000" - )); + return Err(Error::UserNamespacesUnavailable { + remediation: "Enable with:\n \ + sysctl -w user.max_user_namespaces=10000" + .to_string(), + }); } } } @@ -79,14 +80,14 @@ where let mut fds: [i32; 2] = [-1, -1]; if libc::pipe(fds.as_mut_ptr()) != 0 { - return Err(anyhow!("Failed to create sync pipe")); + return Err(anyhow!("Failed to create sync pipe").into()); } parent_read = AutoCloseFd(fds[0]); parent_write = AutoCloseFd(fds[1]); // parent_read/write auto-closed if subsequent pipes fail ↑ if libc::pipe(fds.as_mut_ptr()) != 0 { - return Err(anyhow!("Failed to create done pipe")); + return Err(anyhow!("Failed to create done pipe").into()); } child_read = AutoCloseFd(fds[0]); child_write = AutoCloseFd(fds[1]); @@ -97,7 +98,7 @@ where libc::fcntl(child_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC); if libc::pipe(fds.as_mut_ptr()) != 0 { - return Err(anyhow!("Failed to create error pipe")); + return Err(anyhow!("Failed to create error pipe").into()); } error_read = AutoCloseFd(fds[0]); error_write = AutoCloseFd(fds[1]); @@ -141,15 +142,20 @@ where let result = if let Some(func) = f.take() { func() } else { - Err(anyhow!("Function already called")) + Err(anyhow!("Function already called").into()) }; // On failure, write the full error chain to the error pipe // before signalling done, so the parent can reconstruct it. - if let Err(ref e) = result { - let msg = format!("{:#}", e); + // Wrapping in anyhow makes `{:#}` print the whole chain, + // not just the top message. + let failure = match result { + Ok(()) => None, + Err(e) => Some(format!("{:#}", anyhow::Error::from(e))), + }; + if let Some(msg) = &failure { let bytes = msg.as_bytes(); - libc::write(ew, bytes.as_ptr() as *const libc::c_void, bytes.len()); + libc::write(ew, bytes.as_ptr() as *mut libc::c_void, bytes.len()); } libc::close(ew); @@ -157,7 +163,7 @@ where libc::write(cw, c"done".as_ptr() as *const libc::c_void, 4); libc::close(cw); - if result.is_ok() { + if failure.is_none() { 0 } else { 1 @@ -221,7 +227,8 @@ where drop(error_read); // Wait for child process - let status = nix::sys::wait::waitpid(pid, None)?; + let status = + nix::sys::wait::waitpid(pid, None).context("Failed to wait for the namespace child")?; match status { nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0), @@ -229,7 +236,7 @@ where // If the child reported an error (e.g., setup failure), return it. // Otherwise, just forward the exit code without an error message. if let Some(msg) = child_error { - Err(anyhow!("{}", msg)) + Err(anyhow!("{}", msg).into()) } else { Ok(code) } @@ -253,20 +260,16 @@ fn setup_user_namespace(pid: Pid) -> Result<()> { // IDs 1..sub_uid_count-1 to the subordinate range. A count of 0 or 1 // leaves no subordinate IDs to map and indicates a malformed /etc/subuid. if sub_uid_count < 2 { - return Err(anyhow!( - "subuid count {} for uid {} is too small (need at least 2); \ - check /etc/subuid", - sub_uid_count, - uid - )); + return Err(Error::MissingSubidRange(format!( + "subuid count {} for uid {} is too small (need at least 2)", + sub_uid_count, uid + ))); } if sub_gid_count < 2 { - return Err(anyhow!( - "subgid count {} for gid {} is too small (need at least 2); \ - check /etc/subgid", - sub_gid_count, - gid - )); + return Err(Error::MissingSubidRange(format!( + "subgid count {} for gid {} is too small (need at least 2)", + sub_gid_count, gid + ))); } // Allow setgroups so apt and other tools can drop privileges @@ -292,9 +295,7 @@ fn setup_user_namespace(pid: Pid) -> Result<()> { .context("Failed to execute newuidmap")?; if !uid_result.success() { - return Err(anyhow!( - "newuidmap failed - ensure subuid entry exists in /etc/subuid" - )); + return Err(anyhow!("newuidmap failed - ensure subuid entry exists in /etc/subuid").into()); } // newgidmap format: newgidmap pid ns_start host_start count ... @@ -311,9 +312,7 @@ fn setup_user_namespace(pid: Pid) -> Result<()> { .context("Failed to execute newgidmap")?; if !gid_result.success() { - return Err(anyhow!( - "newgidmap failed - ensure subgid entry exists in /etc/subgid" - )); + return Err(anyhow!("newgidmap failed - ensure subgid entry exists in /etc/subgid").into()); } Ok(()) @@ -341,13 +340,11 @@ fn get_subuid_range(uid: nix::unistd::Uid) -> Result<(u32, u32)> { } } - Err(anyhow!( - "No subuid entry found for user {} (uid {}). \ - Add one to /etc/subuid, e.g.:\n {}:100000:65536", + Err(Error::MissingSubidRange(format!( + "no subuid entry found for user {} (uid {})", username.as_deref().unwrap_or(""), uid, - username.as_deref().unwrap_or(&uid.to_string()), - )) + ))) } /// Get subordinate GID range for a group from /etc/subgid @@ -372,13 +369,11 @@ fn get_subgid_range(gid: nix::unistd::Gid) -> Result<(u32, u32)> { } } - Err(anyhow!( - "No subgid entry found for group {} (gid {}). \ - Add one to /etc/subgid, e.g.:\n {}:100000:65536", + Err(Error::MissingSubidRange(format!( + "no subgid entry found for group {} (gid {})", groupname.as_deref().unwrap_or(""), gid, - groupname.as_deref().unwrap_or(&gid.to_string()), - )) + ))) } /// Generate a unique hostname for a chroot run: @@ -393,10 +388,7 @@ fn generate_hostname(distro: &str) -> String { /// Set hostname in UTS namespace pub fn set_hostname(distro: &str) -> Result<()> { - use nix::unistd::sethostname; - let hostname = generate_hostname(distro); - sethostname(&hostname).with_context(|| format!("Failed to set hostname to {}", hostname))?; Ok(()) diff --git a/crates/ecr/src/qemu.rs b/crates/ecr/src/qemu.rs index 6e2610b..79d8512 100644 --- a/crates/ecr/src/qemu.rs +++ b/crates/ecr/src/qemu.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use crate::{Error, Result}; use std::path::Path; /// Check if binfmt_misc is registered for the target architecture @@ -8,14 +8,9 @@ pub fn check_binfmt(arch: &str) -> Result<()> { let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch); if !Path::new(&binfmt_path).exists() { - return Err(anyhow!( - "binfmt_misc not registered for {}\n\n\ - Install QEMU user emulation:\n\ - Ubuntu/Debian: sudo apt install qemu-user-static\n\ - Arch: sudo pacman -S qemu-user-static-binfmt\n\ - Alpine: sudo apk add qemu-user-static", - arch - )); + return Err(Error::BinfmtNotRegistered { + arch: arch.to_string(), + }); } log::debug!("QEMU binfmt_misc registered for {}", arch); diff --git a/crates/ecr/src/qemu_vm.rs b/crates/ecr/src/qemu_vm.rs index fafe75a..538140e 100644 --- a/crates/ecr/src/qemu_vm.rs +++ b/crates/ecr/src/qemu_vm.rs @@ -1,5 +1,7 @@ use crate::progress::{Progress, Unit}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; + +use crate::Result; use cpio::{newc, NewcBuilder}; use std::io::Write; use std::os::unix::fs::MetadataExt; @@ -28,18 +30,12 @@ pub struct QemuConfig { pub fn launch_qemu(config: QemuConfig, progress: &dyn Progress) -> Result<()> { // Check that kernel exists if !config.kernel_path.exists() { - return Err(anyhow!( - "Kernel not found: {}", - config.kernel_path.display() - )); + return Err(anyhow!("Kernel not found: {}", config.kernel_path.display()).into()); } // Check that rootfs exists if !config.rootfs_path.exists() { - return Err(anyhow!( - "Rootfs not found: {}", - config.rootfs_path.display() - )); + return Err(anyhow!("Rootfs not found: {}", config.rootfs_path.display()).into()); } // Validate memory string format @@ -171,7 +167,8 @@ pub fn launch_qemu(config: QemuConfig, progress: &dyn Progress) -> Result<()> { return Err(anyhow!( "QEMU exited with non-zero status: {}", status.code().unwrap_or(-1) - )); + ) + .into()); } Ok(()) @@ -268,7 +265,7 @@ fn check_kvm_capabilities() -> bool { /// Create an uncompressed cpio initramfs from a directory. /// Entries are streamed straight to disk so large rootfs images never have /// to fit in memory as a whole archive. -fn create_initramfs(rootfs: &Path, progress: &dyn Progress) -> Result { +fn create_initramfs(rootfs: &Path, progress: &dyn Progress) -> anyhow::Result { // Create a temporary file for the initramfs (uncompressed cpio) // Use a temp file in the same directory as rootfs, or fall back to /tmp let initramfs_path = rootfs @@ -314,7 +311,7 @@ fn write_cpio_archive( rootfs: &Path, writer: &mut W, progress: &dyn Progress, -) -> Result { +) -> anyhow::Result { // Track hard links by (device, inode): the value is the synthetic cpio // inode assigned to the first occurrence. The Linux initramfs loader // turns later zero-size entries sharing that inode into hard links. @@ -460,7 +457,7 @@ fn write_cpio_entry( nlink: u32, ino: u32, data: &[u8], -) -> Result<()> { +) -> anyhow::Result<()> { let builder = NewcBuilder::new(name) .mode(mode) .uid(0) @@ -494,7 +491,7 @@ fn write_dir_entries( next_ino: &mut u32, written_names: &mut std::collections::HashSet, total_data: &mut u64, -) -> Result { +) -> anyhow::Result { // Read directory entries let dir_entries: Vec<_> = match std::fs::read_dir(current) { Ok(entries) => entries.collect::>()?, diff --git a/crates/ecr/src/rootfs.rs b/crates/ecr/src/rootfs.rs index 3f6c15f..3b1edaf 100644 --- a/crates/ecr/src/rootfs.rs +++ b/crates/ecr/src/rootfs.rs @@ -38,7 +38,9 @@ use std::path::{Path, PathBuf}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context}; + +use crate::Result; use tempfile::TempDir; use crate::distro::{ @@ -75,7 +77,7 @@ impl RootfsCache { pub fn default_dir() -> Result { dirs::cache_dir() .map(|p| p.join("ecr")) - .ok_or_else(|| anyhow!("Could not determine cache directory")) + .ok_or_else(|| anyhow!("Could not determine cache directory").into()) } pub fn dir(&self) -> &Path { @@ -141,7 +143,7 @@ impl RootfsCache { log::debug!("Using cached tarball: {}", cache_path.display()); } - extract_prepared(cache_path, arch, progress) + Ok(extract_prepared(cache_path, arch, progress)?) } /// Prepare a provisioned rootfs with cache amortization: when the cache @@ -163,7 +165,7 @@ impl RootfsCache { provision: F, ) -> Result where - F: FnOnce(&PreparedRootfs) -> Result<()>, + F: FnOnce(&PreparedRootfs) -> anyhow::Result<()>, { let arch = req.resolved_arch(); let source = resolve_source(&req.image, &arch)?; @@ -172,7 +174,7 @@ impl RootfsCache { if !req.no_cache && cache_path.exists() && marker.exists() { log::debug!("Using provisioned cache entry: {}", cache_path.display()); - return extract_prepared(cache_path, arch, progress); + return Ok(extract_prepared(cache_path, arch, progress)?); } let rootfs = self.prepare(req, progress)?; @@ -283,7 +285,7 @@ impl PreparedRootfs { /// "lts", "edge", …) to a concrete version, so the cache key is stable /// (e.g. "ubuntu-noble-amd64", not "ubuntu-latest-amd64") and a future /// release automatically gets its own cache entry. -fn resolve_source(image: &str, arch: &str) -> Result { +fn resolve_source(image: &str, arch: &str) -> anyhow::Result { let source = parse_image_ref(image, arch)?; match source { ImageSource::DirectTarball { distro, version } => { @@ -301,7 +303,7 @@ fn extract_prepared( cache_path: PathBuf, arch: String, progress: &dyn Progress, -) -> Result { +) -> anyhow::Result { let temp = tempfile::tempdir().context("Failed to create temporary directory")?; let rootfs = temp.path().to_path_buf(); @@ -318,7 +320,7 @@ fn extract_prepared( /// Write a tarball of `rootfs` at `dest`, compressed according to dest's /// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a /// `.partial` file. -fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> { +fn pack_rootfs(rootfs: &Path, dest: &Path) -> anyhow::Result<()> { // Packing the tree into a tarball placed inside it would recurse // forever: the walker keeps seeing the archive grow. let rootfs_canonical = rootfs @@ -341,7 +343,7 @@ fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> { let file = std::fs::File::create(&partial) .with_context(|| format!("Failed to create {}", partial.display()))?; - let result: Result<()> = (|| { + let result: anyhow::Result<()> = (|| { if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); let encoder = write_rootfs_tar(rootfs, encoder)?; @@ -373,7 +375,7 @@ fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> { /// Write the rootfs tree into a tar archive streamed to `writer`, returning /// the writer so the caller can finalise the compression stream. -fn write_rootfs_tar(rootfs: &Path, writer: W) -> Result { +fn write_rootfs_tar(rootfs: &Path, writer: W) -> anyhow::Result { let mut builder = tar::Builder::new(writer); // Store symlinks as symlinks; following them would duplicate the target's // contents into the archive (and recurse into symlinked directories). diff --git a/crates/ecr/src/utils.rs b/crates/ecr/src/utils.rs index 407ec4c..030b5e9 100644 --- a/crates/ecr/src/utils.rs +++ b/crates/ecr/src/utils.rs @@ -1,4 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::anyhow; + +use crate::Result; use std::path::Path; // ============================================================================ @@ -214,7 +216,7 @@ pub fn detect_shell(rootfs: &Path) -> &'static str { /// Returns an error if the format is invalid pub fn validate_memory_string(s: &str) -> Result<()> { if s.is_empty() { - return Err(anyhow!("Memory size cannot be empty")); + return Err(anyhow!("Memory size cannot be empty").into()); } // Must end with a valid suffix or be a plain number @@ -225,12 +227,12 @@ pub fn validate_memory_string(s: &str) -> Result<()> { // Check for negative numbers if numeric_part.starts_with('-') { - return Err(anyhow!("Memory size cannot be negative: {}", s)); + return Err(anyhow!("Memory size cannot be negative: {}", s).into()); } // Must be a valid positive number if numeric_part.is_empty() { - return Err(anyhow!("Memory size must have a numeric value: {}", s)); + return Err(anyhow!("Memory size must have a numeric value: {}", s).into()); } // Check if it's a valid integer @@ -238,7 +240,8 @@ pub fn validate_memory_string(s: &str) -> Result<()> { return Err(anyhow!( "Memory size must be a positive integer with optional suffix: {}", s - )); + ) + .into()); } // Check suffix is valid @@ -249,7 +252,8 @@ pub fn validate_memory_string(s: &str) -> Result<()> { return Err(anyhow!( "Invalid memory suffix '{}'. Valid suffixes: K, M, G, T", suffix - )); + ) + .into()); } }