Skip to content

Commit 7bf4e6f

Browse files
authored
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.
1 parent 1ac6c5c commit 7bf4e6f

13 files changed

Lines changed: 374 additions & 0 deletions

File tree

‎.bazelrc.deleted_packages‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,4 @@ common --deleted_packages=tests/modules/other/simple_v1
5454
common --deleted_packages=tests/modules/other/simple_v2
5555
common --deleted_packages=tests/modules/other/with_external_data
5656
common --deleted_packages=tests/modules/rules_pyrefly_stub/pyrefly
57+
common --deleted_packages=tests/modules/rules_rust_stub/rust

‎BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ filegroup(
7979
"internal_dev_setup.bzl",
8080
"version.bzl",
8181
"//command_line_option:distribution",
82+
"//crates:distribution",
8283
"//python:distribution",
8384
"//tools:distribution",
8485
],

‎MODULE.bazel‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ bazel_dep(name = "rules_shell", version = "0.3.0", dev_dependency = True)
9898
bazel_dep(name = "rules_multirun", version = "0.9.0", dev_dependency = True)
9999
bazel_dep(name = "bazel_ci_rules", version = "1.0.0", dev_dependency = True)
100100
bazel_dep(name = "rules_pkg", version = "1.2.0", dev_dependency = True)
101+
bazel_dep(name = "rules_rust", version = "0.73.0", dev_dependency = True)
102+
103+
rust_crates = use_extension(
104+
"@rules_rust//crate_universe:extensions.bzl",
105+
"crate",
106+
dev_dependency = True,
107+
)
108+
rust_crates.spec(
109+
package = "sha2",
110+
version = "0.10.8",
111+
)
112+
rust_crates.from_specs()
113+
use_repo(rust_crates, "crates")
114+
101115
bazel_dep(name = "other", version = "0", dev_dependency = True)
102116
bazel_dep(name = "another_module", version = "0", dev_dependency = True)
103117

‎crates/BUILD.bazel‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package(default_visibility = ["//:__subpackages__"])
2+
3+
licenses(["notice"])
4+
5+
filegroup(
6+
name = "distribution",
7+
srcs = glob(["**"]) + [
8+
"//crates/exe_zip_maker:distribution",
9+
],
10+
)

‎crates/exe_zip_maker/BUILD.bazel‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library")
2+
3+
package(default_visibility = ["//:__subpackages__"])
4+
5+
licenses(["notice"])
6+
7+
rust_library(
8+
name = "exe_zip_maker_lib",
9+
srcs = ["src/lib.rs"],
10+
edition = "2021",
11+
deps = ["@crates//:sha2"],
12+
)
13+
14+
rust_binary(
15+
name = "exe_zip_maker",
16+
srcs = ["src/main.rs"],
17+
edition = "2021",
18+
visibility = ["//visibility:public"],
19+
deps = [":exe_zip_maker_lib"],
20+
)
21+
22+
filegroup(
23+
name = "distribution",
24+
srcs = glob(["**"]),
25+
)

‎crates/exe_zip_maker/src/lib.rs‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! Library supporting creating self-executable zip files.
2+
3+
use std::fs::{self, File};
4+
use std::io::{self, BufReader, BufWriter, Read, Write};
5+
use std::path::Path;
6+
7+
use sha2::{Digest, Sha256};
8+
9+
pub const BLOCK_SIZE: usize = 256 * 1024;
10+
pub const PLACEHOLDER: &[u8] = b"%ZIP_HASH%";
11+
12+
/// Replaces all occurrences of `from` with `to` in `src`.
13+
pub fn replace_bytes(src: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
14+
if from.is_empty() {
15+
return src.to_vec();
16+
}
17+
let mut result = Vec::new();
18+
let mut i = 0;
19+
while i < src.len() {
20+
if src[i..].starts_with(from) {
21+
result.extend_from_slice(to);
22+
i += from.len();
23+
} else {
24+
result.push(src[i]);
25+
i += 1;
26+
}
27+
}
28+
result
29+
}
30+
31+
/// Computes the SHA256 hex digest of the file at `path`.
32+
pub fn compute_file_sha256_hex(path: &Path) -> io::Result<String> {
33+
let mut file = File::open(path)?;
34+
let mut hasher = Sha256::new();
35+
let mut buffer = [0u8; BLOCK_SIZE];
36+
loop {
37+
let n = file.read(&mut buffer)?;
38+
if n == 0 {
39+
break;
40+
}
41+
hasher.update(&buffer[..n]);
42+
}
43+
let digest = hasher.finalize();
44+
Ok(format!("{:x}", digest))
45+
}
46+
47+
/// Creates a self-executable zip archive by prepending a preamble to a zip archive
48+
/// and substituting `%ZIP_HASH%` with the SHA-256 hash of the zip archive.
49+
pub fn create_exe_zip(preamble_path: &Path, zip_path: &Path, output_path: &Path) -> io::Result<()> {
50+
if let Some(parent) = output_path.parent() {
51+
if !parent.as_os_str().is_empty() {
52+
fs::create_dir_all(parent)?;
53+
}
54+
}
55+
56+
let zip_hash = compute_file_sha256_hex(zip_path)?;
57+
58+
let preamble_content = fs::read(preamble_path)?;
59+
let modified_preamble = replace_bytes(&preamble_content, PLACEHOLDER, zip_hash.as_bytes());
60+
61+
let mut out_file = BufWriter::with_capacity(BLOCK_SIZE, File::create(output_path)?);
62+
out_file.write_all(&modified_preamble)?;
63+
64+
let zip_file = File::open(zip_path)?;
65+
let mut zip_reader = BufReader::with_capacity(BLOCK_SIZE, zip_file);
66+
io::copy(&mut zip_reader, &mut out_file)?;
67+
out_file.flush()?;
68+
69+
#[cfg(unix)]
70+
{
71+
use std::os::unix::fs::PermissionsExt;
72+
let metadata = fs::metadata(output_path)?;
73+
let mut perms = metadata.permissions();
74+
perms.set_mode(perms.mode() | 0o111);
75+
fs::set_permissions(output_path, perms)?;
76+
}
77+
78+
Ok(())
79+
}

