Repository: cargo-lambda/cargo-lambda (language: python)

Issue:
Deploy with `--binary-path` doesn't use `bootstrap` as binary name in created ZIP
I am finding that if I call `cargo deploy` with the `--binary-path` argument, the ZIP it generates uses the function name for the binary, rather than `bootstrap` and as such the Lambda doesn't work.

```
$ unzip -l downloaded-lambda-code-from-console.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
 12990520  05-31-2024 01:39   my-function-name
---------                     -------
 12990520                     1 file
```

This is visible if when turning on logs:

```
RUST_LOG="cargo_lambda_build=debug" cargo lambda deploy --binary-path /Users/xxx/Downloads/xxx/bootstrap  my-function-name

▸▹▹▹▹ loading binary data
DEBUG zipping binary name="my-function-name" path="/Users/xxx/Downloads/xxx/bootstrap" dir="/Users/xxx/Downloads/xxx" zipped="/Users/xxx/Downloads/xxx/my-function-name.zip"
```

Contrast that to using the automatic path:

```
RUST_LOG="cargo_lambda_build=debug" cargo lambda deploy my-function-name

▸▹▹▹▹ loading binary data
DEBUG zipping binary name="bootstrap" path="/Users/xxx/my-function-name/target/lambda/my-function-name/bootstrap" dir="/Users/xxx/my-function-name/target/lambda/my-function-name" zipped="/Users/xxx/my-function-name/target/lambda/my-function-name/bootstrap.zip"                                           
```


Discussion / hints:
Thanks for catching that! It looks like a bug. I'll try to fix it in the next few days, but feel free to try to fix it and send a PR. I really appreciate all the help people provide to make this tool better.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 89979334..f00ff986 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -992,6 +992,7 @@ dependencies = [
  "home",
  "miette 5.10.0",
  "object 0.28.4",
+ "rstest",
  "rustc_version",
  "serde",
  "serde_json",
@@ -1930,6 +1931,12 @@ version = "0.3.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2"
 
+[[package]]
+name = "futures-timer"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+
 [[package]]
 name = "futures-util"
 version = "0.3.29"
@@ -2200,6 +2207,12 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "glob"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+
 [[package]]
 name = "globset"
 version = "0.4.13"
@@ -3432,6 +3445,15 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
 
+[[package]]
+name = "proc-macro-crate"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284"
+dependencies = [
+ "toml_edit 0.21.0",
+]
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.69"
@@ -3610,6 +3632,12 @@ version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
 
+[[package]]
+name = "relative-path"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
+
 [[package]]
 name = "remove_dir_all"
 version = "0.7.0"
@@ -3688,6 +3716,36 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "rstest"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
+dependencies = [
+ "futures",
+ "futures-timer",
+ "rstest_macros",
+ "rustc_version",
+]
+
+[[package]]
+name = "rstest_macros"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
+dependencies = [
+ "cfg-if",
+ "glob",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "relative-path",
+ "rustc_version",
+ "syn 2.0.38",
+ "unicode-ident",
+]
+
 [[package]]
 name = "rustc-demangle"
 version = "0.1.23"
diff --git a/crates/cargo-lambda-build/Cargo.toml b/crates/cargo-lambda-build/Cargo.toml
index 8f93cecf..7bf3764d 100644
--- a/crates/cargo-lambda-build/Cargo.toml
+++ b/crates/cargo-lambda-build/Cargo.toml
@@ -36,4 +36,5 @@ walkdir = "2.4.0"
 chrono = { version = "0.4.38", default-features = false, features = ["clock"] }
 
 [dev-dependencies]
+rstest = "0.21.0"
 tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/crates/cargo-lambda-build/src/archive.rs b/crates/cargo-lambda-build/src/archive.rs
