authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 14:06:08-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 16:23:40-07:00
log89fbf04ec821433bd822bfd0e374ead7b88b611f
tree74f71e5a31fc06cfbb2388cd58ed465cac6b2d21
parent17a124c2584691c108abddd1570f750a0553b9a9
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the Linux build runs on NixOS as well as every glibc distribution

Releases carry no PT_INTERP: the kernel starts them at a small entry that re-executes the file under the dynamic loader /bin/sh uses, which on NixOS is the store's glibc. On NixOS, the libraries opened by name are then loaded by path from the system's and the user's profiles. current_exe() names the loader under this start, so updates, installing and the desktop entry take the executable's own path. Assisted-by: claude-opus-5.5

7 files changed, 300 insertions(+), 7 deletions(-)

crates/snowbound/build.rs+7-2
...@@ -1,8 +1,14 @@...@@ -1,8 +1,14 @@
1//! On Windows, links in the executable's icon and manifest, compiled by llvm-mingw's windres,1//! On Windows, links in the executable's icon and manifest, compiled by llvm-mingw's windres,
2//! which `platform/windows/cargo.sh` names.2//! which `platform/windows/cargo.sh` names. On Linux, enters through `loader`.
33
4fn main() {4fn main() {
5 println!("cargo:rerun-if-changed=build.rs");5 println!("cargo:rerun-if-changed=build.rs");
6 let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap();
7 if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux")
8 && ["x86_64", "aarch64"].contains(&arch.as_str())
9 {
10 println!("cargo:rustc-link-arg-bins=-Wl,-e,sb_entry");
11 }
6 if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {12 if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
7 return;13 return;
8 }14 }
...@@ -13,7 +19,6 @@ fn main() {...@@ -13,7 +19,6 @@ fn main() {
13 println!("cargo:rerun-if-env-changed=LLVM_MINGW");19 println!("cargo:rerun-if-env-changed=LLVM_MINGW");
14 let tools = std::env::var("LLVM_MINGW")20 let tools = std::env::var("LLVM_MINGW")
15 .expect("Windows builds need llvm-mingw: build through platform/windows/cargo.sh");21 .expect("Windows builds need llvm-mingw: build through platform/windows/cargo.sh");
16 let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap();
17 let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("resources.o");22 let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("resources.o");
18 let status = std::process::Command::new(format!("{tools}/bin/{arch}-w64-mingw32-windres"))23 let status = std::process::Command::new(format!("{tools}/bin/{arch}-w64-mingw32-windres"))
19 .current_dir(folder)24 .current_dir(folder)
crates/snowbound/linux/README.md+2-1
...@@ -32,7 +32,8 @@ Snowbound....@@ -32,7 +32,8 @@ Snowbound.
32## Requirements32## Requirements
3333
34- x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8,34- x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8,
35 Ubuntu 14.04 and later).35 Ubuntu 14.04 and later). NixOS needs nothing extra: Snowbound finds the
36 libraries below among the system's packages or your profile's.
36- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through37- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through
37 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.38 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
38- fontconfig, and X11 or Wayland with libxkbcommon.39- fontconfig, and X11 or Wayland with libxkbcommon.
crates/snowbound/linux/package.sh+14
...@@ -12,6 +12,20 @@ for arch do...@@ -12,6 +12,20 @@ for arch do
12 triple=$arch-unknown-linux-gnu12 triple=$arch-unknown-linux-gnu
13 CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \13 CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \
14 --manifest-path "$root/Cargo.toml" --release -p snowbound14 --manifest-path "$root/Cargo.toml" --release -p snowbound
15 # PT_INTERP becomes PT_NULL, so the kernel starts the executable at `loader`'s entry, which
16 # finds the system's dynamic loader, NixOS's too. zig rejects --no-dynamic-linker.
17 python3 - "$target/$triple/release/snowbound" <<'PYTHON'
18import struct, sys
19with open(sys.argv[1], 'r+b') as elf:
20 header = elf.read(64)
21 table, = struct.unpack_from('<Q', header, 32)
22 size, count = struct.unpack_from('<HH', header, 54)
23 for index in range(count):
24 elf.seek(table + index * size)
25 if struct.unpack('<I', elf.read(4))[0] == 3:
26 elf.seek(table + index * size)
27 elf.write(struct.pack('<I', 0))
28PYTHON
1529
16 name=snowbound-linux-$arch30 name=snowbound-linux-$arch
17 stage="$target/dist/$name"31 stage="$target/dist/$name"
crates/snowbound/src/desktop_linux.rs+2-2
...@@ -142,7 +142,7 @@ pub fn prepare(event_loop: &EventLoop<crate::UserEvent>) {...@@ -142,7 +142,7 @@ pub fn prepare(event_loop: &EventLoop<crate::UserEvent>) {
142fn write_portable(path: &Path) -> io::Result<()> {142fn write_portable(path: &Path) -> io::Result<()> {
143 let icon = runtime_icon().ok_or(io::ErrorKind::NotFound)?;143 let icon = runtime_icon().ok_or(io::ErrorKind::NotFound)?;
144 fs::write(&icon, ICON)?;144 fs::write(&icon, ICON)?;
145 let text = entry_text(&std::env::current_exe()?, &icon.to_string_lossy())145 let text = entry_text(&crate::loader::executable()?, &icon.to_string_lossy())
146 + &format!("NoDisplay=true\n{PORTABLE}={}\n", std::process::id());146 + &format!("NoDisplay=true\n{PORTABLE}={}\n", std::process::id());
147 write_entry(path, &text)?;147 write_entry(path, &text)?;
148 extern "C" fn exiting() {148 extern "C" fn exiting() {
...@@ -366,7 +366,7 @@ pub fn install() {...@@ -366,7 +366,7 @@ pub fn install() {
366366
367fn try_install() -> io::Result<()> {367fn try_install() -> io::Result<()> {
368 let (data, binary) = data_home().zip(binary()).ok_or(io::ErrorKind::NotFound)?;368 let (data, binary) = data_home().zip(binary()).ok_or(io::ErrorKind::NotFound)?;
369 let running = std::env::current_exe()?;369 let running = crate::loader::executable()?;
370 if fs::canonicalize(&running)? != fs::canonicalize(&binary).unwrap_or_default() {370 if fs::canonicalize(&running)? != fs::canonicalize(&binary).unwrap_or_default() {
371 fs::create_dir_all(binary.parent().ok_or(io::ErrorKind::NotFound)?)?;371 fs::create_dir_all(binary.parent().ok_or(io::ErrorKind::NotFound)?)?;
372 let partial = binary.with_extension("partial");372 let partial = binary.with_extension("partial");
crates/snowbound/src/loader_linux.rs created+264
...@@ -0,0 +1,264 @@
1//! Starting on every Linux, NixOS included. Releases carry no PT_INTERP (`linux/package.sh`
2//! clears it), so the kernel starts them at `sb_entry` as it would a static executable;
3//! stage 1 then re-executes the file under the dynamic loader `/bin/sh` uses. Started by a
4//! dynamic loader, `sb_entry` hands straight to glibc's `_start`.
5
6use std::{
7 ffi::{CStr, CString, OsStr, c_void},
8 io,
9 mem::MaybeUninit,
10 os::unix::ffi::OsStrExt,
11 path::{Path, PathBuf},
12};
13
14// A dynamic loader passes its finalizer to `_start` in x0 or rdx; the kernel passes zero.
15#[cfg(target_arch = "aarch64")]
16std::arch::global_asm!(
17 ".globl sb_entry",
18 ".type sb_entry, %function",
19 "sb_entry:",
20 "cbz x0, 1f",
21 "b _start",
22 "1: mov x0, sp",
23 "b {stage1}",
24 stage1 = sym stage1,
25);
26#[cfg(target_arch = "x86_64")]
27std::arch::global_asm!(
28 ".globl sb_entry",
29 ".type sb_entry, @function",
30 "sb_entry:",
31 "test rdx, rdx",
32 "jnz _start",
33 "mov rdi, rsp",
34 "and rsp, -16",
35 "call {stage1}",
36 stage1 = sym stage1,
37);
38
39#[cfg(target_arch = "aarch64")]
40mod call {
41 pub const OPENAT: usize = 56;
42 pub const PREAD: usize = 67;
43 pub const READLINKAT: usize = 78;
44 pub const EXECVE: usize = 221;
45 pub const EXIT: usize = 93;
46 pub const WRITE: usize = 64;
47 pub const MMAP: usize = 222;
48
49 pub unsafe fn syscall(number: usize, a: usize, b: usize, c: usize, d: usize) -> isize {
50 let result;
51 unsafe {
52 std::arch::asm!("svc 0", in("x8") number, inlateout("x0") a => result, in("x1") b,
53 in("x2") c, in("x3") d, in("x4") usize::MAX, in("x5") 0, options(nostack))
54 };
55 result
56 }
57}
58#[cfg(target_arch = "x86_64")]
59mod call {
60 pub const OPENAT: usize = 257;
61 pub const PREAD: usize = 17;
62 pub const READLINKAT: usize = 267;
63 pub const EXECVE: usize = 59;
64 pub const EXIT: usize = 60;
65 pub const WRITE: usize = 1;
66 pub const MMAP: usize = 9;
67
68 pub unsafe fn syscall(number: usize, a: usize, b: usize, c: usize, d: usize) -> isize {
69 let result;
70 unsafe {
71 std::arch::asm!("syscall", inlateout("rax") number => result, in("rdi") a,
72 in("rsi") b, in("rdx") c, in("r10") d, in("r8") usize::MAX, in("r9") 0,
73 lateout("rcx") _, lateout("r11") _, options(nostack))
74 };
75 result
76 }
77}
78
79const CWD: usize = -100isize as usize;
80const O_CLOEXEC: usize = 0o2000000;
81static PROC_SELF_EXE: [u8; 15] = *b"/proc/self/exe\0";
82static SHELLS: [[u8; 13]; 2] = [*b"/bin/sh\0\0\0\0\0\0", *b"/usr/bin/env\0"];
83static NO_LOADER: [u8; 62] = *b"snowbound: no dynamic loader; Snowbound runs on glibc systems\n";
84
85/// The interpreter of the ELF at `path` into `buffer`, NUL-terminated; false where it has
86/// none.
87unsafe fn interpreter(path: *const u8, buffer: *mut u8, capacity: usize) -> bool {
88 use call::*;
89 unsafe {
90 let file = syscall(OPENAT, CWD, path as usize, O_CLOEXEC, 0);
91 if file < 0 {
92 return false;
93 }
94 let file = file as usize;
95 let mut header = MaybeUninit::<[u64; 8]>::uninit();
96 let header = header.as_mut_ptr() as *mut u8;
97 if syscall(PREAD, file, header as usize, 64, 0) != 64
98 || (header as *const u32).read_unaligned() != u32::from_le_bytes(*b"\x7fELF")
99 {
100 return false;
101 }
102 let table = (header.add(32) as *const u64).read_unaligned() as usize;
103 let size = (header.add(54) as *const u16).read_unaligned() as usize;
104 let count = (header.add(56) as *const u16).read_unaligned() as usize;
105 let mut entry = MaybeUninit::<[u64; 7]>::uninit();
106 let entry = entry.as_mut_ptr() as *mut u8;
107 let mut index = 0;
108 while index < count {
109 if syscall(PREAD, file, entry as usize, 56, table + index * size) == 56
110 && (entry as *const u32).read_unaligned() == 3
111 {
112 let offset = (entry.add(8) as *const u64).read_unaligned() as usize;
113 let length = (entry.add(32) as *const u64).read_unaligned() as usize;
114 return length < capacity
115 && syscall(PREAD, file, buffer as usize, length, offset) == length as isize
116 && {
117 buffer.add(length).write_volatile(0);
118 true
119 };
120 }
121 index += 1;
122 }
123 false
124 }
125}
126
127/// Runs before relocation: only system calls, the stack and position-relative statics.
128unsafe extern "C" fn stage1(stack: *const usize) -> ! {
129 use call::*;
130 unsafe {
131 let argc = *stack;
132 let argv = stack.add(1);
133 let environment = argv.add(argc + 1);
134 let mut exe = MaybeUninit::<[u8; 4096]>::uninit();
135 let exe = exe.as_mut_ptr() as *mut u8;
136 let mut loader = MaybeUninit::<[u8; 4096]>::uninit();
137 let loader = loader.as_mut_ptr() as *mut u8;
138 let length = syscall(
139 READLINKAT,
140 CWD,
141 PROC_SELF_EXE.as_ptr() as usize,
142 exe as usize,
143 4095,
144 );
145 let found = length > 0 && {
146 exe.add(length as usize).write_volatile(0);
147 let mut shell = 0;
148 while shell < SHELLS.len() && !interpreter(SHELLS[shell].as_ptr(), loader, 4096) {
149 shell += 1;
150 }
151 shell < SHELLS.len()
152 };
153 // The loader runs the program its `argv[1]` names, which the program gets as `argv[0]`.
154 let bytes = (argc + 2) * size_of::<usize>();
155 let arguments = syscall(MMAP, 0, bytes, 3, 0x22) as *mut usize;
156 if found && (arguments as isize) > 0 {
157 arguments.write_volatile(loader as usize);
158 arguments.add(1).write_volatile(exe as usize);
159 let mut index = 1;
160 while index < argc {
161 arguments.add(index + 1).write_volatile(*argv.add(index));
162 index += 1;
163 }
164 arguments.add(argc + 1).write_volatile(0);
165 syscall(
166 EXECVE,
167 loader as usize,
168 arguments as usize,
169 environment as usize,
170 0,
171 );
172 }
173 syscall(WRITE, 2, NO_LOADER.as_ptr() as usize, NO_LOADER.len(), 0);
174 loop {
175 syscall(EXIT, 127, 0, 0, 0);
176 }
177 }
178}
179
180/// This executable's path. `current_exe` names the dynamic loader where stage 1 or a person
181/// started the file through it; the loader passes the file's path on as `argv[0]`.
182pub fn executable() -> io::Result<PathBuf> {
183 let running = std::env::current_exe()?;
184 let name = running.file_name().map_or(&b""[..], |name| name.as_bytes());
185 if !name.starts_with(b"ld-") {
186 return Ok(running);
187 }
188 std::fs::canonicalize(std::env::args_os().next().ok_or(io::ErrorKind::NotFound)?)
189}
190
191/// Whether the library `name` loads, as the crates that open it would find it.
192pub fn loads(name: &CStr) -> bool {
193 !unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_LAZY) }.is_null()
194}
195
196/// The libraries Snowbound and its crates open by name, Wayland's and X11's first.
197const LIBRARIES: [&CStr; 14] = [
198 c"libwayland-client.so.0",
199 c"libwayland-egl.so.1",
200 c"libxkbcommon.so.0",
201 c"libxkbcommon-x11.so.0",
202 c"libX11.so.6",
203 c"libX11-xcb.so.1",
204 c"libXcursor.so.1",
205 c"libXi.so.6",
206 c"libxcb.so.1",
207 c"libvulkan.so.1",
208 c"libEGL.so.1",
209 c"libfontconfig.so.1",
210 c"libenchant-2.so.2",
211 c"libgstreamer-1.0.so.0",
212];
213
214/// On NixOS, where libraries sit only in the store, loads by path each library the system's
215/// and the user's profiles have that no search finds; opening one by name then finds it
216/// loaded.
217pub fn preload() {
218 let system = Path::new("/run/current-system");
219 if !system.exists() {
220 return;
221 }
222 let mut missing: Vec<&CStr> = LIBRARIES.into_iter().filter(|name| !loads(name)).collect();
223 if missing.is_empty() {
224 return;
225 }
226 let home = std::env::var_os("HOME").map(PathBuf::from);
227 let user = std::env::var_os("USER").map(|user| Path::new("/etc/profiles/per-user").join(user));
228 let profiles = [
229 Some(system.to_owned()),
230 user,
231 home.map(|home| home.join(".nix-profile")),
232 ];
233 let Ok(output) = std::process::Command::new(system.join("sw/bin/nix-store"))
234 .arg("--query")
235 .arg("--requisites")
236 .args(
237 profiles
238 .into_iter()
239 .flatten()
240 .filter(|profile| profile.exists()),
241 )
242 .stderr(std::process::Stdio::null())
243 .output()
244 else {
245 return;
246 };
247 for path in output.stdout.split(|&byte| byte == b'\n') {
248 let folder = Path::new(OsStr::from_bytes(path)).join("lib");
249 if missing.is_empty() {
250 break;
251 }
252 if !folder.is_dir() {
253 continue;
254 }
255 missing.retain(|name| {
256 let file = folder.join(OsStr::from_bytes(name.to_bytes()));
257 let Ok(file) = CString::new(file.as_os_str().as_bytes()) else {
258 return true;
259 };
260 let library: *mut c_void = unsafe { libc::dlopen(file.as_ptr(), libc::RTLD_LAZY) };
261 library.is_null()
262 });
263 }
264}
crates/snowbound/src/main.rs+5
...@@ -20,6 +20,9 @@ mod instance;...@@ -20,6 +20,9 @@ mod instance;
20mod keys;20mod keys;
21mod library;21mod library;
22mod link;22mod link;
23#[cfg(target_os = "linux")]
24#[path = "loader_linux.rs"]
25mod loader;
23mod manage;26mod manage;
24#[cfg_attr(target_os = "linux", path = "media_linux.rs")]27#[cfg_attr(target_os = "linux", path = "media_linux.rs")]
25#[cfg_attr(target_os = "macos", path = "media_macos.rs")]28#[cfg_attr(target_os = "macos", path = "media_macos.rs")]
...@@ -5910,6 +5913,8 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -5910,6 +5913,8 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
5910}5913}
59115914
5912fn main() -> Result<(), Box<dyn Error>> {5915fn main() -> Result<(), Box<dyn Error>> {
5916 #[cfg(target_os = "linux")]
5917 loader::preload();
5913 platform::with_pool(launch)5918 platform::with_pool(launch)
5914}5919}
59155920
crates/snowbound/src/update.rs+6-2
...@@ -4,10 +4,14 @@...@@ -4,10 +4,14 @@
4//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.4//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.
5//! `tools/RELEASE.md` describes the publishing side.5//! `tools/RELEASE.md` describes the publishing side.
66
7#[cfg(target_os = "linux")]
8use crate::loader::executable;
7use crate::{State, UserEvent, platform};9use crate::{State, UserEvent, platform};
8use ring::signature::{ED25519, UnparsedPublicKey};10use ring::signature::{ED25519, UnparsedPublicKey};
9use serde::Deserialize;11use serde::Deserialize;
10use std::collections::HashMap;12use std::collections::HashMap;
13#[cfg(not(target_os = "linux"))]
14use std::env::current_exe as executable;
11use std::ffi::OsString;15use std::ffi::OsString;
12use std::path::{Path, PathBuf};16use std::path::{Path, PathBuf};
13use std::process::Command;17use std::process::Command;
...@@ -278,7 +282,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri...@@ -278,7 +282,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri
278/// builds and Mac OS X 10.6 have none, and point to the build's folder instead.282/// builds and Mac OS X 10.6 have none, and point to the build's folder instead.
279fn install() -> Option<PathBuf> {283fn install() -> Option<PathBuf> {
280 running()?;284 running()?;
281 let executable = std::env::current_exe().ok()?;285 let executable = executable().ok()?;
282 if !cfg!(feature = "wgpu") {286 if !cfg!(feature = "wgpu") {
283 None287 None
284 } else if cfg!(target_os = "macos") {288 } else if cfg!(target_os = "macos") {
...@@ -659,7 +663,7 @@ pub fn show_build(version: &Version) {...@@ -659,7 +663,7 @@ pub fn show_build(version: &Version) {
659/// the app with the arguments this one had.663/// the app with the arguments this one had.
660pub fn relaunch(staged: &Path) -> std::io::Result<()> {664pub fn relaunch(staged: &Path) -> std::io::Result<()> {
661 let install = install().ok_or_else(|| std::io::Error::other("Nothing to update"))?;665 let install = install().ok_or_else(|| std::io::Error::other("Nothing to update"))?;
662 Command::new(std::env::current_exe()?)666 Command::new(executable()?)
663 .arg(FINISH)667 .arg(FINISH)
664 .arg(std::process::id().to_string())668 .arg(std::process::id().to_string())
665 .arg(staged)669 .arg(staged)