From 3d7d2395bfa8354bd8e49615c5fb00d61ee09752 Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Sun, 18 Oct 2026 21:02:42 +0800 Subject: [PATCH 1/2] fix(pdump): exclude build environments from loadup images GNU Emacs 31.1 skips set_initial_environment while constructing a dump. Neomacs instead retained the entire builder environment through initial-environment, pinning build-only Nix dependencies in the output. Use the typed loadup dump mode to omit that snapshot, seed a portable shell before cus-start captures its standard value, and exclude exec-path only during serialization so loadup can still run Git. Keep construction state native and reset it when restoring or activating a runtime image. Keep Context offsets stable using trailing padding, an ABI guard, and a compile-time size assertion. Ordinary runtime redumps retain their existing environment policy. Exercise both image modes through public dump/load APIs in isolated processes, scan image bytes, preserve repository metadata, and verify runtime environment, subprocesses, and subsequent user redumps. Reject build-only references in the Nix package output. Fixes #578. --- crates/neovm-core/Cargo.toml | 4 + .../src/emacs_core/lisp/load/mod.rs | 24 ++- .../lisp/native/builtins/symbols.rs | 16 ++ .../src/emacs_core/runtime/eval/construct.rs | 1 + .../src/emacs_core/runtime/eval/mod.rs | 10 + .../runtime/eval/pdump_reconstruct.rs | 1 + .../src/emacs_core/system/environment/mod.rs | 7 +- .../emacs_core/system/shell_file_name/mod.rs | 11 + crates/neovm-core/tests/dump_environment.rs | 195 ++++++++++++++++++ nix/package.nix | 17 +- 10 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 crates/neovm-core/tests/dump_environment.rs diff --git a/crates/neovm-core/Cargo.toml b/crates/neovm-core/Cargo.toml index 2394a3e43d..317108fb60 100644 --- a/crates/neovm-core/Cargo.toml +++ b/crates/neovm-core/Cargo.toml @@ -10,6 +10,10 @@ description = "Core runtime structures for NeoVM" # auto-discovery off makes every remaining integration target explicit below. autotests = false +[[test]] +name = "dump_environment" +path = "tests/dump_environment.rs" + [dependencies] neovm-host-abi.workspace = true neomacs-display-protocol.workspace = true diff --git a/crates/neovm-core/src/emacs_core/lisp/load/mod.rs b/crates/neovm-core/src/emacs_core/lisp/load/mod.rs index bcea8b1a22..866590665e 100644 --- a/crates/neovm-core/src/emacs_core/lisp/load/mod.rs +++ b/crates/neovm-core/src/emacs_core/lisp/load/mod.rs @@ -6186,9 +6186,18 @@ pub fn create_bootstrap_evaluator_for_loadup( maybe_trace_bootstrap_step("create_bootstrap_evaluator_with_features: enter"); let mut eval = super::eval::Context::new(); maybe_trace_bootstrap_step("create_bootstrap_evaluator_with_features: evaluator-new"); - // Match GNU's initialization order: Lisp startup must inherit the host - // environment before loadup.el can inspect HOME or any other variable. - super::environment::install_host_environment_snapshot(&mut eval); + // GNU 31.1 emacs.c skips set_initial_environment when will_dump_p(). + match invocation { + LoadupInvocation::PreloadOnly => { + super::environment::install_host_environment_snapshot(&mut eval); + super::shell_file_name::install(&mut eval); + } + LoadupInvocation::Dump(dump) => { + eval.loadup_dump_mode = Some(dump.mode()); + // cus-start records this initial value in standard-value. + super::shell_file_name::install_dump_default(&mut eval); + } + } let bootstrap_features = normalized_bootstrap_features(extra_features); for feature in &bootstrap_features { let _ = eval.provide_value(Value::symbol(feature), None); @@ -6256,7 +6265,9 @@ pub fn create_bootstrap_evaluator_for_loadup( Value::unibyte_string(lisp_directory_name_from_host_path(&project_root)), ); - // exec-path: list of dirs from PATH env var (C: callproc.c init_callproc_1) + // Keep build tools available until serialization: loadup queries Git + // before dumping. dump-emacs-portable omits the PATH derivative + // only while writing a loadup image; activation re-derives them. let path_dirs: Vec = exec_path_dirs_from_env() .into_iter() .map(Value::unibyte_string) @@ -6286,10 +6297,6 @@ pub fn create_bootstrap_evaluator_for_loadup( eval.obarray.make_special(name); } - // GNU callproc.c plus w32.c: inherited $SHELL, /bin/sh on POSIX, - // and the private cmdproxy.exe on Windows. Bootstrap and post-image - // startup share the same typed platform policy. - super::shell_file_name::install(&mut eval); // shell-command-switch: GNU simple.el — defaults to "-c" eval.set_variable("shell-command-switch", Value::unibyte_string("-c")); @@ -6551,6 +6558,7 @@ fn activate_runtime_evaluator_at_root( project_root: &Path, role: RuntimeImageRole, ) -> Result<(), EvalError> { + eval.loadup_dump_mode = None; finalize_cached_bootstrap_eval(eval, project_root, role).map_err(|error| { tracing::error!("runtime evaluator activation failed: {error:?}"); error diff --git a/crates/neovm-core/src/emacs_core/lisp/native/builtins/symbols.rs b/crates/neovm-core/src/emacs_core/lisp/native/builtins/symbols.rs index 873191f787..e8f14ffe92 100644 --- a/crates/neovm-core/src/emacs_core/lisp/native/builtins/symbols.rs +++ b/crates/neovm-core/src/emacs_core/lisp/native/builtins/symbols.rs @@ -5814,7 +5814,23 @@ pub(crate) fn builtin_dump_emacs_portable( } ctx.set_variable("command-line-processed", Value::NIL); ctx.set_variable("process-environment", Value::NIL); + // This global is needed by loadup's build tools, but runtime startup + // reconstructs it. Save it only in native locals, after the last GC: + // Lisp bindings retaining the originals would also enter the image. + // Ordinary runtime redumps keep GNU's behavior, regardless of filename. + let saved_build_exec_path = ctx.loadup_dump_mode.map(|_| { + let exec_path = ctx + .obarray() + .canonical_value_without_buffer(CanonicalAccess::DUMP, "exec-path") + .any_arm() + .unwrap_or(Value::NIL); + ctx.set_variable("exec-path", Value::NIL); + exec_path + }); let dump_result = crate::emacs_core::pdump::dump_to_file(ctx, dump_path); + if let Some(exec_path) = saved_build_exec_path { + ctx.set_variable("exec-path", exec_path); + } ctx.set_variable("post-gc-hook", saved_post_gc_hook); ctx.set_variable("command-line-processed", saved_command_line_processed); ctx.set_variable("process-environment", saved_process_environment); diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs b/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs index f1ffadfbaa..551d5be374 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/construct.rs @@ -2168,6 +2168,7 @@ impl Context { minibuffer_selected_window: world.global.minibuffer_selected_window, active_minibuffer_window: world.global.active_minibuffer_window, shutdown_request: None, + loadup_dump_mode: None, shutdown_in_progress: false, daemon: world.global.daemon, _reserved_input_mode_interrupt: true, diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs index 26f8aea59b..d1568691da 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs @@ -3580,10 +3580,20 @@ pub struct Context { macro_perf_enabled: bool, /// Custom variable manager — defcustom/defgroup system. pub(crate) custom: CustomManager, + /// Native construction state, never serialized. Unlike an ordinary + /// runtime redump, loadup must omit build-only environment derivatives. + /// Use trailing padding to preserve the pinned Context field offsets. + pub(crate) loadup_dump_mode: Option, } // A mutex does not make the host hooks, Rc leases or TLS views transferable. static_assertions::assert_not_impl_any!(Context: Send, Sync); +// Construction state must fit the single trailing byte without growing +// Context or shifting its pinned native/JIT ABI. +static_assertions::const_assert_eq!( + std::mem::size_of::>(), + 1 +); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ShutdownRequest { diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs b/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs index 45c9ebeb82..b9133e7f7b 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/pdump_reconstruct.rs @@ -181,6 +181,7 @@ impl Context { minibuffer_selected_window: world.global.minibuffer_selected_window, active_minibuffer_window: world.global.active_minibuffer_window, shutdown_request: None, + loadup_dump_mode: None, shutdown_in_progress: false, daemon: world.global.daemon, _reserved_input_mode_interrupt: true, diff --git a/crates/neovm-core/src/emacs_core/system/environment/mod.rs b/crates/neovm-core/src/emacs_core/system/environment/mod.rs index e268c4a59f..9b2486e224 100644 --- a/crates/neovm-core/src/emacs_core/system/environment/mod.rs +++ b/crates/neovm-core/src/emacs_core/system/environment/mod.rs @@ -60,9 +60,10 @@ fn host_process_environment() -> Value { /// Install the environment inherited by this Neomacs process as the Lisp /// startup environment. /// -/// GNU initializes `process-environment` before `loadup.el`, so every startup -/// consumer observes the same HOME, PATH, and other host variables as file -/// expansion and subprocess creation. A cached evaluator needs the same +/// GNU initializes `process-environment` before Lisp startup, except when +/// constructing a dump (`!will_dump_p()` in emacs.c). Runtime consumers +/// observe the same HOME, PATH, and other host variables as file expansion +/// and subprocess creation. A cached evaluator needs the same /// operation when it is activated, because its dumped environment belongs to /// the process that created the cache rather than the current process. pub(crate) fn install_host_environment_snapshot(eval: &mut Context) { diff --git a/crates/neovm-core/src/emacs_core/system/shell_file_name/mod.rs b/crates/neovm-core/src/emacs_core/system/shell_file_name/mod.rs index c19d41e381..31aa5afdb7 100644 --- a/crates/neovm-core/src/emacs_core/system/shell_file_name/mod.rs +++ b/crates/neovm-core/src/emacs_core/system/shell_file_name/mod.rs @@ -91,6 +91,17 @@ pub(crate) fn resolve_current() -> ResolvedShellFileName { /// Install the one authoritative startup answer into Lisp. pub(crate) fn install(eval: &mut Context) { let shell = resolve_current(); + install_resolved(eval, shell); +} + +/// Image construction must not retain the builder's SHELL or executable +/// search directory. Runtime startup replaces this portable default with +/// the inherited shell or the installed private cmdproxy path. +pub(crate) fn install_dump_default(eval: &mut Context) { + install_resolved(eval, resolve_for(CURRENT_PLATFORM, None, None)); +} + +fn install_resolved(eval: &mut Context, shell: ResolvedShellFileName) { eval.set_variable("shell-file-name", Value::unibyte_string(shell.lisp_name())); eval.obarray.make_special("shell-file-name"); } diff --git a/crates/neovm-core/tests/dump_environment.rs b/crates/neovm-core/tests/dump_environment.rs new file mode 100644 index 0000000000..5842f497f2 --- /dev/null +++ b/crates/neovm-core/tests/dump_environment.rs @@ -0,0 +1,195 @@ +//! Build images exclude host state; activation supplies the runtime environment. +use neovm_core::emacs_core::{ + format_eval_result, + load::{ + LoadupDumpInvocation, LoadupDumpMode, LoadupInvocation, RuntimeImageRole, + activate_runtime_evaluator, create_bootstrap_evaluator_for_loadup, + }, + pdump::load_from_dump, +}; +use std::{fs, path::PathBuf, process::Command}; +use tempfile::tempdir; + +#[test] +fn production_dump_does_not_capture_the_build_environment() { + assert_dump_environment( + LoadupDumpMode::Pdump, + "production_dump_does_not_capture_the_build_environment", + ); +} + +#[test] +fn bootstrap_dump_does_not_capture_the_build_environment() { + assert_dump_environment( + LoadupDumpMode::Pbootstrap, + "bootstrap_dump_does_not_capture_the_build_environment", + ); +} + +fn assert_dump_environment(mode: LoadupDumpMode, test_name: &str) { + const MARKER: &str = "/nix/store/00000000000000000000000000000000-issue578-build-only"; + const CHILD: &str = "NEOMACS_DUMP_ENVIRONMENT_TEST_CHILD"; + let (dump_name, role) = match mode { + LoadupDumpMode::Pdump => ("neomacs.pdump", RuntimeImageRole::Final), + LoadupDumpMode::Pbootstrap => ("bootstrap-neomacs.pdump", RuntimeImageRole::Bootstrap), + }; + if std::env::var_os(CHILD).is_none() { + // Context initializes invocation-directory from the executable. Run + // the real dump path in a private process and directory, without + // mutating this test process's environment or build artifacts. + let directory = tempdir().unwrap(); + let executable = directory + .path() + .join(format!("neomacs-temacs{}", std::env::consts::EXE_SUFFIX)); + fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); + fs::write( + directory + .path() + .join(format!("neomacs{}", std::env::consts::EXE_SUFFIX)), + b"dump fixture", + ) + .unwrap(); + let output = Command::new(&executable) + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD, "dump") + .env("NEOMACS_ISSUE578_BUILD_ONLY", MARKER) + .env( + "PATH", + std::env::join_paths( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .chain(std::iter::once(PathBuf::from(MARKER))), + ) + .unwrap(), + ) + .env("SHELL", format!("{MARKER}/bin/sh")) + .output() + .expect("run isolated dump construction"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + let bytes = fs::read(directory.path().join(dump_name)).unwrap(); + assert!( + !bytes + .windows(MARKER.len()) + .any(|window| window == MARKER.as_bytes()), + "production dump retains a build-only environment value", + ); + let runtime = Command::new(&executable) + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD, "runtime") + .env_remove("NEOMACS_ISSUE578_BUILD_ONLY") + .env("NEOMACS_ISSUE578_RUNTIME", "runtime") + .env("NEOMACS_ISSUE578_RUNTIME_PATH", "issue578-runtime-bin") + .env( + "PATH", + std::env::join_paths( + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .chain(std::iter::once(PathBuf::from("issue578-runtime-bin"))), + ) + .unwrap(), + ) + .env("SHELL", "issue578-runtime-shell") + .output() + .expect("activate the image in a different runtime environment"); + assert!( + runtime.status.success(), + "{}", + String::from_utf8_lossy(&runtime.stderr) + ); + return; + } + let temacs = std::env::current_exe().unwrap(); + let dump_path = temacs.parent().unwrap().join(dump_name); + if std::env::var(CHILD).as_deref() == Ok("dump") { + let invocation = LoadupInvocation::Dump(LoadupDumpInvocation::new( + mode, + vec![ + temacs.to_string_lossy().into_owned(), + "--batch".into(), + "-l".into(), + "loadup".into(), + format!("--temacs={}", mode.as_gnu_string()), + ], + )); + let eval = create_bootstrap_evaluator_for_loadup(&[], &invocation) + .expect("build the dump through loadup"); + assert_eq!( + eval.shutdown_request().map(|request| request.exit_code), + Some(0) + ); + drop(eval); + let mut restored = load_from_dump(&dump_path).expect("restore the dump"); + + if mode == LoadupDumpMode::Pdump { + let version = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")) + .output(); + if let Ok(version) = version + && version.status.success() + { + let expected = String::from_utf8(version.stdout).unwrap(); + assert_eq!( + format_eval_result(&restored.eval_str("emacs-repository-version")), + format!("OK {:?}", expected.trim()), + "loadup must still be able to query Git before serializing", + ); + } + } + // GNU 31.1 emacs.c guards set_initial_environment with !will_dump_p(). + assert_eq!( + format_eval_result( + &restored.eval_str("(and (null initial-environment) (null process-environment))",) + ), + "OK t" + ); + } else { + let mut restored = load_from_dump(&dump_path).expect("restore the dump"); + activate_runtime_evaluator(&mut restored, role).expect("activate the runtime environment"); + assert_eq!( + format_eval_result(&restored.eval_str( + r#" + (and (equal (getenv "NEOMACS_ISSUE578_RUNTIME") "runtime") + (null (getenv "NEOMACS_ISSUE578_BUILD_ONLY")) + (not (eq initial-environment process-environment)) + (member (getenv "NEOMACS_ISSUE578_RUNTIME_PATH") exec-path) + (equal shell-file-name "issue578-runtime-shell") + (equal (getenv-internal "NEOMACS_ISSUE578_RUNTIME" initial-environment) + "runtime")) + "# + )), + "OK t" + ); + #[cfg(unix)] + assert_eq!( + format_eval_result(&restored.eval_str( + r#" + (with-temp-buffer + (list (call-process "/bin/sh" nil t nil "-c" "printf %s \"$NEOMACS_ISSUE578_RUNTIME\"") + (buffer-string))) + "# + )), + "OK (0 \"runtime\")" + ); + // A restored runtime must not inherit the producer's construction + // state: ordinary user redumps preserve their shell and search path. + let redump = dump_path.with_file_name("runtime-redump.pdump"); + let form = format!( + "(progn (setq noninteractive t) (dump-emacs-portable {:?}))", + redump.to_string_lossy(), + ); + assert_eq!(format_eval_result(&restored.eval_str(&form)), "OK nil"); + drop(restored); + let mut redumped = load_from_dump(&redump).expect("restore an ordinary runtime redump"); + assert_eq!( + format_eval_result(&redumped.eval_str( + r#"(and (equal shell-file-name "issue578-runtime-shell") + (member "issue578-runtime-bin" exec-path) t)"#, + )), + "OK t", + ); + } +} diff --git a/nix/package.nix b/nix/package.nix index 6fd5256862..d5ffcea033 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -78,7 +78,8 @@ let gstreamerRuntime.pluginScanner ] ++ - lib.optionals (pkgs.stdenv.hostPlatform.isLinux && builtins.elem "webview" productionCapabilities.cargoFeatures) + lib.optionals + (pkgs.stdenv.hostPlatform.isLinux && builtins.elem "webview" productionCapabilities.cargoFeatures) [ "--set-default" "WPE_BACKEND_LIBRARY" @@ -107,6 +108,20 @@ craneLib.buildPackage ( src = source; inherit cargoArtifacts; + # A dumped build environment can turn build-only inputs into runtime + # dependencies even when the executable never links them. Let Nix's + # output reference scan reject that regression after fixup hooks run. + disallowedReferences = [ + rustToolchain + cargoArtifacts + cargoSrc + source + pkgs.stdenv.cc + pkgs.llvmPackages.clang + pkgs.pkg-config + pkgs.binutils + ]; + postBuild = '' cargo xtask fresh-build --release --skip-build ''; From 4e99d070af50ba75a0e60d1ecc8739144aaf6c2d Mon Sep 17 00:00:00 2001 From: Eval Exec Date: Mon, 19 Oct 2026 01:14:47 +0800 Subject: [PATCH 2/2] test(pdump): identify the image in environment assertions --- crates/neovm-core/tests/dump_environment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/neovm-core/tests/dump_environment.rs b/crates/neovm-core/tests/dump_environment.rs index 5842f497f2..096bc3e409 100644 --- a/crates/neovm-core/tests/dump_environment.rs +++ b/crates/neovm-core/tests/dump_environment.rs @@ -75,7 +75,7 @@ fn assert_dump_environment(mode: LoadupDumpMode, test_name: &str) { !bytes .windows(MARKER.len()) .any(|window| window == MARKER.as_bytes()), - "production dump retains a build-only environment value", + "{dump_name} retains a build-only environment value", ); let runtime = Command::new(&executable) .args(["--exact", test_name, "--nocapture"])