‎crates/exe_zip_maker/src/main.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use std::env;
2+
use std::path::Path;
3+
use std::process;
4+
5+
fn main() {
6+
let args: Vec<_> = env::args_os().collect();
7+
if args.len() != 4 {
8+
let prog_name = args
9+
.first()
10+
.map(|s| s.to_string_lossy().into_owned())
11+
.unwrap_or_else(|| "exe_zip_maker".to_string());
12+
eprintln!("Usage: {} <preamble> <zip> <output>", prog_name);
13+
process::exit(1);
14+
}
15+
16+
let preamble_path = Path::new(&args[1]);
17+
let zip_path = Path::new(&args[2]);
18+
let output_path = Path::new(&args[3]);
19+
20+
if let Err(e) = exe_zip_maker_lib::create_exe_zip(preamble_path, zip_path, output_path) {
21+
eprintln!("exe_zip_maker: error: {}", e);
22+
process::exit(1);
23+
}
24+
}

‎internal_dev_deps.bzl‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ def rules_python_internal_deps():
114114
path = "tests/modules/rules_pyrefly_stub",
115115
)
116116

117+
# Stub repository for rules_rust in WORKSPACE mode so that load()
118+
# statements for @rules_rust resolve without requiring full rules_rust.
119+
local_repository(
120+
name = "rules_rust",
121+
path = "tests/modules/rules_rust_stub",
122+
)
123+
117124
# The below two deps are required for the integration test with bazel
118125
# gazelle. Maybe the test should be moved to the `gazelle` workspace?
119126
http_archive(

‎tests/exe_zip_maker/BUILD.bazel‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
load("@rules_rust//rust:defs.bzl", "rust_test")
2+
3+
package(default_visibility = ["//:__subpackages__"])
4+
5+
licenses(["notice"])
6+
7+
rust_test(
8+
name = "exe_zip_maker_test",
9+
size = "small",
10+
srcs = ["exe_zip_maker_test.rs"],
11+
deps = [
12+
"//crates/exe_zip_maker:exe_zip_maker_lib",
13+
],
14+
)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
use std::env;
2+
use std::fs;
3+
4+
use exe_zip_maker_lib::{
5+
compute_file_sha256_hex, create_exe_zip, replace_bytes, PLACEHOLDER,
6+
};
7+
8+
#[test]
9+
fn test_replace_bytes_none() {
10+
let src = b"hello world";
11+
assert_eq!(replace_bytes(src, b"foo", b"bar"), b"hello world");
12+
}
13+
14+
#[test]
15+
fn test_replace_bytes_single() {
16+
let src = b"EXPECTED_HASH='%ZIP_HASH%'";
17+
let replaced = replace_bytes(src, PLACEHOLDER, b"12345678");
18+
assert_eq!(replaced, b"EXPECTED_HASH='12345678'");
19+
}
20+
21+
#[test]
22+
fn test_replace_bytes_multiple() {
23+
let src = b"%ZIP_HASH% and %ZIP_HASH%";
24+
let replaced = replace_bytes(src, PLACEHOLDER, b"abc");
25+
assert_eq!(replaced, b"abc and abc");
26+
}
27+
28+
#[test]
29+
fn test_replace_bytes_empty_from() {
30+
let src = b"unchanged";
31+
assert_eq!(replace_bytes(src, b"", b"abc"), b"unchanged");
32+
}
33+
34+
#[test]
35+
fn test_compute_file_sha256_hex() {
36+
let temp_dir = env::temp_dir().join(format!("sha256_test_{}", std::process::id()));
37+
fs::create_dir_all(&temp_dir).unwrap();
38+
let file_path = temp_dir.join("sample.txt");
39+
40+
fs::write(&file_path, b"hello world\n").unwrap();
41+
let hash = compute_file_sha256_hex(&file_path).unwrap();
42+
assert_eq!(
43+
hash,
44+
"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
45+
);
46+
47+
let _ = fs::remove_dir_all(&temp_dir);
48+
}
49+
50+
#[test]
51+
fn test_create_exe_zip_successful() {
52+
let temp_dir = env::temp_dir().join(format!("create_exe_zip_test_{}", std::process::id()));
53+
fs::create_dir_all(&temp_dir).unwrap();
54+
55+
let preamble_path = temp_dir.join("preamble.sh");
56+
let zip_path = temp_dir.join("data.zip");
57+
let output_path = temp_dir.join("output.exe");
58+
59+
let zip_content = b"PK\x03\x04dummyzipcontent";
60+
fs::write(&zip_path, zip_content).unwrap();
61+
62+
let preamble_text = b"#!/bin/bash\nEXPECTED_HASH='%ZIP_HASH%'\n# ... logic ...\n";
63+
fs::write(&preamble_path, preamble_text).unwrap();
64+
65+
create_exe_zip(&preamble_path, &zip_path, &output_path).unwrap();
66+
67+
assert!(output_path.exists());
68+
69+
#[cfg(unix)]
70+
{
71+
use std::os::unix::fs::PermissionsExt;
72+
let st = fs::metadata(&output_path).unwrap();
73+
assert_ne!(
74+
st.permissions().mode() & 0o100,
75+
0,
76+
"Expected executable permission on output file"
77+
);
78+
}
79+
80+
let content = fs::read(&output_path).unwrap();
81+
let expected_hash = "65e39989ca91c49484998aa3f0429f6943c029609bfd2f3c18c77bf9ded72c59";
82+
let expected_preamble = replace_bytes(preamble_text, PLACEHOLDER, expected_hash.as_bytes());
83+
84+
assert!(content.starts_with(&expected_preamble));
85+
assert!(content.ends_with(zip_content));
86+
assert_eq!(content.len(), expected_preamble.len() + zip_content.len());
87+
88+
let _ = fs::remove_dir_all(&temp_dir);
89+
}
90+
91+
#[test]
92+
fn test_create_exe_zip_multiple_placeholders() {
93+
let temp_dir = env::temp_dir().join(format!("create_exe_zip_multi_{}", std::process::id()));
94+
fs::create_dir_all(&temp_dir).unwrap();
95+
96+
let preamble_path = temp_dir.join("preamble.sh");
97+
let zip_path = temp_dir.join("data.zip");
98+
let output_path = temp_dir.join("output.exe");
99+
100+
let zip_content = b"PK\x03\x04dummyzipcontent";
101+
fs::write(&zip_path, zip_content).unwrap();
102+
103+
let preamble_text = b"# First: %ZIP_HASH%\n# Second: %ZIP_HASH%\n";
104+
fs::write(&preamble_path, preamble_text).unwrap();
105+
106+
create_exe_zip(&preamble_path, &zip_path, &output_path).unwrap();
107+
108+
let content = fs::read(&output_path).unwrap();
109+
let expected_hash = "65e39989ca91c49484998aa3f0429f6943c029609bfd2f3c18c77bf9ded72c59";
110+
let expected_preamble = replace_bytes(preamble_text, PLACEHOLDER, expected_hash.as_bytes());
111+
112+
assert!(content.starts_with(&expected_preamble));
113+
assert!(content.ends_with(zip_content));
114+
115+
let _ = fs::remove_dir_all(&temp_dir);
116+
}
117+
118+
#[test]
119+
fn test_create_exe_zip_creates_parent_dir() {
120+
let temp_dir = env::temp_dir().join(format!("create_exe_zip_parent_{}", std::process::id()));
121+
fs::create_dir_all(&temp_dir).unwrap();
122+
123+
let preamble_path = temp_dir.join("preamble.sh");
124+
let zip_path = temp_dir.join("data.zip");
125+
let output_path = temp_dir.join("nested").join("sub").join("output.exe");
126+
127+
fs::write(&zip_path, b"content").unwrap();
128+
fs::write(&preamble_path, b"preamble").unwrap();
129+
130+
create_exe_zip(&preamble_path, &zip_path, &output_path).unwrap();
131+
assert!(output_path.exists());
132+
133+
let _ = fs::remove_dir_all(&temp_dir);
134+
}
135+
136+
#[test]
137+
fn test_create_exe_zip_missing_files() {
138+
let temp_dir = env::temp_dir().join(format!("create_exe_zip_err_{}", std::process::id()));
139+
fs::create_dir_all(&temp_dir).unwrap();
140+
141+
let missing_preamble = temp_dir.join("nonexistent_preamble.sh");
142+
let zip_path = temp_dir.join("data.zip");
143+
let output_path = temp_dir.join("output.exe");
144+
fs::write(&zip_path, b"dummy").unwrap();
145+
146+
assert!(create_exe_zip(&missing_preamble, &zip_path, &output_path).is_err());
147+
148+
let preamble_path = temp_dir.join("preamble.sh");
149+
fs::write(&preamble_path, b"preamble").unwrap();
150+
let missing_zip = temp_dir.join("nonexistent_data.zip");
151+
152+
assert!(create_exe_zip(&preamble_path, &missing_zip, &output_path).is_err());
153+
154+
let _ = fs::remove_dir_all(&temp_dir);
155+
}

0 commit comments

Comments
 (0)