new file mode 100644
index 00000000..40ed78aa
--- /dev/null
+++ b/crates/cargo-lambda-build/src/archive.rs
@@ -0,0 +1,578 @@
+use std::{
+    borrow::Cow,
+    fmt::Debug,
+    fs::{read, File, Metadata},
+    io::{Read, Seek, Write},
+    path::{Path, PathBuf},
+};
+
+use cargo_lambda_metadata::{cargo::target_dir, fs::copy_and_replace};
+use chrono::{DateTime, Utc};
+use miette::{Context, IntoDiagnostic, Result};
+use object::{read::File as ObjectFile, Architecture, Object};
+use sha2::{Digest, Sha256};
+use tracing::{debug, trace};
+use walkdir::WalkDir;
+use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter};
+
+use crate::error::BuildError;
+
+#[derive(Debug)]
+pub enum BinaryData<'a> {
+    Function(&'a str),
+    ExternalExtension(&'a str),
+    InternalExtension(&'a str),
+}
+
+impl<'a> BinaryData<'a> {
+    /// Create a BinaryData given the arguments of the CLI
+    pub fn new(name: &'a str, extension: bool, internal: bool) -> Self {
+        if extension {
+            if internal {
+                BinaryData::InternalExtension(name)
+            } else {
+                BinaryData::ExternalExtension(name)
+            }
+        } else {
+            BinaryData::Function(name)
+        }
+    }
+
+    /// Name of the binary to copy inside the zip archive
+    pub fn binary_name(&self) -> &str {
+        match self {
+            BinaryData::Function(_) => "bootstrap",
+            BinaryData::ExternalExtension(name) => name,
+            BinaryData::InternalExtension(name) => name,
+        }
+    }
+
+    /// Name of the zip archive
+    pub fn zip_name(&self) -> String {
+        format!("{}.zip", self.binary_name())
+    }
+
+    /// Location of the binary after building it
+    pub fn binary_location(&self) -> &str {
+        match self {
+            BinaryData::Function(name) => name,
+            BinaryData::ExternalExtension(_) => "extensions",
+            BinaryData::InternalExtension(_) => "extensions",
+        }
+    }
+
+    /// Name of the parent directory to copy the binary into
+    pub fn parent_dir(&self) -> Option<&str> {
+        match self {
+            BinaryData::ExternalExtension(_) => Some("extensions"),
+            _ => None,
+        }
+    }
+
+    /// Command to use to build each kind of binary
+    pub fn build_help(&self) -> &str {
+        match self {
+            BinaryData::Function(_) => "build",
+            BinaryData::ExternalExtension(_) => "build --extension",
+            BinaryData::InternalExtension(_) => "build --extension --internal",
+        }
+    }
+}
+
+pub struct BinaryArchive {
+    pub architecture: String,
+    pub path: PathBuf,
+}
+
+impl BinaryArchive {
+    /// Read the content of the binary archive to the end
+    pub fn read(&self) -> Result<Vec<u8>> {
+        read(&self.path)
+            .into_diagnostic()
+            .wrap_err("failed to read binary archive")
+    }
+
+    /// Calculate the SHA256 hash of the zip binary file
+    pub fn sha256(&self) -> Result<String> {
+        let data = self.read()?;
+        let mut hasher = Sha256::new();
+        hasher.update(data);
+        let sha256 = format!("{:X}", hasher.finalize());
+        Ok(sha256)
+    }
+
+    /// Add files to the zip archive
+    pub fn add_files(&self, files: &Vec<PathBuf>) -> Result<()> {
+        trace!(?self.path, ?files, "adding files to zip file");
+        let zipfile = File::open(&self.path).into_diagnostic()?;
+
+        let mut archive = ZipArchive::new(zipfile).into_diagnostic()?;
+
+        // Open a new, empty archive for writing to
+        let tmp_dir = tempfile::tempdir().into_diagnostic()?;
+        let tmp_path = tmp_dir
+            .path()
+            .join(self.path.file_name().expect("missing zip file name"));
+        let tmp = File::create(&tmp_path).into_diagnostic()?;
+        let mut new_archive = ZipWriter::new(tmp);
+
+        for i in 0..archive.len() {
+            let file = archive.by_index_raw(i).into_diagnostic()?;
+            new_archive.raw_copy_file(file).into_diagnostic()?;
+        }
+
+        include_files_in_zip(&mut new_archive, files)?;
+
+        new_archive
+            .finish()
+            .into_diagnostic()
+            .wrap_err_with(|| format!("failed to finish zip file `{}`", self.path.display()))?;
+
+        drop(archive);
+        copy_and_replace(&tmp_path, &self.path).into_diagnostic()?;
+        Ok(())
+    }
+}
+
+/// Search for the bootstrap file for a function inside the target directory.
+/// If the binary file exists, it creates the zip archive and extracts its architecture by reading the binary.
+pub fn create_binary_archive<M, P>(
+    manifest_path: M,
+    base_dir: &Option<P>,
+    data: &BinaryData,
+    include: Option<Vec<PathBuf>>,
+) -> Result<BinaryArchive>
+where
+    M: AsRef<Path> + Debug,
+    P: AsRef<Path>,
+{
+    let bootstrap_dir = if let Some(dir) = base_dir {
+        dir.as_ref().join(data.binary_location())
+    } else {
+        let target_dir = target_dir(manifest_path).unwrap_or_else(|_| PathBuf::from("target"));
+        target_dir.join("lambda").join(data.binary_location())
+    };
+
+    let binary_path = bootstrap_dir.join(data.binary_name());
+    if !binary_path.exists() {
+        return Err(
+            BuildError::BinaryMissing(data.binary_name().into(), data.build_help().into()).into(),
+        );
+    }
+
+    zip_binary(binary_path, bootstrap_dir, data, include)
+}
+
+/// Create a zip file from a function binary.
+/// The binary inside the zip file is called `bootstrap` for function binaries.
+/// The binary inside the zip file is called by its name, and put inside the `extensions`
+/// directory, for extension binaries.
+pub fn zip_binary<BP: AsRef<Path>, DD: AsRef<Path>>(
+    binary_path: BP,
+    destination_directory: DD,
+    data: &BinaryData,
+    include: Option<Vec<PathBuf>>,
+) -> Result<BinaryArchive> {
+    let path = binary_path.as_ref();
+    let dir = destination_directory.as_ref();
+
+    let zipped = dir.join(data.zip_name());
+    debug!(?data, ?path, ?dir, ?zipped, "zipping binary");
+
+    let zipped_binary = File::create(&zipped)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to create zip file `{zipped:?}`"))?;
+
+    let mut file = File::open(path)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to open binary file `{path:?}`"))?;
+
+    let mut binary_data = Vec::new();
+    file.read_to_end(&mut binary_data)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to read binary file `{path:?}`"))?;
+
+    let binary_data = &*binary_data;
+    let object = ObjectFile::parse(binary_data)
+        .into_diagnostic()
+        .wrap_err("the provided function file is not a valid Linux binary")?;
+
+    let arch = match object.architecture() {
+        Architecture::Aarch64 => "arm64",
+        Architecture::X86_64 => "x86_64",
+        other => return Err(BuildError::InvalidBinaryArchitecture(other).into()),
+    };
+
+    let mut zip = ZipWriter::new(zipped_binary);
+    if let Some(files) = include {
+        include_files_in_zip(&mut zip, &files)?;
+    }
+
+    let file_name = if let Some(parent) = data.parent_dir() {
+        let options = SimpleFileOptions::default();
+        zip.add_directory(parent, options)
+            .into_diagnostic()
+            .wrap_err_with(|| {
+                format!("failed to add directory `{parent}` to zip file `{zipped:?}`")
+            })?;
+        Path::new(parent).join(data.binary_name())
+    } else {
+        PathBuf::from(data.binary_name())
+    };
+
+    let zip_file_name = convert_to_unix_path(&file_name)
+        .ok_or_else(|| BuildError::InvalidUnixFileName(file_name.clone()))?;
+
+    let options = zip_file_options(&file, path)?;
+
+    zip.start_file(zip_file_name.to_string(), options)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to start zip file `{zip_file_name:?}`"))?;
+    zip.write_all(binary_data)
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to write data into zip file `{zip_file_name:?}`"))?;
+    zip.finish()
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to finish zip file `{zip_file_name:?}`"))?;
+
+    Ok(BinaryArchive {
+        architecture: arch.into(),
+        path: zipped,
+    })
+}
+
+fn zip_file_options(file: &File, path: &Path) -> Result<SimpleFileOptions> {
+    let meta = file
+        .metadata()
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to get metadata from file `{path:?}`"))?;
+    let perm = binary_permissions(&meta);
+    let mtime = binary_mtime(&meta)?;
+
+    Ok(SimpleFileOptions::default()
+        .unix_permissions(perm)
+        .last_modified_time(mtime))
+}
+
+fn include_files_in_zip<W>(zip: &mut ZipWriter<W>, files: &Vec<PathBuf>) -> Result<()>
+where
+    W: Write + Seek,
+{
+    for file in files {
+        for entry in WalkDir::new(file).into_iter().filter_map(|e| e.ok()) {
+            let path = entry.path();
+            let entry_name = convert_to_unix_path(path)
+                .ok_or_else(|| BuildError::InvalidUnixFileName(path.to_path_buf()))?;
+
+            if path.is_dir() {
+                trace!(?entry_name, "creating directory in zip file");
+
+                zip.add_directory(entry_name.to_string(), SimpleFileOptions::default())
+                    .into_diagnostic()
+                    .wrap_err_with(|| {
+                        format!("failed to add directory `{entry_name}` to zip file")
+                    })?;
+            } else {
+                let mut content = Vec::new();
+                let mut file = File::open(path)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("failed to open file `{path:?}`"))?;
+                file.read_to_end(&mut content)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("failed to read file `{path:?}`"))?;
+
+                trace!(?entry_name, "including file in zip file");
+
+                let options = zip_file_options(&file, path)?;
+
+                zip.start_file(entry_name.to_string(), options)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("failed to start zip file `{entry_name:?}`"))?;
+
+                zip.write_all(&content)
+                    .into_diagnostic()
+                    .wrap_err_with(|| {
+                        format!("failed to write data into zip file `{entry_name:?}`")
+                    })?;
+            }
+        }
+    }
+    Ok(())
+}
+
+fn binary_mtime(meta: &Metadata) -> Result<zip::DateTime> {
+    let modified = meta
+        .modified()
+        .into_diagnostic()
+        .wrap_err("failed to get modified time")?;
+
+    let dt: DateTime<Utc> = modified.into();
+    zip::DateTime::try_from(dt.naive_utc())
+        .into_diagnostic()
+        .wrap_err_with(|| format!("failed to convert `{modified:?}` to zip time"))
+}
+
+#[cfg(unix)]
+fn binary_permissions(meta: &Metadata) -> u32 {
+    use std::os::unix::fs::PermissionsExt;
+    meta.permissions().mode()
+}
+
+#[cfg(not(unix))]
+fn binary_permissions(meta: &Metadata) -> u32 {
+    0o755
+}
+
+#[cfg(target_os = "windows")]
+fn convert_to_unix_path(path: &Path) -> Option<Cow<'_, str>> {
+    let mut path_str = String::new();
+    for component in path.components() {
+        if let std::path::Component::Normal(os_str) = component {
+            if !path_str.is_empty() {
+                path_str.push('/');
+            }
+            path_str.push_str(os_str.to_str()?);
+        }
+    }
+    Some(Cow::Owned(path_str))
+}
+
+#[cfg(not(target_os = "windows"))]
+fn convert_to_unix_path(path: &Path) -> Option<Cow<'_, str>> {
+    path.to_str().map(Cow::Borrowed)
+}
+
+#[cfg(test)]
+mod test {
+    use std::{
+        fs::{create_dir_all, remove_dir_all},
+        thread::sleep,
+        time::Duration,
+    };
+
+    use cargo_lambda_metadata::fs::copy_without_replace;
+    use rstest::rstest;
+    use tempfile::TempDir;
+
+    use super::*;
+
+    #[test]
+    fn test_convert_to_unix_path() {
+        // On Windows, a PathBuff constructed from Path::join will have "\" as separator, while on Unix-like systems it will have "/"
+        let path = Path::new("extensions").join("test").join("filename");
+        assert_eq!(
+            "extensions/test/filename",
+            convert_to_unix_path(&path).expect("failed to convert file path")
+        );
+    }
+
+    #[test]
+    fn test_convert_to_unix_path_keep_original() {
+        let path = Path::new("extensions/test/filename");
+        assert_eq!(
+            "extensions/test/filename",
+            convert_to_unix_path(path).expect("failed to convert file path")
+        );
+    }
+
+    #[test]
+    fn test_convert_to_unix_path_empty_path() {
+        let path = Path::new("");
+        assert_eq!(
+            "",
+            convert_to_unix_path(path).expect("failed to convert file path")
+        );
+    }
+
+    #[rstest]
+    #[case("binary-x86-64", "x86_64")]
+    #[case("binary-arm64", "arm64")]
+    fn test_zip_funcion(#[case] name: &str, #[case] arch: &str) {
+        let data = BinaryData::new(name, false, false);
+        let bp = &format!("../../tests/binaries/{name}");
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+        let archive =
+            zip_binary(bp, dd.path(), &data, None).expect("failed to create binary archive");
+
+        assert_eq!(arch, archive.architecture);
+
+        let arch_path = dd.path().join("bootstrap.zip");
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name("bootstrap")
+            .expect("failed to find bootstrap in zip archive");
+    }
+
+    #[rstest]
+    #[case("binary-x86-64", "x86_64")]
+    #[case("binary-arm64", "arm64")]
+    fn test_zip_extension(#[case] name: &str, #[case] arch: &str) {
+        let data = BinaryData::new(name, true, false);
+
+        let bp = &format!("../../tests/binaries/{name}");
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+        let archive =
+            zip_binary(bp, dd.path(), &data, None).expect("failed to create binary archive");
+
+        assert_eq!(arch, archive.architecture);
+
+        let arch_path = dd.path().join(format!("{name}.zip"));
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name(&format!("extensions/{name}"))
+            .expect("failed to find bootstrap in zip archive");
+    }
+
+    #[rstest]
+    #[case("binary-x86-64", "x86_64")]
+    #[case("binary-arm64", "arm64")]
+    fn test_zip_internal_extension(#[case] name: &str, #[case] arch: &str) {
+        let data = BinaryData::new(name, true, true);
+
+        let bp = &format!("../../tests/binaries/{name}");
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+        let archive =
+            zip_binary(bp, dd.path(), &data, None).expect("failed to create binary archive");
+
+        assert_eq!(arch, archive.architecture);
+
+        let arch_path = dd.path().join(format!("{name}.zip"));
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name(name)
+            .expect(&format!("failed to find {name} in zip archive"));
+    }
+
+    #[rstest]
+    #[case("binary-x86-64", "x86_64")]
+    #[case("binary-arm64", "arm64")]
+    fn test_zip_funcion_with_files(#[case] name: &str, #[case] arch: &str) {
+        let data = BinaryData::new(name, false, false);
+
+        let bp = &format!("../../tests/binaries/{name}");
+        let extra = vec!["Cargo.toml".into()];
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+        let archive =
+            zip_binary(bp, dd.path(), &data, Some(extra)).expect("failed to create binary archive");
+
+        assert_eq!(arch, archive.architecture);
+
+        let arch_path = dd.path().join("bootstrap.zip");
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name("bootstrap")
+            .expect("failed to find bootstrap in zip archive");
+
+        zip.by_name("Cargo.toml")
+            .expect("failed to find Cargo.toml in zip archive");
+    }
+
+    #[test]
+    fn test_consistent_hash() {
+        let data = BinaryData::new("binary-x86-64", false, false);
+
+        let bp = &format!("../../tests/binaries/binary-x86-64");
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+
+        let archive1 =
+            zip_binary(bp, dd.path(), &data, None).expect("failed to create binary archive");
+
+        // Sleep to ensure that the mtime is different enough for the hash to change
+        sleep(Duration::from_secs(2));
+
+        let archive2 =
+            zip_binary(bp, dd.path(), &data, None).expect("failed to create binary archive");
+
+        assert_eq!(archive1.sha256().unwrap(), archive2.sha256().unwrap());
+    }
+
+    #[test]
+    fn test_add_files() {
+        let data = BinaryData::new("binary-x86-64", false, false);
+
+        let bp = &format!("../../tests/binaries/binary-x86-64");
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+
+        let archive =
+            zip_binary(bp, dd.path(), &data, None).expect("failed to create binary archive");
+
+        let extra = vec!["Cargo.toml".into()];
+        archive.add_files(&extra).expect("failed to add files");
+
+        let arch_path = dd.path().join("bootstrap.zip");
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name("bootstrap")
+            .expect("failed to find bootstrap in zip archive");
+
+        zip.by_name("Cargo.toml")
+            .expect("failed to find Cargo.toml in zip archive");
+    }
+
+    #[test]
+    fn test_create_binary_archive_with_base_path() {
+        let data = BinaryData::new("binary-x86-64", false, false);
+
+        let bp = &format!("../../tests/binaries/binary-x86-64");
+        let dd = TempDir::with_prefix("cargo-lambda-").expect("failed to create temp dir");
+        let bsp = dd.path().join("binary-x86-64");
+
+        create_dir_all(&bsp).expect("failed to create dir");
+        copy_without_replace(bp, bsp.join("bootstrap")).expect("failed to copy bootstrap file");
+
+        let archive = create_binary_archive("Cargo.toml", &Some(dd.path()), &data, None)
+            .expect("failed to create binary archive");
+
+        let arch_path = bsp.join("bootstrap.zip");
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name("bootstrap")
+            .expect("failed to find bootstrap in zip archive");
+    }
+
+    #[test]
+    fn test_create_binary_archive_from_target() {
+        let data = BinaryData::new("binary-x86-64", false, false);
+
+        let bp = &format!("../../tests/binaries/binary-x86-64");
+        let target_dir = target_dir("Cargo.toml").unwrap_or_else(|_| PathBuf::from("target"));
+
+        let bsp = target_dir.join("lambda").join("binary-x86-64");
+
+        create_dir_all(&bsp).expect("failed to create dir");
+        copy_without_replace(bp, bsp.join("bootstrap")).expect("failed to copy bootstrap file");
+
+        let base_dir: Option<&Path> = None;
+        let archive = create_binary_archive("Cargo.toml", &base_dir, &data, None)
+            .expect("failed to create binary archive");
+
+        let arch_path = bsp.join("bootstrap.zip");
+        assert_eq!(arch_path, archive.path);
+
+        let file = File::open(arch_path).expect("failed to open zip file");
+        let mut zip = ZipArchive::new(file).expect("failed to open zip archive");
+
+        zip.by_name("bootstrap")
+            .expect("failed to find bootstrap in zip archive");
+
+        remove_dir_all(&bsp).expect("failed to delete dir");
+    }
+}
diff --git a/crates/cargo-lambda-build/src/lib.rs b/crates/cargo-lambda-build/src/lib.rs
index 241a3fca..e01ea452 100644
--- a/crates/cargo-lambda-build/src/lib.rs
+++ b/crates/cargo-lambda-build/src/lib.rs
@@ -2,34 +2,29 @@ use cargo_lambda_interactive::{error::InquireError, is_user_cancellation_error};
 use cargo_lambda_metadata::{
     cargo::{
         binary_targets_from_metadata, cargo_release_profile_config, function_build_metadata,
-        load_metadata, target_dir, target_dir_from_metadata, CompilerOptions,
+        load_metadata, target_dir_from_metadata, CompilerOptions,
     },
     fs::copy_and_replace,
 };
 use cargo_options::Build as CargoBuild;
