1use std::{fmt::Write as _, io::Read};
2
3use indexmap::IndexMap;
4use lzma_rust2::filter::bcj::BcjWriter;
5use serde::Deserialize;
6
7use crate::util::unix_path;
8
9use super::*;
10
11const SFX_HELP: &str = r#"
12Compile a self-extracting executable
13
14The request file:
15 source/sfx-package.zr-sfx
16 | [sfx]
17 | # executable to run, required
18 | run = "target/release/run"
19 |
20 | # Embedded icon for the sfx executable (Windows only)
21 | icon = "res/sfx.ico"
22 |
23 | # optional args for 'run'
24 | args = ["--foo"]
25 | # optional extra env for 'run'
26 | env = {
27 | FOO = "bar",
28 | }
29 |
30 | # rustc target triple, default is the host triple
31 | # rustc-target = "x86_64-pc-windows-msvc"
32 | # build a console exe on Windows, default is true (build a GUI exe)
33 | windows-subsystem = false
34 |
35 | # compression to use for 'run', default is "zstd-bcj"
36 | # compress = "none"
37 |
38 | # data the sfx can serve the 'run'
39 | [[data]]
40 | # name must be unique and not include ':', default is "", for single data
41 | name = "payload"
42 | # compress data on build, default is "zstd"
43 | compress = "zstd"
44 | # file to include
45 | file = "./data.tar"
46 |
47 | [sign]
48 | # optional, code sign the sfx exe
49 | tool = "signtool sign /v /f $PFX /tr http://timestamp.sectigo.com /td SHA256 /fd SHA256 $SIGN_TARGET"
50 | # only sign the sfx exe, default 'false' signs the 'run' exe too
51 | # sfx-only = true
52
53Compiles and signs a 'sfx-package.exe' with custom icon on Windows, or a 'sfx-package' on Unix.
54
55Run:
56
57When sfx runs it extracts the 'run' executable to a temp dir and runs it.
58
59The optional 'env' variables override the system env. The SFX_ARGS and SFX_DATA var is always set.
60
61The SFX_ARGS is set to the sfx command line args, '\n' separated. The first arg is the path to the sfx exe.
62
63On build, also searches for "$run.exe" if "$run" is not found and has no extension.
64
65Data:
66
67To read data the 'run' exe must spawn another instance of the sfx with the "SFX_GET_DATA" set
68to the entry name. It will serve the data to stdout. The data may be decompressed on demand.
69
70File Paths:
71
72Paths are relative to the Cargo workspace root, you can also use .zr-rp to select files in the
73resource target dir.
74
75This request file:
76 source/sfx-package.zr-sfxf.zr-rp
77 | [[data]]
78 | file = "${ZR_TARGET_DD}/res.txt"
79
80Compiles a 'sfx-package' that includes the 'res.txt' copied to the target dir by `cargo zng res`.
81
82Compress:
83
84The sfx exe includes a zstd decompressor that is used to extract the 'run' exe.
85
86The decompressor code can be used to read data too. The 'compress' field values are:
87
88- "none" — No compression on build. Data is served as is.
89- "zstd" — Compress on build unless file extension is ".zst". Decompress on demand while reading.
90- "zstd-[filter]" — Transform data to improve compression, unless file extension is ".zst". Reverses
91 transform on demand while reading.
92
93Sfx is optimized for small number of large data entries. Use a container format to
94package many small entries.
95
96Filter:
97
98Currently only BCJ (Branch/Call/Jump) filters are supported, identified by CPU instruction set:
99
100- "zstd-bcj-[set]" where [set] is: "x86", "arm", "arm64", "arm-thumb", "ppc", "sparc", "ia64", "riscv".
101- "zstd-bcj" — Select filter from 'rustc-target' arch, or zstd unfiltered for no matches.
102
103The target file must be a binary (exe or lib) or a container (like tar) with only binary entries. The filters
104are non-destructive but if the wrong filter is selected it will have negative impact on the compression level.
105
106Signing:
107
108Code signing must be applied to both the run exe and sfx exe, to facilitate this you can set the 'sign.tool'.
109
110The sign-tool command will run twice, with $SIGN_TARGET set to "./run.exe" and "package.exe".
111
112In the example above The $PFX var is an example of how to set the the private key.
113Keep the private key file outside the repository and set an env var to it. In CI use
114secure variables.
115
116Icon:
117
118On Windows the sfx executable icon can be set with 'icon' field. Note that this requires the build
119to run on a Windows machine with MSVC Toolkit installed. Cross-compilation from other systems will not work.
120
121"#;
122pub(super) fn sfx() {
123 help(SFX_HELP);
124
125 let request = std::fs::read_to_string(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
126 let request: Request = toml::from_str(&request).unwrap_or_else(|e| fatal!("{e}"));
127
128 let target = path(ZR_TARGET);
130 let tmp = target.with_file_name(format!("{}-temp", target.file_name().unwrap().display()));
131 fs::create_dir(&tmp).unwrap_or_else(|e| fatal!("cannot create {}, {e}", tmp.display()));
132 let src = tmp.join("src");
133 fs::create_dir(&src).unwrap_or_else(|e| fatal!("cannot create src, {e}"));
134
135 let mut icon = request.sfx.icon;
136 if icon.is_some() && (!cfg!(windows) || !request.sfx.rustc_target.contains("windows")) {
137 warn!("ignoring icon, can only build for Windows on Windows");
138 icon = None;
139 }
140
141 let cargo = sfx_cargo(icon.is_some());
143 let manifest = tmp.join("Cargo.toml");
144 fs::write(&manifest, cargo.as_bytes()).unwrap_or_else(|e| fatal!("cannot create Cargo.toml, {e}"));
145
146 if let Some(build_rs) = sfx_build(icon) {
148 let build = tmp.join("build.rs");
149 fs::write(&build, build_rs.as_bytes()).unwrap_or_else(|e| fatal!("cannot create build.rs, {e}"));
150 }
151
152 let mut run = request.sfx.run;
153 if !run.exists() {
154 if run.extension().is_none() && run.set_extension("exe") {
155 if !run.exists() {
156 run.set_extension("");
157 fatal!(
158 "cannot find 'run' executable\n {}\n also tried {}.exe",
159 unix_path(&run),
160 run.file_name().unwrap().display()
161 );
162 }
163 } else {
164 fatal!("cannot find 'run' executable\n {}", unix_path(&run));
165 }
166 }
167
168 if let Some(tool) = &request.sign.tool
169 && !request.sign.only_sfx
170 {
171 let sr = tmp.join("signed-run");
172 fs::copy(&run, &sr).unwrap_or_else(|e| fatal!("cannot copy {}, {e}", run.display()));
173 run = sr.clone();
174 unsafe { std::env::set_var("SIGN_TARGET", &*unix_path(&sr)) };
176 super::sh_run(tool.clone(), false, None).unwrap_or_else(|e| fatal!("cannot sign run, {e}"));
177 }
178
179 let mut data = vec![];
180 let run_compression = parse_compress(&request.sfx.rustc_target, &request.sfx.compress);
181 let run_parts = prepare_data(&tmp, 0, run_compression, &run).unwrap_or_else(|e| fatal!("cannot compress run, {e}"));
182 data.push((":run", run_compression, run_parts));
183 for (id, d) in request.data.iter().enumerate() {
184 if d.name.contains(':') {
185 fatal!("data name cannot contain ':'");
186 }
187 let compression = parse_compress(&request.sfx.rustc_target, &d.compress);
188 let file = d.file.as_path();
189
190 let parts = prepare_data(&tmp, id + 1, compression, file)
191 .unwrap_or_else(|e| fatal!("cannot process file, {e}\n file: {}", unix_path(file)));
192 data.push((d.name.as_str(), compression, parts));
193 }
194 let sfx_main = sfx_main(request.sfx.windows_subsystem, &request.sfx.args, &request.sfx.env, &data);
195 fs::write(src.join("main.rs"), sfx_main.as_bytes()).unwrap_or_else(|e| fatal!("cannot create main.rs, {e}"));
196
197 let r = std::process::Command::new("cargo")
198 .arg("build")
199 .arg("--release")
200 .arg("--target")
201 .arg(&request.sfx.rustc_target)
202 .arg("--manifest-path")
203 .arg(manifest)
204 .status()
205 .unwrap_or_else(|e| fatal!("{e}"));
206 assert!(r.success());
207
208 let output = tmp.join(format!(
209 "target/{}/release/zng-res-sfx{}",
210 request.sfx.rustc_target,
211 std::env::consts::EXE_SUFFIX
212 ));
213 if let Some(tool) = request.sign.tool {
214 unsafe { std::env::set_var("SIGN_TARGET", &*unix_path(&output)) };
216 super::sh_run(tool, false, None).unwrap_or_else(|e| fatal!("cannot sign sfx, {e}"));
217 }
218
219 let mut target = target;
220 target.set_extension(output.extension().unwrap_or_default());
221 fs::rename(output, target).unwrap_or_else(|e| fatal!("cannot finalize build, {e}"));
222
223 fs::remove_dir_all(tmp).unwrap_or_else(|e| fatal!("cannot cleanup, {e}"));
224}
225
226fn parse_compress(rustc_target: &str, compress: &str) -> Compression {
227 if let Some(filter) = compress.strip_prefix("zstd-") {
228 match filter {
229 "bcj-x86" => Compression::ZstdBcj(BcjFilter::X86),
230 "bcj-arm" => Compression::ZstdBcj(BcjFilter::Arm),
231 "bcj-arm64" => Compression::ZstdBcj(BcjFilter::Arm64),
232 "bcj-arm-thumb" => Compression::ZstdBcj(BcjFilter::ArmThumb),
233 "bcj-arm-ppc" => Compression::ZstdBcj(BcjFilter::Ppc),
234 "bcj-arm-sparc" => Compression::ZstdBcj(BcjFilter::Sparc),
235 "bcj-arm-ia64" => Compression::ZstdBcj(BcjFilter::Ia64),
236 "bcj-arm-riscv" => Compression::ZstdBcj(BcjFilter::Riscv),
237 "bcj" => bcj_from_triple(rustc_target).map(Compression::ZstdBcj).unwrap_or(Compression::Zstd),
238 unk => fatal!("unknown filter {unk:?}"),
239 }
240 } else {
241 match compress {
242 "none" => Compression::None,
243 "zstd" => Compression::Zstd,
244 unk => fatal!("unknown compression {unk:?}"),
245 }
246 }
247}
248
249#[derive(Deserialize)]
250struct Request {
251 sfx: Sfx,
252 #[serde(default)]
253 data: Vec<Data>,
254 #[serde(default = "default_sign")]
255 sign: Sign,
256}
257fn default_sign() -> Sign {
258 Sign {
259 tool: None,
260 only_sfx: false,
261 }
262}
263
264#[derive(Deserialize)]
265struct Sfx {
266 run: PathBuf,
267 icon: Option<PathBuf>,
268 args: Vec<String>,
269 env: indexmap::IndexMap<String, String>,
270 #[serde(rename = "rustc-target")]
271 #[serde(default = "rustc_host_triple")]
272 rustc_target: String,
273 #[serde(rename = "windows-subsystem")]
274 #[serde(default = "default_windows_subsystem")]
275 windows_subsystem: bool,
276 #[serde(default = "default_run_compress")]
277 compress: String,
278}
279fn default_windows_subsystem() -> bool {
280 true
281}
282fn default_run_compress() -> String {
283 "zstd-bcj".to_owned()
284}
285
286#[derive(Deserialize)]
287struct Data {
288 name: String,
289 #[serde(default = "default_compress")]
290 compress: String,
291 file: PathBuf,
292}
293fn default_compress() -> String {
294 "zstd".to_owned()
295}
296
297#[derive(Deserialize)]
298struct Sign {
299 tool: Option<String>,
300 #[serde(default)]
301 only_sfx: bool,
302}
303
304fn prepare_data(tmp: &Path, data_id: usize, mut compression: Compression, file: &Path) -> io::Result<Vec<PathBuf>> {
305 let file_path = file;
306 let file = fs::File::open(file)?;
307
308 const PART_MAX: u64 = 200u64 * 2u64.pow(20);
316
317 if let Some(ext) = file_path.extension()
318 && ext.eq_ignore_ascii_case("zst")
319 {
320 compression = Compression::None;
321 }
322
323 match compression {
324 Compression::None => {
325 println!("preparing {}", unix_path(file_path));
326
327 let mut len = file.metadata()?.len();
328 if len <= PART_MAX {
329 return Ok(vec![file_path.to_owned()]);
330 }
331
332 let mut file = io::BufReader::new(file);
333 let mut parts = vec![];
334 loop {
335 let part_path = tmp.join(format!("d{data_id}-p{}", parts.len()));
336 let mut part = fs::File::create_new(&part_path)?;
337 parts.push(part_path);
338 if PART_MAX > len {
339 part.set_len(PART_MAX)?;
340 io::copy(&mut (&mut file).take(PART_MAX), &mut part)?;
341 len -= PART_MAX;
342 } else {
343 part.set_len(len)?;
344 io::copy(&mut file, &mut part)?;
345 break;
347 }
348 }
349 Ok(parts)
350 }
351 Compression::Zstd => {
352 println!("compressing {}", unix_path(file_path));
353
354 let mut file = zstd::stream::read::Encoder::new(file, 19)?;
357
358 let mut parts = vec![];
359 loop {
360 let part_path = tmp.join(format!("d{data_id}-p{}", parts.len()));
361 let mut part = fs::File::create_new(&part_path)?;
362 parts.push(part_path);
363
364 let part_len = io::copy(&mut (&mut file).take(PART_MAX), &mut part)?;
365 if part_len < PART_MAX {
366 break;
367 }
368 }
369
370 Ok(parts)
371 }
372 Compression::ZstdBcj(bcj) => {
373 println!("compressing {}", unix_path(file_path));
374
375 struct PartsWriter<'a> {
377 tmp: &'a Path,
378 data_id: usize,
379 parts: &'a mut Vec<PathBuf>,
380 part: Option<fs::File>,
381 left: usize,
382 }
383 impl<'a> PartsWriter<'a> {
384 fn next_part(&mut self) -> io::Result<()> {
385 let part_path = self.tmp.join(format!("d{}-p{}", self.data_id, self.parts.len()));
386 self.part = Some(fs::File::create_new(&part_path)?);
387 self.parts.push(part_path);
388 self.left = PART_MAX as usize;
389 Ok(())
390 }
391 }
392 impl<'a> io::Write for PartsWriter<'a> {
393 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
394 if buf.is_empty() {
395 return Ok(0);
396 }
397 if self.left == 0 {
398 self.next_part()?;
399 }
400 if let Some(f) = &mut self.part {
401 let max_len = self.left.min(buf.len());
402 let buf = &buf[..max_len];
403 let written = f.write(buf)?;
404 self.left -= written;
405 Ok(written)
406 } else {
407 Ok(0)
408 }
409 }
410
411 fn flush(&mut self) -> io::Result<()> {
412 if let Some(f) = &mut self.part { f.flush() } else { Ok(()) }
413 }
414 }
415
416 let mut parts = vec![];
417 let out = PartsWriter {
418 tmp,
419 data_id,
420 parts: &mut parts,
421 part: None,
422 left: 0,
423 };
424 let mut encoder = zstd::stream::write::Encoder::new(out, 22)?;
426 let out = &mut encoder;
427 let mut bcj_filter = match bcj {
428 BcjFilter::X86 => BcjWriter::new_x86(out, 0),
429 BcjFilter::Arm => BcjWriter::new_arm(out, 0),
430 BcjFilter::Arm64 => BcjWriter::new_arm64(out, 0),
431 BcjFilter::ArmThumb => BcjWriter::new_arm_thumb(out, 0),
432 BcjFilter::Ppc => BcjWriter::new_ppc(out, 0),
433 BcjFilter::Sparc => BcjWriter::new_sparc(out, 0),
434 BcjFilter::Ia64 => BcjWriter::new_ia64(out, 0),
435 BcjFilter::Riscv => BcjWriter::new_riscv(out, 0),
436 };
437
438 let mut file = file;
439 io::copy(&mut file, &mut bcj_filter)?;
440 bcj_filter.finish()?;
441 encoder.finish()?;
442
443 Ok(parts)
444 }
445 }
446}
447
448fn bcj_from_triple(target: &str) -> Option<BcjFilter> {
449 let arch = target.split('-').next()?;
450
451 match arch {
452 "i386" | "i486" | "i586" | "i686" | "x86_64" => Some(BcjFilter::X86),
453 "arm" | "armv4t" | "armv5te" | "armv7" | "armv7a" | "armv7r" | "armv7s" | "armebv7r" => Some(BcjFilter::Arm),
454 "thumbv6m" | "thumbv7m" | "thumbv7em" | "thumbv7neon" | "thumbv8m.base" | "thumbv8m.main" => Some(BcjFilter::ArmThumb),
455 "aarch64" | "aarch64_be" => Some(BcjFilter::Arm64),
456 "powerpc" | "powerpc64" | "powerpc64le" => Some(BcjFilter::Ppc),
457 "sparc" | "sparcv9" | "sparc64" => Some(BcjFilter::Sparc),
458 "ia64" => Some(BcjFilter::Ia64),
459 "riscv32" | "riscv64" => Some(BcjFilter::Riscv),
460
461 _ => None,
462 }
463}
464
465#[path = "sfx_res/sfx_main.rs"]
467#[allow(unused)]
468mod sfx_main;
469use sfx_main::{BcjFilter, Compression};
470
471fn sfx_main(
472 windows_subsystem: bool,
473 args: &[String],
474 env: &IndexMap<String, String>,
475 data: &[(&str, Compression, Vec<PathBuf>)],
476) -> String {
477 let main = include_str!("sfx_res/sfx_main.rs");
478
479 const DATA: &str = "static DATA: &[(&str, Compression, &[&[u8]])] = &[";
480 let mut out_data = String::new();
481 if windows_subsystem {
482 out_data.push_str("#![windows_subsystem = \"windows\"]\n");
483 }
484 out_data.push_str(DATA);
485 out_data.push('\n');
486 for (name, compression, parts) in data {
487 let (compression, filter) = match compression {
488 Compression::None => ("None", ""),
489 Compression::Zstd => ("Zstd", ""),
490 Compression::ZstdBcj(f) => {
491 let f = match f {
492 BcjFilter::X86 => "(BcjFilter::X86)",
493 BcjFilter::Arm => "(BcjFilter::Arm)",
494 BcjFilter::Arm64 => "(BcjFilter::Arm64)",
495 BcjFilter::ArmThumb => "(BcjFilter::ArmThumb)",
496 BcjFilter::Ppc => "(BcjFilter::Ppc)",
497 BcjFilter::Sparc => "(BcjFilter::Sparc)",
498 BcjFilter::Ia64 => "(BcjFilter::Ia64)",
499 BcjFilter::Riscv => "(BcjFilter::Riscv)",
500 };
501 ("ZstdBcj", f)
502 }
503 };
504 write!(&mut out_data, " ({name:?}, Compression::{compression}{filter}, &[").unwrap();
505 for part in parts {
506 write!(&mut out_data, "include_bytes!(\"{}\"), ", unix_path(part)).unwrap();
507 }
508 writeln!(&mut out_data, "]),").unwrap();
509 }
510 out_data.push_str("\n];");
511
512 const ARGS: &str = "static ARGS: &[&str] = &[";
513 let mut out_args = ARGS.to_owned();
514 for arg in args {
515 write!(&mut out_args, "{arg:?}, ").unwrap();
516 }
517 out_args.push_str("];");
518
519 const ENV: &str = "static ENV: &[(&str, &str)] = &[";
520 let mut out_env = ENV.to_owned();
521 out_env.push('\n');
522 for (key, value) in env {
523 writeln!(&mut out_env, " ({key:?}, {value:?}),").unwrap();
524 }
525 out_env.push_str("\n];");
526
527 let mut replaces = vec![(DATA, out_data), (ARGS, out_args), (ENV, out_env)];
528
529 let mut out_main = String::new();
530 let mut region = "";
531 for line in main.lines() {
532 if let Some(r) = line.trim_start().strip_prefix("// </")
534 && let Some(r) = r.strip_suffix('>')
535 {
536 assert_eq!(region, r);
537 region = "";
538 continue;
539 }
540 if let Some(r) = line.trim_start().strip_prefix("// <")
541 && let Some(r) = r.strip_suffix('>')
542 {
543 region = r;
544 continue;
545 }
546 let keep_line = match region {
547 "windows-subsystem" => windows_subsystem,
548 "" => true,
549 unk => panic!("unknown region {unk:?}"),
550 };
551 if !keep_line {
552 continue;
553 }
554
555 if let Some(i) = replaces.iter().position(|(k, _)| line.starts_with(k)) {
556 let (_, value) = replaces.swap_remove(i);
557 out_main.push_str(&value);
558 } else {
559 out_main.push_str(line);
560 }
561
562 if replaces.is_empty() {
563 let i = line.as_ptr() as usize - main.as_ptr() as usize + line.len();
564 out_main.push_str(&main[i..]);
565 break;
566 } else {
567 out_main.push('\n');
568 }
569 }
570
571 out_main
572}
573
574fn sfx_build(icon: Option<PathBuf>) -> Option<String> {
575 let ico = icon?;
576 let build = include_str!("sfx_res/build_icon.rs");
577 let build = build.replace("{icon-path}", &ico.display().to_string());
578 Some(build)
579}
580
581fn sfx_cargo(has_icon: bool) -> String {
582 let cargo = include_str!("sfx_res/sfx_cargo.toml");
583 if has_icon {
584 cargo.to_owned()
585 } else {
586 let (before, after) = cargo.split_once("# <has-icon>\n").unwrap();
587 let (_, after) = after.split_once("\n# </has-icon>\n").unwrap();
588 format!("{before}{after}")
589 }
590}
591
592fn rustc_host_triple() -> String {
593 let o = std::process::Command::new("rustc")
594 .arg("--version")
595 .arg("--verbose")
596 .output()
597 .unwrap_or_else(|e| fatal!("cannot find host triple, {e}"));
598
599 if !o.status.success() {
600 fatal!("cannot find host triple, exit code: {:?}", o.status.code().unwrap_or(0))
601 }
602
603 let stdout = str::from_utf8(&o.stdout).unwrap_or_else(|e| fatal!("cannot find host triple, {e}"));
604
605 for line in stdout.lines() {
606 if let Some(triple) = line.strip_prefix("host: ") {
607 return triple.to_owned();
608 }
609 }
610
611 fatal!("cannot find host triple")
612}