diff --git a/crates/snowbound/build.rs b/crates/snowbound/build.rs index c0326ee89ed5b086255eba7ad7a826fb126b350c..13cc52a2c9109ea30d1476deb4b764d5cdfc38aa 100644 --- a/crates/snowbound/build.rs +++ b/crates/snowbound/build.rs @@ -1,8 +1,14 @@ //! On Windows, links in the executable's icon and manifest, compiled by llvm-mingw's windres, -//! which `platform/windows/cargo.sh` names. +//! which `platform/windows/cargo.sh` names. On Linux, enters through `loader`. fn main() { println!("cargo:rerun-if-changed=build.rs"); + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") + && ["x86_64", "aarch64"].contains(&arch.as_str()) + { + println!("cargo:rustc-link-arg-bins=-Wl,-e,sb_entry"); + } if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } @@ -13,7 +19,6 @@ fn main() { println!("cargo:rerun-if-env-changed=LLVM_MINGW"); let tools = std::env::var("LLVM_MINGW") .expect("Windows builds need llvm-mingw: build through platform/windows/cargo.sh"); - let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap(); let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("resources.o"); let status = std::process::Command::new(format!("{tools}/bin/{arch}-w64-mingw32-windres")) .current_dir(folder) diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index c78feb6ac15015205246ebeb08ab2f198c5a5344..c8646df0366a7b396b82f3c19b0b72a98e2c3326 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -32,7 +32,8 @@ Snowbound. ## Requirements - x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8, - Ubuntu 14.04 and later). + Ubuntu 14.04 and later). NixOS needs nothing extra: Snowbound finds the + libraries below among the system's packages or your profile's. - A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. - fontconfig, and X11 or Wayland with libxkbcommon. diff --git a/crates/snowbound/linux/package.sh b/crates/snowbound/linux/package.sh index 10610342cc8e1fc9c2a0f6f80a40b633b02b374d..5148246208f612efa89e62f3c4d3cd895cb1b78c 100755 --- a/crates/snowbound/linux/package.sh +++ b/crates/snowbound/linux/package.sh @@ -12,6 +12,20 @@ for arch do triple=$arch-unknown-linux-gnu CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \ --manifest-path "$root/Cargo.toml" --release -p snowbound + # PT_INTERP becomes PT_NULL, so the kernel starts the executable at `loader`'s entry, which + # finds the system's dynamic loader, NixOS's too. zig rejects --no-dynamic-linker. + python3 - "$target/$triple/release/snowbound" <<'PYTHON' +import struct, sys +with open(sys.argv[1], 'r+b') as elf: + header = elf.read(64) + table, = struct.unpack_from(') { fn write_portable(path: &Path) -> io::Result<()> { let icon = runtime_icon().ok_or(io::ErrorKind::NotFound)?; fs::write(&icon, ICON)?; - let text = entry_text(&std::env::current_exe()?, &icon.to_string_lossy()) + let text = entry_text(&crate::loader::executable()?, &icon.to_string_lossy()) + &format!("NoDisplay=true\n{PORTABLE}={}\n", std::process::id()); write_entry(path, &text)?; extern "C" fn exiting() { @@ -366,7 +366,7 @@ pub fn install() { fn try_install() -> io::Result<()> { let (data, binary) = data_home().zip(binary()).ok_or(io::ErrorKind::NotFound)?; - let running = std::env::current_exe()?; + let running = crate::loader::executable()?; if fs::canonicalize(&running)? != fs::canonicalize(&binary).unwrap_or_default() { fs::create_dir_all(binary.parent().ok_or(io::ErrorKind::NotFound)?)?; let partial = binary.with_extension("partial"); diff --git a/crates/snowbound/src/loader_linux.rs b/crates/snowbound/src/loader_linux.rs new file mode 100644 index 0000000000000000000000000000000000000000..22e149a3a014611a09e23107847f42d4b00a5026 --- /dev/null +++ b/crates/snowbound/src/loader_linux.rs @@ -0,0 +1,264 @@ +//! Starting on every Linux, NixOS included. Releases carry no PT_INTERP (`linux/package.sh` +//! clears it), so the kernel starts them at `sb_entry` as it would a static executable; +//! stage 1 then re-executes the file under the dynamic loader `/bin/sh` uses. Started by a +//! dynamic loader, `sb_entry` hands straight to glibc's `_start`. + +use std::{ + ffi::{CStr, CString, OsStr, c_void}, + io, + mem::MaybeUninit, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, +}; + +// A dynamic loader passes its finalizer to `_start` in x0 or rdx; the kernel passes zero. +#[cfg(target_arch = "aarch64")] +std::arch::global_asm!( + ".globl sb_entry", + ".type sb_entry, %function", + "sb_entry:", + "cbz x0, 1f", + "b _start", + "1: mov x0, sp", + "b {stage1}", + stage1 = sym stage1, +); +#[cfg(target_arch = "x86_64")] +std::arch::global_asm!( + ".globl sb_entry", + ".type sb_entry, @function", + "sb_entry:", + "test rdx, rdx", + "jnz _start", + "mov rdi, rsp", + "and rsp, -16", + "call {stage1}", + stage1 = sym stage1, +); + +#[cfg(target_arch = "aarch64")] +mod call { + pub const OPENAT: usize = 56; + pub const PREAD: usize = 67; + pub const READLINKAT: usize = 78; + pub const EXECVE: usize = 221; + pub const EXIT: usize = 93; + pub const WRITE: usize = 64; + pub const MMAP: usize = 222; + + pub unsafe fn syscall(number: usize, a: usize, b: usize, c: usize, d: usize) -> isize { + let result; + unsafe { + std::arch::asm!("svc 0", in("x8") number, inlateout("x0") a => result, in("x1") b, + in("x2") c, in("x3") d, in("x4") usize::MAX, in("x5") 0, options(nostack)) + }; + result + } +} +#[cfg(target_arch = "x86_64")] +mod call { + pub const OPENAT: usize = 257; + pub const PREAD: usize = 17; + pub const READLINKAT: usize = 267; + pub const EXECVE: usize = 59; + pub const EXIT: usize = 60; + pub const WRITE: usize = 1; + pub const MMAP: usize = 9; + + pub unsafe fn syscall(number: usize, a: usize, b: usize, c: usize, d: usize) -> isize { + let result; + unsafe { + std::arch::asm!("syscall", inlateout("rax") number => result, in("rdi") a, + in("rsi") b, in("rdx") c, in("r10") d, in("r8") usize::MAX, in("r9") 0, + lateout("rcx") _, lateout("r11") _, options(nostack)) + }; + result + } +} + +const CWD: usize = -100isize as usize; +const O_CLOEXEC: usize = 0o2000000; +static PROC_SELF_EXE: [u8; 15] = *b"/proc/self/exe\0"; +static SHELLS: [[u8; 13]; 2] = [*b"/bin/sh\0\0\0\0\0\0", *b"/usr/bin/env\0"]; +static NO_LOADER: [u8; 62] = *b"snowbound: no dynamic loader; Snowbound runs on glibc systems\n"; + +/// The interpreter of the ELF at `path` into `buffer`, NUL-terminated; false where it has +/// none. +unsafe fn interpreter(path: *const u8, buffer: *mut u8, capacity: usize) -> bool { + use call::*; + unsafe { + let file = syscall(OPENAT, CWD, path as usize, O_CLOEXEC, 0); + if file < 0 { + return false; + } + let file = file as usize; + let mut header = MaybeUninit::<[u64; 8]>::uninit(); + let header = header.as_mut_ptr() as *mut u8; + if syscall(PREAD, file, header as usize, 64, 0) != 64 + || (header as *const u32).read_unaligned() != u32::from_le_bytes(*b"\x7fELF") + { + return false; + } + let table = (header.add(32) as *const u64).read_unaligned() as usize; + let size = (header.add(54) as *const u16).read_unaligned() as usize; + let count = (header.add(56) as *const u16).read_unaligned() as usize; + let mut entry = MaybeUninit::<[u64; 7]>::uninit(); + let entry = entry.as_mut_ptr() as *mut u8; + let mut index = 0; + while index < count { + if syscall(PREAD, file, entry as usize, 56, table + index * size) == 56 + && (entry as *const u32).read_unaligned() == 3 + { + let offset = (entry.add(8) as *const u64).read_unaligned() as usize; + let length = (entry.add(32) as *const u64).read_unaligned() as usize; + return length < capacity + && syscall(PREAD, file, buffer as usize, length, offset) == length as isize + && { + buffer.add(length).write_volatile(0); + true + }; + } + index += 1; + } + false + } +} + +/// Runs before relocation: only system calls, the stack and position-relative statics. +unsafe extern "C" fn stage1(stack: *const usize) -> ! { + use call::*; + unsafe { + let argc = *stack; + let argv = stack.add(1); + let environment = argv.add(argc + 1); + let mut exe = MaybeUninit::<[u8; 4096]>::uninit(); + let exe = exe.as_mut_ptr() as *mut u8; + let mut loader = MaybeUninit::<[u8; 4096]>::uninit(); + let loader = loader.as_mut_ptr() as *mut u8; + let length = syscall( + READLINKAT, + CWD, + PROC_SELF_EXE.as_ptr() as usize, + exe as usize, + 4095, + ); + let found = length > 0 && { + exe.add(length as usize).write_volatile(0); + let mut shell = 0; + while shell < SHELLS.len() && !interpreter(SHELLS[shell].as_ptr(), loader, 4096) { + shell += 1; + } + shell < SHELLS.len() + }; + // The loader runs the program its `argv[1]` names, which the program gets as `argv[0]`. + let bytes = (argc + 2) * size_of::(); + let arguments = syscall(MMAP, 0, bytes, 3, 0x22) as *mut usize; + if found && (arguments as isize) > 0 { + arguments.write_volatile(loader as usize); + arguments.add(1).write_volatile(exe as usize); + let mut index = 1; + while index < argc { + arguments.add(index + 1).write_volatile(*argv.add(index)); + index += 1; + } + arguments.add(argc + 1).write_volatile(0); + syscall( + EXECVE, + loader as usize, + arguments as usize, + environment as usize, + 0, + ); + } + syscall(WRITE, 2, NO_LOADER.as_ptr() as usize, NO_LOADER.len(), 0); + loop { + syscall(EXIT, 127, 0, 0, 0); + } + } +} + +/// This executable's path. `current_exe` names the dynamic loader where stage 1 or a person +/// started the file through it; the loader passes the file's path on as `argv[0]`. +pub fn executable() -> io::Result { + let running = std::env::current_exe()?; + let name = running.file_name().map_or(&b""[..], |name| name.as_bytes()); + if !name.starts_with(b"ld-") { + return Ok(running); + } + std::fs::canonicalize(std::env::args_os().next().ok_or(io::ErrorKind::NotFound)?) +} + +/// Whether the library `name` loads, as the crates that open it would find it. +pub fn loads(name: &CStr) -> bool { + !unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_LAZY) }.is_null() +} + +/// The libraries Snowbound and its crates open by name, Wayland's and X11's first. +const LIBRARIES: [&CStr; 14] = [ + c"libwayland-client.so.0", + c"libwayland-egl.so.1", + c"libxkbcommon.so.0", + c"libxkbcommon-x11.so.0", + c"libX11.so.6", + c"libX11-xcb.so.1", + c"libXcursor.so.1", + c"libXi.so.6", + c"libxcb.so.1", + c"libvulkan.so.1", + c"libEGL.so.1", + c"libfontconfig.so.1", + c"libenchant-2.so.2", + c"libgstreamer-1.0.so.0", +]; + +/// On NixOS, where libraries sit only in the store, loads by path each library the system's +/// and the user's profiles have that no search finds; opening one by name then finds it +/// loaded. +pub fn preload() { + let system = Path::new("/run/current-system"); + if !system.exists() { + return; + } + let mut missing: Vec<&CStr> = LIBRARIES.into_iter().filter(|name| !loads(name)).collect(); + if missing.is_empty() { + return; + } + let home = std::env::var_os("HOME").map(PathBuf::from); + let user = std::env::var_os("USER").map(|user| Path::new("/etc/profiles/per-user").join(user)); + let profiles = [ + Some(system.to_owned()), + user, + home.map(|home| home.join(".nix-profile")), + ]; + let Ok(output) = std::process::Command::new(system.join("sw/bin/nix-store")) + .arg("--query") + .arg("--requisites") + .args( + profiles + .into_iter() + .flatten() + .filter(|profile| profile.exists()), + ) + .stderr(std::process::Stdio::null()) + .output() + else { + return; + }; + for path in output.stdout.split(|&byte| byte == b'\n') { + let folder = Path::new(OsStr::from_bytes(path)).join("lib"); + if missing.is_empty() { + break; + } + if !folder.is_dir() { + continue; + } + missing.retain(|name| { + let file = folder.join(OsStr::from_bytes(name.to_bytes())); + let Ok(file) = CString::new(file.as_os_str().as_bytes()) else { + return true; + }; + let library: *mut c_void = unsafe { libc::dlopen(file.as_ptr(), libc::RTLD_LAZY) }; + library.is_null() + }); + } +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 7816868a9e5254c598f0fe818237b42781ebfc4a..d9730fe23a96a4b10636f703d7f64c8833876b52 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -20,6 +20,9 @@ mod instance; mod keys; mod library; mod link; +#[cfg(target_os = "linux")] +#[path = "loader_linux.rs"] +mod loader; mod manage; #[cfg_attr(target_os = "linux", path = "media_linux.rs")] #[cfg_attr(target_os = "macos", path = "media_macos.rs")] @@ -5910,6 +5913,8 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box Result<(), Box> { + #[cfg(target_os = "linux")] + loader::preload(); platform::with_pool(launch) } diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index a97ab4b6deb089c5e74aa683d69bb93318b8bc17..089d41887f0fe6e07b7b52e981760ceb303bfc20 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -4,10 +4,14 @@ //! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits. //! `tools/RELEASE.md` describes the publishing side. +#[cfg(target_os = "linux")] +use crate::loader::executable; use crate::{State, UserEvent, platform}; use ring::signature::{ED25519, UnparsedPublicKey}; use serde::Deserialize; use std::collections::HashMap; +#[cfg(not(target_os = "linux"))] +use std::env::current_exe as executable; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Command; @@ -278,7 +282,7 @@ fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), Stri /// builds and Mac OS X 10.6 have none, and point to the build's folder instead. fn install() -> Option { running()?; - let executable = std::env::current_exe().ok()?; + let executable = executable().ok()?; if !cfg!(feature = "wgpu") { None } else if cfg!(target_os = "macos") { @@ -659,7 +663,7 @@ pub fn show_build(version: &Version) { /// the app with the arguments this one had. pub fn relaunch(staged: &Path) -> std::io::Result<()> { let install = install().ok_or_else(|| std::io::Error::other("Nothing to update"))?; - Command::new(std::env::current_exe()?) + Command::new(executable()?) .arg(FINISH) .arg(std::process::id().to_string()) .arg(staged)