-use chrono::{DateTime, Utc};
 use clap::{Args, ValueHint};
 use miette::{IntoDiagnostic, Report, Result, WrapErr};
-use object::{read::File as ObjectFile, Architecture, Object};
-use sha2::{Digest, Sha256};
 use std::{
-    borrow::Cow,
-    fmt,
-    fs::{create_dir_all, read, File, Metadata},
-    io::{self, Read, Write},
+    fs::create_dir_all,
     path::{Path, PathBuf},
     str::FromStr,
 };
 use strum_macros::EnumString;
 use target_arch::TargetArch;
-use toolchain::rustup_cmd;
-use tracing::{debug, trace, warn};
-use walkdir::WalkDir;
-use zip::{write::SimpleFileOptions, ZipWriter};
+use tracing::{debug, warn};
 
 pub use cargo_zigbuild::Zig;
 
+mod archive;
+pub use archive::{create_binary_archive, zip_binary, BinaryArchive, BinaryData};
+
 mod compiler;
+use compiler::{build_command, build_profile};
 
 mod error;
 use error::BuildError;
@@ -37,9 +32,9 @@ use error::BuildError;
 mod target_arch;
 use target_arch::validate_linux_target;
 
-use crate::compiler::{build_command, build_profile};
-
 mod toolchain;
