Skip to main content

lux_lib/operations/
dist_bin.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Stdio,
5};
6
7use bon::Builder;
8use itertools::Itertools;
9use thiserror::Error;
10use walkdir::WalkDir;
11
12use crate::{
13    build::utils::c_dylib_extension,
14    config::Config,
15    lua_installation::{LuaInstallation, LuaInstallationError},
16    lua_rockspec::LuaModule,
17    operations::{InstallProject, InstallProjectError},
18    project::{project_toml::LocalProjectTomlValidationError, Project},
19    rockspec::Rockspec,
20    tree::{InstallTree, TreeError},
21};
22
23/// Compile a Lux project and all its dependencies into a single
24/// static binary that does not require a Lua installation.
25///
26/// Based on [luastatic](https://github.com/ers35/luastatic)
27#[derive(Builder)]
28#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
29pub struct DistProjectBin<'a, T>
30where
31    T: InstallTree,
32{
33    /// The project to compile.
34    project: &'a Project,
35
36    config: &'a Config,
37
38    /// Tree in which to install the project before compiling.
39    tree: &'a T,
40
41    /// Destination path for the compiled binary.
42    /// Defaults to `<cwd>/<package>[.exe]`.
43    output: Option<PathBuf>,
44}
45
46use miette::Diagnostic;
47#[derive(Error, Debug, Diagnostic)]
48#[non_exhaustive]
49pub enum DistProjectBinError {
50    #[error("error installing project:\n{0}")]
51    #[diagnostic(forward(0))]
52    InstallProject(#[from] InstallProjectError),
53    #[error(transparent)]
54    #[diagnostic(transparent)]
55    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
56    #[error(transparent)]
57    #[diagnostic(transparent)]
58    Tree(#[from] TreeError),
59    #[cfg(not(target_os = "linux"))]
60    #[error(
61        r#"Lua binary libraries are only linkable on Linux.
62Cannot link the following binaries:
63{0}"#
64    )]
65    CannotLinkBinaryLibs(String),
66    #[error(transparent)]
67    #[diagnostic(transparent)]
68    LuaInstallation(#[from] LuaInstallationError),
69    #[error(transparent)]
70    CC(#[from] cc::Error),
71    #[error(transparent)]
72    Io(#[from] io::Error),
73    #[error("C compilation failed (exit {status}):\nstdout: {stdout}\nstderr: {stderr}")]
74    CompilationFailed {
75        status: std::process::ExitStatus,
76        stdout: String,
77        stderr: String,
78    },
79}
80
81impl<T, State> DistProjectBinBuilder<'_, T, State>
82where
83    T: InstallTree + Sync + Send + Clone + 'static,
84    State: dist_project_bin_builder::State + dist_project_bin_builder::IsComplete,
85{
86    pub async fn compile(self) -> Result<PathBuf, DistProjectBinError> {
87        do_dist_project_bin(self._build()).await
88    }
89}
90
91/// Files collected from an installed tree for binary compilation.
92#[derive(Debug)]
93struct InstalledFiles {
94    /// Lua source files (.lua) to embed.
95    src: Vec<(LuaModule, PathBuf)>,
96    /// Native Lua modules (.so/.dll/.dylib) to link.
97    lib: Vec<PathBuf>,
98}
99
100#[tracing::instrument(name = "Distributing binary", skip_all)]
101
102async fn do_dist_project_bin<T>(args: DistProjectBin<'_, T>) -> Result<PathBuf, DistProjectBinError>
103where
104    T: InstallTree + Sync + Send + Clone + 'static,
105{
106    let package = InstallProject::new()
107        .project(args.project)
108        .config(args.config)
109        .tree(args.tree)
110        .build()
111        .await?;
112
113    let files = collect_installed_files(args.tree)?;
114
115    let project_toml = args.project.toml().into_local()?;
116    let pkg_name = project_toml.package().to_string();
117
118    let entrypoint_module = project_toml
119        .run()
120        .and_then(|r| r.current_platform().args.as_ref())
121        .map(|args| args.first().as_str())
122        .map(entrypoint_stem)
123        .unwrap_or_else(|| pkg_name.clone());
124
125    let layout = args.tree.installed_rock_layout(&package)?;
126
127    let lua = LuaInstallation::new_from_config(args.config).await?;
128
129    let output = args.output.unwrap_or_else(|| {
130        let mut p = PathBuf::from(&pkg_name);
131        if cfg!(target_env = "msvc") {
132            p.set_extension("exe");
133        }
134        p
135    });
136
137    let lib_root = layout.lib.clone();
138    let c_src = generate_c_source(&entrypoint_module, &files, &lib_root).await?;
139
140    let work_dir = tempfile::tempdir()?;
141    let c_path = work_dir.path().join(format!("{pkg_name}.static.c"));
142    tokio::fs::write(&c_path, &c_src).await?;
143
144    compile_binary(&c_path, &output, &lua, &files.lib, &work_dir, args.config).await?;
145
146    Ok(output)
147}
148
149#[allow(clippy::result_large_err)]
150fn collect_installed_files(tree: &impl InstallTree) -> Result<InstalledFiles, DistProjectBinError> {
151    let mut lua_sources = Vec::new();
152    let mut native_modules = Vec::new();
153    let c_dylib_ext = c_dylib_extension();
154
155    for package in tree.list()?.values().flatten() {
156        let layout = tree.installed_rock_layout(package)?;
157
158        if layout.src.is_dir() {
159            let src_canonical = layout.src.canonicalize().unwrap_or(layout.src.clone());
160            for path in WalkDir::new(&src_canonical)
161                .into_iter()
162                .filter_map(|e| e.ok())
163                .map(|e| e.into_path())
164                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "lua"))
165            {
166                let rel = path
167                    .strip_prefix(&src_canonical)
168                    .unwrap_or(&path)
169                    .with_extension("");
170                if let Ok(module) = LuaModule::from_pathbuf(rel) {
171                    lua_sources.push((module, path));
172                }
173            }
174        }
175
176        if layout.lib.is_dir() {
177            let lib_canononical = layout.lib.canonicalize().unwrap_or(layout.lib.clone());
178            for path in WalkDir::new(&lib_canononical)
179                .into_iter()
180                .filter_map(|e| e.ok())
181                .map(|e| e.into_path())
182                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == c_dylib_ext))
183            {
184                native_modules.push(path);
185            }
186        }
187    }
188    #[cfg(not(target_os = "linux"))]
189    if !native_modules.is_empty() {
190        return Err(DistProjectBinError::CannotLinkBinaryLibs(
191            native_modules
192                .iter()
193                .unique()
194                .map(|p| p.to_string_lossy())
195                .join("\n"),
196        ));
197    }
198
199    // NOTE: A `FlatDistTree` can produce duplicates, as all modules share the same `src`.
200    Ok(InstalledFiles {
201        src: lua_sources.into_iter().unique().collect(),
202        lib: native_modules.into_iter().unique().collect(),
203    })
204}
205
206/// Derive a module stem from a run-spec arg like `"src/main.lua"` -> `"main"`.
207fn entrypoint_stem(arg: &str) -> String {
208    PathBuf::from(arg)
209        .file_stem()
210        .map(|s| s.to_string_lossy().into_owned())
211        .unwrap_or_else(|| arg.to_owned())
212}
213
214/// Derive the Lua module name from an installed lib path.
215/// e.g. `<tree>/lib/foo/bar.so` -> `("foo.bar", "foo_bar")`.
216fn module_names(lib_root: &Path, path: &Path) -> (String, String) {
217    let rel = path.strip_prefix(lib_root).unwrap_or(path);
218    let dotpath = rel
219        .with_extension("")
220        .to_string_lossy()
221        .replace(std::path::MAIN_SEPARATOR, ".");
222    let underscore = dotpath.replace(['.', '-'], "_");
223    (dotpath, underscore)
224}
225
226/// Generate a C source file embedding all Lua sources.
227async fn generate_c_source(
228    entrypoint_module: &str,
229    files: &InstalledFiles,
230    lib_root: &Path,
231) -> Result<String, io::Error> {
232    let mut out = String::from(C_PREAMBLE);
233
234    out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n");
235    for path in &files.lib {
236        let (_, underscore) = module_names(lib_root, path);
237        out.push_str(&format!("int luaopen_{underscore}(lua_State *L);\n"));
238    }
239    out.push_str("#ifdef __cplusplus\n}\n#endif\n\n");
240
241    for (i, (_, path)) in files.src.iter().enumerate() {
242        let bytes = tokio::fs::read(path).await.map_err(|err| {
243            io::Error::other(format!("unable to read '{}': {err}", path.display()))
244        })?;
245        let hex = bytes_to_hex(&bytes);
246        out.push_str(&format!(
247            "static const unsigned char lua_src_{i}[] = {{{hex}}};\n"
248        ));
249    }
250
251    let loader_with_entrypoint = format!(
252        "{LUA_LOADER_SOURCE}local func = lua_loader(\"{entrypoint_module}\")\n\
253         if type(func) == \"function\" then\n\
254         \tfunc(unpack(arg))\n\
255         else\n\
256         \terror(func, 0)\n\
257         end\n"
258    );
259    let loader_hex = bytes_to_hex(loader_with_entrypoint.as_bytes());
260    out.push_str(&format!(
261        "static const unsigned char lua_loader_program[] = {{{loader_hex}}};\n\n"
262    ));
263
264    out.push_str("int main(int argc, char *argv[]) {\n");
265    out.push_str("  lua_State *L = luaL_newstate();\n");
266    out.push_str("  luaL_openlibs(L);\n");
267    out.push_str("  createargtable(L, argv, argc, 0);\n\n");
268
269    out.push_str(&format!(
270        "  if (luaL_loadbuffer(L, (const char*)lua_loader_program, sizeof(lua_loader_program), \"{entrypoint_module}\") != LUA_OK) {{\n"
271    ));
272    out.push_str("    fprintf(stderr, \"luaL_loadbuffer: %s\\n\", lua_tostring(L, -1));\n");
273    out.push_str("    lua_close(L); return 1;\n  }\n\n");
274
275    out.push_str("  /* lua_bundle */\n  lua_newtable(L);\n");
276
277    for (i, (module, _)) in files.src.iter().enumerate() {
278        out.push_str(&format!(
279            "  lua_pushlstring(L, (const char*)lua_src_{i}, sizeof(lua_src_{i}));\n"
280        ));
281        out.push_str(&format!("  lua_setfield(L, -2, \"{module}\");\n"));
282    }
283
284    for path in &files.lib {
285        let (dotpath, underscore) = module_names(lib_root, path);
286        out.push_str(&format!("  lua_pushcfunction(L, luaopen_{underscore});\n"));
287        out.push_str(&format!("  lua_setfield(L, -2, \"{dotpath}\");\n"));
288    }
289
290    out.push_str("\n  if (docall(L, 1, LUA_MULTRET)) {\n");
291    out.push_str("    const char *msg = lua_tostring(L, 1);\n");
292    out.push_str("    if (msg) fprintf(stderr, \"%s\\n\", msg);\n");
293    out.push_str("    lua_close(L); return 1;\n  }\n");
294    out.push_str("  lua_close(L);\n  return 0;\n}\n");
295
296    Ok(out)
297}
298
299fn bytes_to_hex(bytes: &[u8]) -> String {
300    bytes
301        .iter()
302        .map(|b| format!("0x{b:02x}"))
303        .collect::<Vec<_>>()
304        .join(", ")
305}
306
307async fn compile_binary(
308    c_path: &Path,
309    output: &Path,
310    lua: &LuaInstallation,
311    native_modules: &[PathBuf],
312    work_dir: &tempfile::TempDir,
313    config: &Config,
314) -> Result<(), DistProjectBinError> {
315    let mut build = cc::Build::new();
316    let host = target_lexicon::Triple::host().to_string();
317
318    let intermediate_dir = tempfile::tempdir()?;
319    build
320        .cargo_output(false)
321        .cargo_metadata(false)
322        .cargo_warnings(false)
323        .warnings(config.verbose())
324        .host(&host)
325        .target(&host)
326        .opt_level(3)
327        .out_dir(&intermediate_dir);
328
329    let compiler = build.try_get_compiler()?;
330
331    let is_msvc = compiler.is_like_msvc();
332    // Suppress all warnings
333    if is_msvc {
334        build.flag("-W0");
335    } else {
336        build.flag("-w");
337    }
338
339    let mut cmd: tokio::process::Command = compiler.to_command().into();
340    cmd.current_dir(work_dir.path());
341    cmd.arg(c_path);
342
343    for include in lua.includes() {
344        cmd.arg(format!("-I{}", include.display()));
345    }
346    cmd.args(native_modules);
347    cmd.arg("-o").arg(output);
348    cmd.args(lua.lib_link_args(&compiler));
349
350    #[cfg(not(target_env = "msvc"))]
351    {
352        cmd.arg("-rdynamic");
353        cmd.arg("-lm");
354        // Link with libdl because liblua was built with support loading
355        // shared objects and the operating system depends on it.
356        #[cfg(target_family = "unix")]
357        cmd.arg("-ldl");
358    }
359
360    let out = cmd
361        .stdout(Stdio::piped())
362        .stderr(Stdio::piped())
363        .output()
364        .await?;
365
366    if !out.status.success() {
367        return Err(DistProjectBinError::CompilationFailed {
368            status: out.status,
369            stdout: String::from_utf8_lossy(&out.stdout).into(),
370            stderr: String::from_utf8_lossy(&out.stderr).into(),
371        });
372    }
373
374    Ok(())
375}
376
377const C_PREAMBLE: &str = r#"
378#ifdef __cplusplus
379extern "C" {
380#endif
381#include <lauxlib.h>
382#include <lua.h>
383#include <lualib.h>
384#ifdef __cplusplus
385}
386#endif
387#include <signal.h>
388#include <stdio.h>
389#include <stdlib.h>
390#include <string.h>
391
392#if LUA_VERSION_NUM == 501
393  #define LUA_OK 0
394#endif
395
396static lua_State *globalL = NULL;
397
398static void lstop(lua_State *L, lua_Debug *ar) {
399  (void)ar;
400  lua_sethook(L, NULL, 0, 0);
401  luaL_error(L, "interrupted!");
402}
403
404static void laction(int i) {
405  signal(i, SIG_DFL);
406  lua_sethook(globalL, lstop, LUA_MASKCALL | LUA_MASKRET | LUA_MASKCOUNT, 1);
407}
408
409static void createargtable(lua_State *L, char **argv, int argc, int script) {
410  int i, narg;
411  if (script == argc) script = 0;
412  narg = argc - (script + 1);
413  lua_createtable(L, narg, script + 1);
414  for (i = 0; i < argc; i++) {
415    lua_pushstring(L, argv[i]);
416    lua_rawseti(L, -2, i - script);
417  }
418  lua_setglobal(L, "arg");
419}
420
421static int msghandler(lua_State *L) {
422  const char *msg = lua_tostring(L, 1);
423  if (msg == NULL) {
424    if (luaL_callmeta(L, 1, "__tostring") && lua_type(L, -1) == LUA_TSTRING)
425      return 1;
426    msg = lua_pushfstring(L, "(error object is a %s value)", luaL_typename(L, 1));
427  }
428  lua_getglobal(L, "debug");
429  lua_getfield(L, -1, "traceback");
430  lua_remove(L, -2);
431  lua_pushstring(L, msg);
432  lua_remove(L, -3);
433  lua_pushinteger(L, 2);
434  lua_call(L, 2, 1);
435  return 1;
436}
437
438static int docall(lua_State *L, int narg, int nres) {
439  int status;
440  int base = lua_gettop(L) - narg;
441  lua_pushcfunction(L, msghandler);
442  lua_insert(L, base);
443  globalL = L;
444  signal(SIGINT, laction);
445  status = lua_pcall(L, narg, nres, base);
446  signal(SIGINT, SIG_DFL);
447  lua_remove(L, base);
448  return status;
449}
450
451"#;
452
453const LUA_LOADER_SOURCE: &str = r#"local args = {...}
454local lua_bundle = args[1]
455
456local function load_string(str, name)
457	if _VERSION == "Lua 5.1" then
458		return loadstring(str, name)
459	else
460		return load(str, name)
461	end
462end
463
464local function lua_loader(name)
465	local separator = package.config:sub(1, 1)
466	name = name:gsub(separator, ".")
467	local mod = lua_bundle[name] or lua_bundle[name .. ".init"]
468	if mod then
469		if type(mod) == "string" then
470			local chunk, errstr = load_string(mod, name)
471			if chunk then
472				return chunk
473			else
474				error(
475					("error loading module '%s' from static Lua bundle:\n\t%s"):format(name, errstr),
476					0
477				)
478			end
479		elseif type(mod) == "function" then
480			return mod
481		end
482	else
483		return ("\n\tno module '%s' in static Lua bundle"):format(name)
484	end
485end
486table.insert(package.loaders or package.searchers, 2, lua_loader)
487
488local unpack = unpack or table.unpack
489"#;
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    use assert_fs::fixture::PathCopy;
496    #[cfg(target_os = "linux")]
497    use assert_fs::prelude::{PathChild, PathCreateDir};
498    use assert_fs::TempDir;
499
500    use crate::lua_installation::detect_installed_lua_version;
501    use crate::{config::ConfigBuilder, lua_version::LuaVersion, tree::FlatDistTree};
502    #[cfg(target_os = "linux")]
503    use crate::{
504        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
505        package::PackageSpec,
506        remote_package_source::RemotePackageSource,
507        rockspec::RockBinaries,
508    };
509
510    #[cfg(target_os = "linux")]
511    fn mk_dummy_package(spec: PackageSpec) -> LocalPackage {
512        let hashes = LocalPackageHashes {
513            rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
514                .parse()
515                .unwrap(),
516            source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
517                .parse()
518                .unwrap(),
519        };
520        LocalPackage::from(
521            &spec,
522            LockConstraint::Unconstrained,
523            RockBinaries::default(),
524            RemotePackageSource::Test,
525            None,
526            hashes,
527        )
528    }
529
530    #[cfg(target_os = "linux")]
531    #[tokio::test]
532    async fn test_collect_installed_files() {
533        let staging = TempDir::new().unwrap();
534        let config = ConfigBuilder::new()
535            .unwrap()
536            .lua_version(Some(LuaVersion::Lua51))
537            .build()
538            .unwrap();
539        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
540
541        let pkg_a = mk_dummy_package(PackageSpec::new("foo".into(), "1.0.0-1".parse().unwrap()));
542        let layout_a = tree.entrypoint(&pkg_a).unwrap();
543        staging
544            .child(layout_a.src.strip_prefix(staging.path()).unwrap())
545            .create_dir_all()
546            .unwrap();
547        tokio::fs::write(layout_a.src.join("foo.lua"), "return {}")
548            .await
549            .unwrap();
550
551        let pkg_b = mk_dummy_package(PackageSpec::new("bar".into(), "2.0.0-1".parse().unwrap()));
552        let layout_b = tree.entrypoint(&pkg_b).unwrap();
553        staging
554            .child(layout_b.src.strip_prefix(staging.path()).unwrap())
555            .create_dir_all()
556            .unwrap();
557        tokio::fs::write(layout_b.src.join("bar.lua"), "return {}")
558            .await
559            .unwrap();
560        staging
561            .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
562            .create_dir_all()
563            .unwrap();
564        tokio::fs::write(
565            layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
566            "",
567        )
568        .await
569        .unwrap();
570
571        {
572            let lockfile = tree.lockfile().unwrap();
573            let mut lockfile = lockfile.write_guard();
574            lockfile.add_entrypoint(&pkg_a);
575            lockfile.add_entrypoint(&pkg_b);
576        }
577
578        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
579        let files = collect_installed_files(&tree).unwrap();
580
581        assert_eq!(files.src.len(), 2);
582        assert!(files
583            .src
584            .iter()
585            .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
586        assert_eq!(files.lib.len(), 1);
587        assert!(files.lib[0]
588            .extension()
589            .is_some_and(|e| e == c_dylib_extension()));
590    }
591
592    #[tokio::test]
593    async fn test_collect_installed_files_from_sample_project() {
594        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
595            .join("resources/test/sample-projects/only-src/");
596        let project_dir = TempDir::new().unwrap();
597        project_dir.copy_from(&sample, &["**"]).unwrap();
598
599        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
600        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
601        let config = ConfigBuilder::new()
602            .unwrap()
603            .lua_version(lua_version)
604            .build()
605            .unwrap();
606
607        let staging = TempDir::new().unwrap();
608        let tree = FlatDistTree::new(
609            staging.to_path_buf(),
610            config.lua_version().cloned().unwrap(),
611            &config,
612        )
613        .unwrap();
614
615        InstallProject::new()
616            .project(&project)
617            .config(&config)
618            .tree(&tree)
619            .build()
620            .await
621            .unwrap();
622
623        let files = collect_installed_files(&tree).unwrap();
624
625        let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
626
627        assert!(
628            module_keys.contains(&"main"),
629            "expected 'main' in {module_keys:?}"
630        );
631        assert!(
632            module_keys.contains(&"foo"),
633            "expected 'foo' in {module_keys:?}"
634        );
635    }
636
637    #[tokio::test]
638    async fn test_dist_bin_from_lua_source_compiles_and_runs() {
639        test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
640    }
641
642    #[tokio::test]
643    #[cfg(target_os = "linux")]
644    async fn test_dist_bin_from_c_source_compiles_and_runs() {
645        test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
646    }
647
648    async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
649        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
650        let project_dir = TempDir::new().unwrap();
651        project_dir.copy_from(&sample, &["**"]).unwrap();
652
653        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
654        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
655        let config = ConfigBuilder::new()
656            .unwrap()
657            .lua_version(lua_version)
658            .build()
659            .unwrap();
660
661        let staging = TempDir::new().unwrap();
662        let tree = FlatDistTree::new(
663            staging.to_path_buf(),
664            config.lua_version().cloned().unwrap(),
665            &config,
666        )
667        .unwrap();
668
669        let out_dir = TempDir::new().unwrap();
670        let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
671            "sample-project.exe"
672        } else {
673            "sample-project"
674        });
675
676        DistProjectBin::new()
677            .project(&project)
678            .config(&config)
679            .tree(&tree)
680            .output(binary.clone())
681            .compile()
682            .await
683            .unwrap();
684
685        assert!(binary.is_file(), "binary not produced");
686
687        let out = tokio::process::Command::new(&binary)
688            .output()
689            .await
690            .unwrap();
691
692        assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
693        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
694    }
695}