refactor(zipapp): implement rust exe_zip_maker program (#4151)

Creating self-executable zip archives currently relies on Python script
execution during builds, which incurs interpreter startup overhead and
requires a Python runtime.

Provide a compiled Rust implementation of exe_zip_maker. The tool
computes the SHA-256 digest of the input zip archive, substitutes the
%ZIP_HASH% placeholder within the executable preamble script, and
concatenates the modified preamble with the zip payload.

This change only adds a Rust implementation. Subsequent changes will
wire it into the overall build process as a prebuilt tool.
diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages
index da79f11..5654df1 100644
--- a/.bazelrc.deleted_packages
+++ b/.bazelrc.deleted_packages
@@ -54,3 +54,4 @@
 common --deleted_packages=tests/modules/other/simple_v2
 common --deleted_packages=tests/modules/other/with_external_data
 common --deleted_packages=tests/modules/rules_pyrefly_stub/pyrefly
+common --deleted_packages=tests/modules/rules_rust_stub/rust
diff --git a/BUILD.bazel b/BUILD.bazel
index f978126..9013928 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -79,6 +79,7 @@
         "internal_dev_setup.bzl",
         "version.bzl",
         "//command_line_option:distribution",
+        "//crates:distribution",
         "//python:distribution",
         "//tools:distribution",
     ],
diff --git a/MODULE.bazel b/MODULE.bazel
index 53a5674..3ce0c80 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -98,6 +98,20 @@
 bazel_dep(name = "rules_multirun", version = "0.9.0", dev_dependency = True)
 bazel_dep(name = "bazel_ci_rules", version = "1.0.0", dev_dependency = True)
 bazel_dep(name = "rules_pkg", version = "1.2.0", dev_dependency = True)
+bazel_dep(name = "rules_rust", version = "0.73.0", dev_dependency = True)
+
+rust_crates = use_extension(
+    "@rules_rust//crate_universe:extensions.bzl",
+    "crate",
+    dev_dependency = True,
+)
+rust_crates.spec(
+    package = "sha2",
+    version = "0.10.8",
+)
+rust_crates.from_specs()
+use_repo(rust_crates, "crates")
+
 bazel_dep(name = "other", version = "0", dev_dependency = True)
 bazel_dep(name = "another_module", version = "0", dev_dependency = True)
 