+use toolchain::rustup_cmd;
+
 mod zig;
 
 #[derive(Args, Clone, Debug)]
@@ -252,15 +247,11 @@ impl Build {
                         format!("error creating lambda directory {bootstrap_dir:?}")
                     })?;
 
-                let bin_name = if self.extension {
-                    name.as_str()
-                } else {
-                    "bootstrap"
-                };
+                let data = BinaryData::new(name.as_str(), self.extension, self.internal);
 
                 match self.output_format {
                     OutputFormat::Binary => {
-                        let output_location = bootstrap_dir.join(bin_name);
+                        let output_location = bootstrap_dir.join(data.binary_name());
                         copy_and_replace(&binary, &output_location)
                             .into_diagnostic()
                             .wrap_err_with(|| {
@@ -268,16 +259,11 @@ impl Build {
                             })?;
                     }
                     OutputFormat::Zip => {
-                        let parent = if self.extension && !self.internal {
-                            Some("extensions")
-                        } else {
-                            None
-                        };
                         let extra_files = self
                             .include
                             .clone()
                             .or_else(|| build_config.include.clone());
-                        zip_binary(bin_name, binary, bootstrap_dir, parent, extra_files)?;
+                        zip_binary(binary, bootstrap_dir, &data, extra_files)?;
                     }
                 }
             }
