Skip to main content

cargo_zng/res/built_in/
sfx.rs

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    // make target-temp/src
129    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    // write target-temp/Cargo.toml
142    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    // write target-temp/build.rs if needed
147    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        // SAFETY: tools run single threaded
175        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        // SAFETY: tools run single threaded
215        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    // 200MB
309    //
310    // Difficult to find exact limits for `include_bytes!`, Windows maybe has a 2GB limit per object.
311    //
312    // Static data is memory mapped to RAM on demand, so we depend on the system pagination to keep RAM
313    // usage down. The split into 200MB parts here is to hopefully provide extra clear hinting that an
314    // object is no longer needed as the sfx iterates over parts.
315    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                    // len = 0;
346                    break;
347                }
348            }
349            Ok(parts)
350        }
351        Compression::Zstd => {
352            println!("compressing {}", unix_path(file_path));
353
354            // 19 is the maximum non-ultra compression
355            // zstd creates an optimal BufReader
356            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            // There is no pull-based bcj encoder so the parts swap needs to happen inside
376            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            // 22 is the maximum ultra compression (high CPU and RAM usage)
425            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// enable rust-analyzer
466#[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        // conditional regions
533        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}