diff --git a/crates/BUILD.bazel b/crates/BUILD.bazel
new file mode 100644
index 0000000..d6bf725
--- /dev/null
+++ b/crates/BUILD.bazel
@@ -0,0 +1,10 @@
+package(default_visibility = ["//:__subpackages__"])
+
+licenses(["notice"])
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["**"]) + [
+        "//crates/exe_zip_maker:distribution",
+    ],
+)
diff --git a/crates/exe_zip_maker/BUILD.bazel b/crates/exe_zip_maker/BUILD.bazel
new file mode 100644
index 0000000..94c4783
--- /dev/null
+++ b/crates/exe_zip_maker/BUILD.bazel
@@ -0,0 +1,25 @@
+load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library")
+
+package(default_visibility = ["//:__subpackages__"])
+
+licenses(["notice"])
+
+rust_library(
+    name = "exe_zip_maker_lib",
+    srcs = ["src/lib.rs"],
+    edition = "2021",
+    deps = ["@crates//:sha2"],
+)
+
+rust_binary(
+    name = "exe_zip_maker",
+    srcs = ["src/main.rs"],
+    edition = "2021",
+    visibility = ["//visibility:public"],
+    deps = [":exe_zip_maker_lib"],
+)
+
+filegroup(
+    name = "distribution",
+    srcs = glob(["**"]),
+)
diff --git a/crates/exe_zip_maker/src/lib.rs b/crates/exe_zip_maker/src/lib.rs
new file mode 100644
index 0000000..a7b467e
--- /dev/null
+++ b/crates/exe_zip_maker/src/lib.rs
@@ -0,0 +1,79 @@
+//! Library supporting creating self-executable zip files.
+
+use std::fs::{self, File};
+use std::io::{self, BufReader, BufWriter, Read, Write};
+use std::path::Path;
+
+use sha2::{Digest, Sha256};
+
+pub const BLOCK_SIZE: usize = 256 * 1024;
+pub const PLACEHOLDER: &[u8] = b"%ZIP_HASH%";
+
+/// Replaces all occurrences of `from` with `to` in `src`.
+pub fn replace_bytes(src: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
+    if from.is_empty() {
+        return src.to_vec();
+    }
+    let mut result = Vec::new();
+    let mut i = 0;
+    while i < src.len() {
+        if src[i..].starts_with(from) {
+            result.extend_from_slice(to);
+            i += from.len();
+        } else {
+            result.push(src[i]);
+            i += 1;
+        }
+    }
+    result
+}
+
+/// Computes the SHA256 hex digest of the file at `path`.
+pub fn compute_file_sha256_hex(path: &Path) -> io::Result<String> {
+    let mut file = File::open(path)?;
+    let mut hasher = Sha256::new();
+    let mut buffer = [0u8; BLOCK_SIZE];
+    loop {
+        let n = file.read(&mut buffer)?;
+        if n == 0 {
+            break;
+        }
+        hasher.update(&buffer[..n]);
+    }
+    let digest = hasher.finalize();
+    Ok(format!("{:x}", digest))
+}
+
+/// Creates a self-executable zip archive by prepending a preamble to a zip archive
+/// and substituting `%ZIP_HASH%` with the SHA-256 hash of the zip archive.
+pub fn create_exe_zip(preamble_path: &Path, zip_path: &Path, output_path: &Path) -> io::Result<()> {
+    if let Some(parent) = output_path.parent() {
+        if !parent.as_os_str().is_empty() {
+            fs::create_dir_all(parent)?;
+        }
+    }
+
+    let zip_hash = compute_file_sha256_hex(zip_path)?;
+
+    let preamble_content = fs::read(preamble_path)?;
+    let modified_preamble = replace_bytes(&preamble_content, PLACEHOLDER, zip_hash.as_bytes());
+
+    let mut out_file = BufWriter::with_capacity(BLOCK_SIZE, File::create(output_path)?);
+    out_file.write_all(&modified_preamble)?;
+
+    let zip_file = File::open(zip_path)?;
+    let mut zip_reader = BufReader::with_capacity(BLOCK_SIZE, zip_file);
+    io::copy(&mut zip_reader, &mut out_file)?;
+    out_file.flush()?;
+
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        let metadata = fs::metadata(output_path)?;
+        let mut perms = metadata.permissions();
+        perms.set_mode(perms.mode() | 0o111);
+        fs::set_permissions(output_path, perms)?;
+    }
+
+    Ok(())
+}
diff --git a/crates/exe_zip_maker/src/main.rs b/crates/exe_zip_maker/src/main.rs
new file mode 100644
index 0000000..d66fdb2
--- /dev/null
+++ b/crates/exe_zip_maker/src/main.rs
@@ -0,0 +1,24 @@
+use std::env;
+use std::path::Path;
+use std::process;
+
+fn main() {
+    let args: Vec<_> = env::args_os().collect();
+    if args.len() != 4 {
+        let prog_name = args
+            .first()
+            .map(|s| s.to_string_lossy().into_owned())
+            .unwrap_or_else(|| "exe_zip_maker".to_string());
+        eprintln!("Usage: {} <preamble> <zip> <output>", prog_name);
+        process::exit(1);
+    }
+
+    let preamble_path = Path::new(&args[1]);
+    let zip_path = Path::new(&args[2]);
+    let output_path = Path::new(&args[3]);
+
+    if let Err(e) = exe_zip_maker_lib::create_exe_zip(preamble_path, zip_path, output_path) {
+        eprintln!("exe_zip_maker: error: {}", e);
+        process::exit(1);
+    }
+}
diff --git a/internal_dev_deps.bzl b/internal_dev_deps.bzl
index 6d4d765..68b8f89 100644
--- a/internal_dev_deps.bzl
+++ b/internal_dev_deps.bzl
@@ -114,6 +114,13 @@
         path = "tests/modules/rules_pyrefly_stub",
     )
 