@@ -294,315 +280,9 @@ impl Build {
     }
 }
 
-pub struct BinaryArchive {
-    pub architecture: String,
-    pub sha256: String,
-    pub path: PathBuf,
-}
-
-impl BinaryArchive {
-    pub fn read(&self) -> Result<Vec<u8>> {
-        read(&self.path)
-            .into_diagnostic()
-            .wrap_err("failed to read binary archive")
-    }
-
-    pub fn add_files(&self, files: &Vec<PathBuf>) -> Result<()> {
-        trace!(?self.path, ?files, "adding files to zip file");
-        let zipfile = std::fs::File::open(&self.path).into_diagnostic()?;
-
-        let mut archive = zip::ZipArchive::new(zipfile).into_diagnostic()?;
-
-        // Open a new, empty archive for writing to
-        let tmp_dir = tempfile::tempdir().into_diagnostic()?;
-        let tmp_path = tmp_dir
-            .path()
-            .join(self.path.file_name().expect("missing zip file name"));
-        let tmp = File::create(&tmp_path).into_diagnostic()?;
-        let mut new_archive = zip::ZipWriter::new(tmp);
-
-        for i in 0..archive.len() {
-            let file = archive.by_index_raw(i).into_diagnostic()?;
-            new_archive.raw_copy_file(file).into_diagnostic()?;
-        }
-
-        include_files_in_zip(&mut new_archive, files)?;
-
-        new_archive
-            .finish()
-            .into_diagnostic()
-            .wrap_err_with(|| format!("failed to finish zip file `{}`", self.path.display()))?;
-
-        drop(archive);
-        copy_and_replace(&tmp_path, &self.path).into_diagnostic()?;
-        Ok(())
-    }
-}
-
-/// Search for the bootstrap file for a function inside the target directory.
-/// If the binary file exists, it creates the zip archive and extracts its architecture by reading the binary.
-pub fn find_binary_archive<M, P>(
-    name: &str,
-    manifest_path: M,
-    base_dir: &Option<P>,
-    is_extension: bool,
-    is_internal: bool,
-    include: Option<Vec<PathBuf>>,
-) -> Result<BinaryArchive>
-where
-    M: AsRef<Path> + fmt::Debug,
-    P: AsRef<Path>,
-{
-    let target_dir = target_dir(manifest_path).unwrap_or_else(|_| PathBuf::from("target"));
-    let (dir_name, binary_name, parent) = if is_extension && is_internal {
-        ("extensions", name, None)
-    } else if is_extension && !is_internal {
-        ("extensions", name, Some("extensions"))
-    } else {
-        (name, "bootstrap", None)
-    };
-
-    let bootstrap_dir = if let Some(dir) = base_dir {
-        dir.as_ref().join(dir_name)
-    } else {
-        target_dir.join("lambda").join(dir_name)
-    };
-
-    let binary_path = bootstrap_dir.join(binary_name);
-    if !binary_path.exists() {
-        let build_cmd = if is_extension && is_internal {
-            "build --extension --internal"
-        } else if is_extension && !is_internal {
-            "build --extension"
-        } else {
-            "build"
-        };
-        return Err(BuildError::BinaryMissing(name.into(), build_cmd.into()).into());
-    }
-
-    zip_binary(binary_name, binary_path, bootstrap_dir, parent, include)
-}
-
-/// Create a zip file from a function binary.
-/// The binary inside the zip file is called `bootstrap` for function binaries.
-/// The binary inside the zip file is called by its name, and put inside the `extensions`
-/// directory, for extension binaries.
-pub fn zip_binary<BP: AsRef<Path>, DD: AsRef<Path>>(
-    name: &str,
-    binary_path: BP,
-    destination_directory: DD,
-    parent: Option<&str>,
-    include: Option<Vec<PathBuf>>,
-) -> Result<BinaryArchive> {
-    let path = binary_path.as_ref();
-    let dir = destination_directory.as_ref();
-    let zipped = dir.join(format!("{name}.zip"));
-    debug!(name, parent, ?path, ?dir, ?zipped, "zipping binary");
-
-    let zipped_binary = File::create(&zipped)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to create zip file `{zipped:?}`"))?;
-
-    let mut file = File::open(path)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to open binary file `{path:?}`"))?;
-
-    let mut binary_data = Vec::new();
-    file.read_to_end(&mut binary_data)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to read binary file `{path:?}`"))?;
-
-    let binary_data = &*binary_data;
-    let object = ObjectFile::parse(binary_data)
-        .into_diagnostic()
-        .wrap_err("the provided function file is not a valid Linux binary")?;
-
-    let arch = match object.architecture() {
-        Architecture::Aarch64 => "arm64",
-        Architecture::X86_64 => "x86_64",
-        other => return Err(BuildError::InvalidBinaryArchitecture(other).into()),
-    };
-
-    let mut hasher = Sha256::new();
-    hasher.update(binary_data);
-    let sha256 = format!("{:X}", hasher.finalize());
-
-    let mut zip = ZipWriter::new(zipped_binary);
-    if let Some(files) = include {
-        include_files_in_zip(&mut zip, &files)?;
-    }
-
-    let file_name = if let Some(parent) = parent {
-        let options = SimpleFileOptions::default();
-        zip.add_directory(parent, options)
-            .into_diagnostic()
-            .wrap_err_with(|| {
-                format!("failed to add directory `{parent}` to zip file `{zipped:?}`")
-            })?;
-        Path::new(parent).join(name)
-    } else {
-        PathBuf::from(name)
-    };
-
-    let zip_file_name = convert_to_unix_path(&file_name)
-        .ok_or_else(|| BuildError::InvalidUnixFileName(file_name.clone()))?;
-
-    let options = zip_file_options(&file, path)?;
-
-    zip.start_file(zip_file_name.to_string(), options)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to start zip file `{zip_file_name:?}`"))?;
-    zip.write_all(binary_data)
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to write data into zip file `{zip_file_name:?}`"))?;
-    zip.finish()
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to finish zip file `{zip_file_name:?}`"))?;
-
-    Ok(BinaryArchive {
-        architecture: arch.into(),
-        path: zipped,
-        sha256,
-    })
-}
-
-fn zip_file_options(file: &File, path: &Path) -> Result<SimpleFileOptions> {
-    let meta = file
-        .metadata()
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to get metadata from file `{path:?}`"))?;
-    let perm = binary_permissions(&meta);
-    let mtime = binary_mtime(&meta)?;
-
-    Ok(SimpleFileOptions::default()
-        .unix_permissions(perm)
-        .last_modified_time(mtime))
-}
-
-fn binary_mtime(meta: &Metadata) -> Result<zip::DateTime> {
-    let modified = meta
-        .modified()
-        .into_diagnostic()
-        .wrap_err("failed to get modified time")?;
-
-    let dt: DateTime<Utc> = modified.into();
-    zip::DateTime::try_from(dt.naive_utc())
-        .into_diagnostic()
-        .wrap_err_with(|| format!("failed to convert `{modified:?}` to zip time"))
-}
-
-#[cfg(unix)]
-fn binary_permissions(meta: &Metadata) -> u32 {
-    use std::os::unix::fs::PermissionsExt;
-    meta.permissions().mode()
-}
-
-#[cfg(not(unix))]
-fn binary_permissions(meta: &Metadata) -> u32 {
-    0o755
-}
-
-#[cfg(target_os = "windows")]
-fn convert_to_unix_path(path: &Path) -> Option<Cow<'_, str>> {
-    let mut path_str = String::new();
-    for component in path.components() {
-        if let std::path::Component::Normal(os_str) = component {
-            if !path_str.is_empty() {
-                path_str.push('/');
-            }
-            path_str.push_str(os_str.to_str()?);
-        }
-    }
-    Some(Cow::Owned(path_str))
-}
-
-#[cfg(not(target_os = "windows"))]
-fn convert_to_unix_path(path: &Path) -> Option<Cow<'_, str>> {
-    path.to_str().map(Cow::Borrowed)
-}
-
 fn downcasted_user_cancellation(err: &Report) -> bool {
     match err.root_cause().downcast_ref::<InquireError>() {
         Some(err) => is_user_cancellation_error(err),
         None => false,
     }
 }
