feat(error): type the public library API with ecr::Error

anyhow at the public boundary left library consumers unable to match
on failure modes: every mistake was an opaque error, and the ones
worth branching on (host prerequisites, unsupported architectures)
could only be recognized by string matching.

Public functions now return Result<T, ecr::Error> (thiserror) with
semantic variants for those conditions: user namespaces unavailable,
binfmt_misc unregistered, architecture not served by the registry,
missing subuid/subgid entry.  anyhow stays the internal error type;
everything else converts to Error::Other, which transparently wraps
the full context chain.  Errors raised inside the namespace child
cross the process boundary as formatted chains and also surface as
Other; the error pipe wraps them in anyhow before formatting so the
chain still survives.  The CLI maps the error back into anyhow, so
its output is unchanged.
This commit is contained in:
2026-10-04 21:20:51 +02:00
parent 2225c33ef2
commit 593ce20f4f
18 changed files with 299 additions and 171 deletions
Generated
+1
View File
@@ -354,6 +354,7 @@ dependencies = [
"sha2",
"tar",
"tempfile",
"thiserror",
"tokio",
"users",
"which",
+2 -2
View File
@@ -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() {
+1
View File
@@ -35,6 +35,7 @@ tempfile = "3"
# Error handling
anyhow = "1"
thiserror = "2"
# Utilities
dirs = "6"
+7 -5
View File
@@ -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::<Result<Vec<_>>>()?;
.collect::<anyhow::Result<Vec<_>>>()?;
(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::<Result<Vec<_>>>()?;
.collect::<anyhow::Result<Vec<_>>>()?;
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<PathBuf> {
fn resolve_program(program: &str, env: &[(String, String)]) -> anyhow::Result<PathBuf> {
if program.contains('/') {
let path = PathBuf::from(program);
if !path.exists() {
+3 -1
View File
@@ -1,4 +1,6 @@
use anyhow::{Context, Result};
use anyhow::Context;
use crate::Result;
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
+21 -19
View File
@@ -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<ImageSource> {
.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<ImageSource> {
})
}
// 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<ImageSource> {
fn parse_oci_ref(input: &str, arch: &str) -> anyhow::Result<ImageSource> {
// 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<String> {
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<Strin
/// Fetch the latest Ubuntu codename from a changelogs.ubuntu.com meta-release file.
/// Pass the LTS-only URL to get the latest LTS, or the full URL for the latest release.
fn fetch_ubuntu_codename(meta_release_url: &str) -> Result<String> {
fn fetch_ubuntu_codename(meta_release_url: &str) -> anyhow::Result<String> {
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<String> {
/// 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<String> {
fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> anyhow::Result<String> {
#[derive(serde::Deserialize)]
struct AlpineRelease {
file: Option<String>,
@@ -259,13 +261,13 @@ fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result<String>
))
}
fn fetch_alpine_latest_version(arch: &str) -> Result<String> {
fn fetch_alpine_latest_version(arch: &str) -> anyhow::Result<String> {
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<String> {
fn fetch_alpine_minor_version(minor: &str, arch: &str) -> anyhow::Result<String> {
fetch_alpine_version_from_branch(&format!("v{}", minor), arch)
}
@@ -277,16 +279,16 @@ pub fn resolve_distro_version(
version: Option<&str>,
arch: &str,
) -> Result<String> {
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<std::collections::HashMap<String, String>> {
fn fetch_ubuntu_version_map() -> anyhow::Result<std::collections::HashMap<String, String>> {
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<std::collections::HashMap<String, String
Ok(map)
}
fn resolve_ubuntu_version(version: Option<&str>) -> Result<String> {
fn resolve_ubuntu_version(version: Option<&str>) -> anyhow::Result<String> {
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<String> {
fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
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<String> {
})
}
fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result<String> {
fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
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<String> {
))
}
fn resolve_alpine_url(version: Option<&str>, arch: &str) -> Result<String> {
fn resolve_alpine_url(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
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)?;
+29 -19
View File
@@ -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<String> {
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<String> {
async fn fetch_oci_digest_async(
registry: &str,
repository: &str,
tag: &str,
) -> anyhow::Result<String> {
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<String> {
async fn fetch_published_checksum(distro: &Distro, tarball_url: &str) -> anyhow::Result<String> {
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<String> {
) -> anyhow::Result<String> {
// 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<String> {
async fn get_auth_token(
client: &Client,
registry: &str,
repository: &str,
) -> anyhow::Result<String> {
// 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<String> {
async fn fetch_bearer_token(client: &Client, token_url: &str) -> anyhow::Result<String> {
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));
+95
View File
@@ -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 <user>: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<T>` carries [`Error`].
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[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");
}
}
+2 -1
View File
@@ -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)]
+23 -13
View File
@@ -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<R: std::io::Read>(
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<R: std::io::Read>(
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<R: std::io::Read>(
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<R: std::io::Read>(
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<PathBuf> {
}
/// 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<R: std::io::Read>(
label: &str,
fixups: &mut DeferredFixups,
progress: &dyn Progress,
) -> Result<()> {
) -> anyhow::Result<()> {
progress.begin(label, Unit::Files, None);
archive.set_preserve_permissions(true);
+14 -8
View File
@@ -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<String> {
fn get_alpine_branch() -> anyhow::Result<String> {
// 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<String> {
fetch_alpine_branch_from_yaml()
}
fn fetch_alpine_branch_from_yaml() -> Result<String> {
fn fetch_alpine_branch_from_yaml() -> anyhow::Result<String> {
#[derive(serde::Deserialize)]
struct AlpineRelease {
version: Option<String>,
@@ -104,7 +106,7 @@ fn fetch_alpine_branch_from_yaml() -> Result<String> {
/// 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()))?;
+6 -1
View File
@@ -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};
+12 -9
View File
@@ -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<String> {
fn escape_overlay_path(path: &Path) -> anyhow::Result<String> {
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<std::path::PathBuf> {
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<Vec<TempDir>>
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<TempDir> {
fn setup_overlay(source: &Path, mount_point: &Path) -> anyhow::Result<TempDir> {
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<TempDir> {
}
/// 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()?;
+46 -54
View File
@@ -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::<u32>() {
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("<unknown>"),
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("<unknown>"),
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(())
+4 -9
View File
@@ -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);
+11 -14
View File
@@ -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<PathBuf> {
fn create_initramfs(rootfs: &Path, progress: &dyn Progress) -> anyhow::Result<PathBuf> {
// 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<W: Write>(
rootfs: &Path,
writer: &mut W,
progress: &dyn Progress,
) -> Result<u64> {
) -> anyhow::Result<u64> {
// 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<W: Write>(
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<String>,
total_data: &mut u64,
) -> Result<u64> {
) -> anyhow::Result<u64> {
// Read directory entries
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
+12 -10
View File
@@ -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<PathBuf> {
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<PreparedRootfs>
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<ImageSource> {
fn resolve_source(image: &str, arch: &str) -> anyhow::Result<ImageSource> {
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<PreparedRootfs> {
) -> anyhow::Result<PreparedRootfs> {
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<W: std::io::Write>(rootfs: &Path, writer: W) -> Result<W> {
fn write_rootfs_tar<W: std::io::Write>(rootfs: &Path, writer: W) -> anyhow::Result<W> {
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).
+10 -6
View File
@@ -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());
}
}