+    # Stub repository for rules_rust in WORKSPACE mode so that load()
+    # statements for @rules_rust resolve without requiring full rules_rust.
+    local_repository(
+        name = "rules_rust",
+        path = "tests/modules/rules_rust_stub",
+    )
+
     # The below two deps are required for the integration test with bazel
     # gazelle. Maybe the test should be moved to the `gazelle` workspace?
     http_archive(
diff --git a/tests/exe_zip_maker/BUILD.bazel b/tests/exe_zip_maker/BUILD.bazel
new file mode 100644
index 0000000..4a9190d
--- /dev/null
+++ b/tests/exe_zip_maker/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@rules_rust//rust:defs.bzl", "rust_test")
+
+package(default_visibility = ["//:__subpackages__"])
+
+licenses(["notice"])
+
+rust_test(
+    name = "exe_zip_maker_test",
+    size = "small",
+    srcs = ["exe_zip_maker_test.rs"],
+    deps = [
+        "//crates/exe_zip_maker:exe_zip_maker_lib",
+    ],
+)
diff --git a/tests/exe_zip_maker/exe_zip_maker_test.rs b/tests/exe_zip_maker/exe_zip_maker_test.rs
new file mode 100644
index 0000000..dcd6f94
--- /dev/null
+++ b/tests/exe_zip_maker/exe_zip_maker_test.rs
@@ -0,0 +1,155 @@
+use std::env;
+use std::fs;
+
+use exe_zip_maker_lib::{
+    compute_file_sha256_hex, create_exe_zip, replace_bytes, PLACEHOLDER,
+};
+
+#[test]
+fn test_replace_bytes_none() {
+    let src = b"hello world";
+    assert_eq!(replace_bytes(src, b"foo", b"bar"), b"hello world");
+}
+
+#[test]
+fn test_replace_bytes_single() {
+    let src = b"EXPECTED_HASH='%ZIP_HASH%'";
+    let replaced = replace_bytes(src, PLACEHOLDER, b"12345678");
+    assert_eq!(replaced, b"EXPECTED_HASH='12345678'");
+}
+
+#[test]
+fn test_replace_bytes_multiple() {
+    let src = b"%ZIP_HASH% and %ZIP_HASH%";
+    let replaced = replace_bytes(src, PLACEHOLDER, b"abc");
+    assert_eq!(replaced, b"abc and abc");
+}
+
+#[test]
+fn test_replace_bytes_empty_from() {
+    let src = b"unchanged";
+    assert_eq!(replace_bytes(src, b"", b"abc"), b"unchanged");
+}
+
+#[test]
+fn test_compute_file_sha256_hex() {
+    let temp_dir = env::temp_dir().join(format!("sha256_test_{}", std::process::id()));
+    fs::create_dir_all(&temp_dir).unwrap();
+    let file_path = temp_dir.join("sample.txt");
+
+    fs::write(&file_path, b"hello world\n").unwrap();
+    let hash = compute_file_sha256_hex(&file_path).unwrap();
+    assert_eq!(
+        hash,
+        "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
+    );
+
+    let _ = fs::remove_dir_all(&temp_dir);
+}
+
+#[test]
+fn test_create_exe_zip_successful() {
+    let temp_dir = env::temp_dir().join(format!("create_exe_zip_test_{}", std::process::id()));
+    fs::create_dir_all(&temp_dir).unwrap();
+
+    let preamble_path = temp_dir.join("preamble.sh");
+    let zip_path = temp_dir.join("data.zip");
+    let output_path = temp_dir.join("output.exe");
+
+    let zip_content = b"PK\x03\x04dummyzipcontent";
+    fs::write(&zip_path, zip_content).unwrap();
+
+    let preamble_text = b"#!/bin/bash\nEXPECTED_HASH='%ZIP_HASH%'\n# ... logic ...\n";
+    fs::write(&preamble_path, preamble_text).unwrap();
+
+    create_exe_zip(&preamble_path, &zip_path, &output_path).unwrap();
+
+    assert!(output_path.exists());
+
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        let st = fs::metadata(&output_path).unwrap();
+        assert_ne!(
+            st.permissions().mode() & 0o100,
+            0,
+            "Expected executable permission on output file"
+        );
+    }
+
+    let content = fs::read(&output_path).unwrap();
+    let expected_hash = "65e39989ca91c49484998aa3f0429f6943c029609bfd2f3c18c77bf9ded72c59";
+    let expected_preamble = replace_bytes(preamble_text, PLACEHOLDER, expected_hash.as_bytes());
+
+    assert!(content.starts_with(&expected_preamble));
+    assert!(content.ends_with(zip_content));
+    assert_eq!(content.len(), expected_preamble.len() + zip_content.len());
+
+    let _ = fs::remove_dir_all(&temp_dir);
+}
+
+#[test]
+fn test_create_exe_zip_multiple_placeholders() {
+    let temp_dir = env::temp_dir().join(format!("create_exe_zip_multi_{}", std::process::id()));
+    fs::create_dir_all(&temp_dir).unwrap();
+
+    let preamble_path = temp_dir.join("preamble.sh");
+    let zip_path = temp_dir.join("data.zip");
+    let output_path = temp_dir.join("output.exe");
+
+    let zip_content = b"PK\x03\x04dummyzipcontent";
+    fs::write(&zip_path, zip_content).unwrap();
+
+    let preamble_text = b"# First: %ZIP_HASH%\n# Second: %ZIP_HASH%\n";
+    fs::write(&preamble_path, preamble_text).unwrap();
+
+    create_exe_zip(&preamble_path, &zip_path, &output_path).unwrap();
+
+    let content = fs::read(&output_path).unwrap();
+    let expected_hash = "65e39989ca91c49484998aa3f0429f6943c029609bfd2f3c18c77bf9ded72c59";
+    let expected_preamble = replace_bytes(preamble_text, PLACEHOLDER, expected_hash.as_bytes());
+
+    assert!(content.starts_with(&expected_preamble));
+    assert!(content.ends_with(zip_content));
+
+    let _ = fs::remove_dir_all(&temp_dir);
+}
+
+#[test]
+fn test_create_exe_zip_creates_parent_dir() {
+    let temp_dir = env::temp_dir().join(format!("create_exe_zip_parent_{}", std::process::id()));
+    fs::create_dir_all(&temp_dir).unwrap();
+
+    let preamble_path = temp_dir.join("preamble.sh");
+    let zip_path = temp_dir.join("data.zip");
+    let output_path = temp_dir.join("nested").join("sub").join("output.exe");
+
+    fs::write(&zip_path, b"content").unwrap();
+    fs::write(&preamble_path, b"preamble").unwrap();
+
+    create_exe_zip(&preamble_path, &zip_path, &output_path).unwrap();
+    assert!(output_path.exists());
+
+    let _ = fs::remove_dir_all(&temp_dir);
+}
+
+#[test]
+fn test_create_exe_zip_missing_files() {
+    let temp_dir = env::temp_dir().join(format!("create_exe_zip_err_{}", std::process::id()));
+    fs::create_dir_all(&temp_dir).unwrap();
+
+    let missing_preamble = temp_dir.join("nonexistent_preamble.sh");
+    let zip_path = temp_dir.join("data.zip");
+    let output_path = temp_dir.join("output.exe");
+    fs::write(&zip_path, b"dummy").unwrap();
+
+    assert!(create_exe_zip(&missing_preamble, &zip_path, &output_path).is_err());
+
+    let preamble_path = temp_dir.join("preamble.sh");
+    fs::write(&preamble_path, b"preamble").unwrap();
+    let missing_zip = temp_dir.join("nonexistent_data.zip");
+
+    assert!(create_exe_zip(&preamble_path, &missing_zip, &output_path).is_err());
+
+    let _ = fs::remove_dir_all(&temp_dir);
+}
diff --git a/tests/modules/rules_rust_stub/WORKSPACE b/tests/modules/rules_rust_stub/WORKSPACE
new file mode 100644
index 0000000..53ff64a
--- /dev/null
+++ b/tests/modules/rules_rust_stub/WORKSPACE
@@ -0,0 +1 @@
+workspace(name = "rules_rust")
diff --git a/tests/modules/rules_rust_stub/rust/BUILD.bazel b/tests/modules/rules_rust_stub/rust/BUILD.bazel
new file mode 100644
index 0000000..0ca983a
--- /dev/null
+++ b/tests/modules/rules_rust_stub/rust/BUILD.bazel
@@ -0,0 +1,3 @@
+package(default_visibility = ["//visibility:public"])
+
+exports_files(["defs.bzl"])
diff --git a/tests/modules/rules_rust_stub/rust/defs.bzl b/tests/modules/rules_rust_stub/rust/defs.bzl
new file mode 100644
index 0000000..b9ca0f3
--- /dev/null
+++ b/tests/modules/rules_rust_stub/rust/defs.bzl
@@ -0,0 +1,40 @@
+"""Stub implementation of rules_rust for WORKSPACE mode."""
+
+# buildifier: disable=unused-variable
+def rust_library(name, **_kwargs):
+    """Stub rust_library rule for WORKSPACE mode.
+
+    Args:
+        name: Target name.
+        **_kwargs: Ignored keyword arguments.
+    """
+    native.filegroup(
+        name = name,
+        tags = ["manual"],
+    )
+
+# buildifier: disable=unused-variable
+def rust_binary(name, **_kwargs):
+    """Stub rust_binary rule for WORKSPACE mode.
+
+    Args:
+        name: Target name.
+        **_kwargs: Ignored keyword arguments.
+    """
+    native.filegroup(
+        name = name,
+        tags = ["manual"],
+    )
+
+# buildifier: disable=unused-variable
+def rust_test(name, **_kwargs):
+    """Stub rust_test rule for WORKSPACE mode.
+
+    Args:
+        name: Target name.
+        **_kwargs: Ignored keyword arguments.
+    """
+    native.filegroup(
+        name = name,
+        tags = ["manual"],
+    )