-
-fn include_files_in_zip<W>(zip: &mut ZipWriter<W>, files: &Vec<PathBuf>) -> Result<()>
-where
-    W: Write + io::Seek,
-{
-    for file in files {
-        for entry in WalkDir::new(file).into_iter().filter_map(|e| e.ok()) {
-            let path = entry.path();
-            let entry_name = convert_to_unix_path(path)
-                .ok_or_else(|| BuildError::InvalidUnixFileName(path.to_path_buf()))?;
-
-            if path.is_dir() {
-                trace!(?entry_name, "creating directory in zip file");
-
-                zip.add_directory(entry_name.to_string(), SimpleFileOptions::default())
-                    .into_diagnostic()
-                    .wrap_err_with(|| {
-                        format!("failed to add directory `{entry_name}` to zip file")
-                    })?;
-            } else {
-                let mut content = Vec::new();
-                let mut file = File::open(path)
-                    .into_diagnostic()
-                    .wrap_err_with(|| format!("failed to open file `{path:?}`"))?;
-                file.read_to_end(&mut content)
-                    .into_diagnostic()
-                    .wrap_err_with(|| format!("failed to read file `{path:?}`"))?;
-
-                trace!(?entry_name, "including file in zip file");
-
-                let options = zip_file_options(&file, path)?;
-
-                zip.start_file(entry_name.to_string(), options)
-                    .into_diagnostic()
-                    .wrap_err_with(|| format!("failed to start zip file `{entry_name:?}`"))?;
-
-                zip.write_all(&content)
-                    .into_diagnostic()
-                    .wrap_err_with(|| {
-                        format!("failed to write data into zip file `{entry_name:?}`")
-                    })?;
-            }
-        }
-    }
-    Ok(())
-}
-
-#[cfg(test)]
-mod test {
-    use super::*;
-
-    #[test]
-    fn test_convert_to_unix_path() {
-        // On Windows, a PathBuff constructed from Path::join will have "\" as separator, while on Unix-like systems it will have "/"
-        let path = Path::new("extensions").join("test").join("filename");
-        assert_eq!(
-            "extensions/test/filename",
-            convert_to_unix_path(&path).expect("failed to convert file path")
-        );
-    }
-
-    #[test]
-    fn test_convert_to_unix_path_keep_original() {
-        let path = Path::new("extensions/test/filename");
-        assert_eq!(
-            "extensions/test/filename",
-            convert_to_unix_path(path).expect("failed to convert file path")
-        );
-    }
-
-    #[test]
-    fn test_convert_to_unix_path_empty_path() {
-        let path = Path::new("");
-        assert_eq!(
-            "",
-            convert_to_unix_path(path).expect("failed to convert file path")
-        );
-    }
-}
diff --git a/crates/cargo-lambda-deploy/src/lib.rs b/crates/cargo-lambda-deploy/src/lib.rs
index 6ddd50fc..346cd76b 100644
--- a/crates/cargo-lambda-deploy/src/lib.rs
+++ b/crates/cargo-lambda-deploy/src/lib.rs
@@ -1,5 +1,5 @@
 use aws_smithy_types::retry::{RetryConfig, RetryMode};
-use cargo_lambda_build::{find_binary_archive, zip_binary, BinaryArchive};
+use cargo_lambda_build::{create_binary_archive, zip_binary, BinaryArchive, BinaryData};
 use cargo_lambda_interactive::progress::Progress;
 use cargo_lambda_metadata::cargo::{function_deploy_metadata, main_binary, DeployConfig};
 use cargo_lambda_remote::{
@@ -244,8 +244,8 @@ impl Deploy {
     }
 
     fn load_archive(&self) -> Result<(String, BinaryArchive)> {
-        let arc = match &self.binary_path {
-            Some(bp) if bp.is_dir() => return Err(miette::miette!("invalid file {:?}", bp)),
+        match &self.binary_path {
+            Some(bp) if bp.is_dir() => Err(miette::miette!("invalid file {:?}", bp)),
             Some(bp) => {
                 let name = match &self.name {
                     Some(name) => name.clone(),
@@ -260,14 +260,9 @@ impl Deploy {
                     .parent()
                     .ok_or_else(|| miette::miette!("invalid binary path {:?}", bp))?;
 
-                let parent = if self.extension && !self.internal {
-                    Some("extensions")
-                } else {
-                    None
-                };
-
-                let arc = zip_binary(&name, bp, destination, parent, self.include.clone())?;
-                (name, arc)
+                let data = BinaryData::new(&name, self.extension, self.internal);
+                let arc = zip_binary(bp, destination, &data, self.include.clone())?;
+                Ok((name, arc))
             }
             None => {
                 let name = match (&self.name, &self.binary_name) {
@@ -276,19 +271,17 @@ impl Deploy {
                     (None, None) => main_binary(&self.manifest_path).into_diagnostic()?,
                 };
                 let binary_name = self.binary_name_or_default(&name);
+                let data = BinaryData::new(&binary_name, self.extension, self.internal);
 
-                let arc = find_binary_archive(
-                    &binary_name,
+                let arc = create_binary_archive(
                     &self.manifest_path,
                     &self.lambda_dir,
-                    self.extension,
-                    self.internal,
+                    &data,
                     self.include.clone(),
                 )?;
-                (name, arc)
+                Ok((name, arc))
             }
-        };
-        Ok(arc)
+        }
     }
 
     fn dry_output(
diff --git a/crates/cargo-lambda-metadata/src/fs/copy.rs b/crates/cargo-lambda-metadata/src/fs/copy.rs
index 0125e456..bb772fb5 100644
--- a/crates/cargo-lambda-metadata/src/fs/copy.rs
+++ b/crates/cargo-lambda-metadata/src/fs/copy.rs
@@ -53,12 +53,16 @@ where
 {
     let from = from.as_ref();
     if from.is_dir() {
-        copy_dir(from, to, replace).and(remove_dir_all::remove_dir_all(from))
+        return copy_dir(from, to, replace).and(remove_dir_all::remove_dir_all(from));
     } else if replace || !to.as_ref().exists() {
-        fs::copy(from, to).and(fs::remove_file(from))
-    } else {
-        Ok(())
+        fs::copy(from, to)?;
     }
+
+    if replace {
+        fs::remove_file(from)?;
+    }
+
+    Ok(())
 }
 
 fn copy_dir<P, Q>(from: P, to: Q, replace: bool) -> io::Result<()>


Test patch:
diff --git a/tests/binaries/binary-arm64 b/tests/binaries/binary-arm64
new file mode 100755
index 00000000..5bfd6bd1
Binary files /dev/null and b/tests/binaries/binary-arm64 differ
diff --git a/tests/binaries/binary-x86-64 b/tests/binaries/binary-x86-64
new file mode 100755
index 00000000..c07e53a9
Binary files /dev/null and b/tests/binaries/binary-x86-64 differ