Files
wanderbooks/crates/ledger-host/src/start.rs
T
swaitsandClaude Fable 5.1 6e7deb15c7 feat(host): the daily update check and the staged update (P11a step 4)
While the books are open the host looks at the release feed at most once
a day (knob update_check, noted in updates/checked so a restart does not
look again), and only downloads and verifies a newer release into
<root>/updates/staged/ (sha256 AND minisign signature, as for update).
It never swaps: the staged program is applied (a) by an opener just
before it starts a host, when no host runs, so no call is cut off, after
a second check against the key (a staged file that fails or does not
start is dropped), and (b) by wanderbooks update, the owner's say-so,
which reuses the staged copy instead of downloading again. A local
release can be named in <root>/updates/feed (file:// only, with its key
file, same rule as --trusted-key). The program's version is set by main
since packages rewrite only the cli's. No new dependency.

Tests (crates/ledger-cli/tests/update.rs): the_host_stages_a_daily_update_and_the_next_start_applies_it,
a_daily_update_that_is_not_ours_is_not_staged,
update_installs_the_copy_the_host_staged_without_downloading_again,
a_staged_update_that_was_tampered_with_is_dropped_not_applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:23:40 -06:00

504 lines
20 KiB
Rust

//! Starting the host cleanly (plan 3.9, "Starting the host cleanly").
//!
//! ```text
//! GOAL: the host never inherits anything from whoever opened the books:
//! not their environment, descriptors, session, umask, or the app's
//! scope (which would take the host down with the app).
//! SOURCE: plan/served.md 3.9; test host_starts_clean, host_survives_app_scope.
//! INVARIANTS:
//! 1. The host's environment is a fixed list: PATH (fixed), HOME (from
//! the account's record, never the opener's), and XDG_RUNTIME_DIR
//! and DBUS_SESSION_BUS_ADDRESS only when they point at this
//! account's own private runtime folder, and SSH_AUTH_SOCK only when
//! it names a socket this account owns (the owner's ssh agent, so the
//! server's backups reach a private git repository, D-30). Nothing
//! else passes (a scope's own identifiers, such as INVOCATION_ID, are
//! systemd's). One more: APPIMAGE, only when it names an absolute
//! file, so the host knows its own AppImage (below).
//! 2. Its standard input and output are null, its errors go to
//! run/host.log (0600); it starts a new session with umask 077. Every
//! other descriptor is closed (std opens them close-on-exec, and the
//! host closes any stray one at start).
//! 3. Under a systemd app scope it starts through a transient
//! `systemd-run --user --scope` (nothing installed or kept); if the
//! scope couldn't be made (systemd-run failed without the host ever
//! writing its first line), or there is no such scope, directly. A
//! host that ran and stopped is never started a second time.
//! 4. It runs this executable's file as it is on disk now; for an
//! AppImage that is the image file (APPIMAGE), not the program inside
//! its mount, which goes away with the image it came from. The host
//! reads that path about itself and never tells it to an agent (I-2),
//! so a replaced image restarts the books like any replaced program.
//! 5. The opener doesn't wait for it, and reaps it when it ends.
//! ```
use crate::knobs::Knobs;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// The host's PATH (INVARIANT 1).
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
/// Where systemd-run may be (never looked up on the opener's PATH).
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
/// The host's log, in the root's `run/`.
pub const HOST_LOG: &str = "host.log";
/// A log larger than this is moved aside when a host starts.
const LOG_LIMIT: u64 = 8 * 1024 * 1024;
/// This executable's file as it is on disk now (INVARIANT 4): on Linux a
/// replaced file reads as "… (deleted)"; the path, not the old file, is
/// what a new host runs.
pub fn this_executable() -> Result<PathBuf, String> {
if let Some(image) = own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
return Ok(PathBuf::from(image));
}
let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
let text = exe.to_string_lossy();
if let Some(path) = text.strip_suffix(" (deleted)")
&& !exe.exists()
{
return Ok(PathBuf::from(path));
}
Ok(exe)
}
/// The AppImage this process runs from: `APPIMAGE` when it is an
/// absolute path to a file.
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
let path = Path::new(value?);
(path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string())
}
/// This account's user ID.
fn own_uid() -> u32 {
// SAFETY: geteuid has no preconditions and can't fail.
unsafe { libc::geteuid() }
}
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
/// runtime folder: absolute, a real folder (no link), owned by this
/// account, and closed to everyone else.
pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
// Written plainly (no trailing slash, no `.`), as a bus address
// names it.
let dir: PathBuf = PathBuf::from(value?).components().collect();
if !dir.is_absolute() {
return None;
}
let meta = std::fs::symlink_metadata(&dir).ok()?;
(meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir)
}
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
let value = value?.to_str()?;
let want = format!("unix:path={}/bus", runtime.display());
let rest = value.strip_prefix(&want)?;
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
}
/// This account's runtime folder: the one named in the environment if it
/// is ours and private; else, when the opener's environment has none (a
/// client that scrubs it, as Codex does), the system's for this account,
/// `/run/user/<uid>`, under the same checks.
fn runtime_dir() -> Option<PathBuf> {
// The one named, if it passes; else (none named, or one that isn't
// ours or private) the system's for this account.
std::env::var_os("XDG_RUNTIME_DIR")
.and_then(|named| own_runtime_dir(Some(&named)))
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
}
/// The session bus in `runtime`: the one named in the environment if it
/// is there, else its usual socket there, if one exists.
fn session_bus(runtime: &Path) -> Option<String> {
match std::env::var_os("DBUS_SESSION_BUS_ADDRESS") {
Some(named) => own_bus(Some(&named), runtime),
None => {
use std::os::unix::fs::FileTypeExt;
let socket = runtime.join("bus");
std::fs::symlink_metadata(&socket)
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid())
.then(|| format!("unix:path={}", socket.display()))
}
}
}
/// The ssh agent's socket named in `named`, if it is a socket this
/// account owns at an absolute path (INVARIANT 1).
fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
use std::os::unix::fs::FileTypeExt;
let path = Path::new(named?);
(path.is_absolute()
&& std::fs::symlink_metadata(path)
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid()))
.then(|| path.as_os_str().to_os_string())
}
/// The host's environment (INVARIANT 1), given the opener's
/// `SSH_AUTH_SOCK`.
fn host_env(
home: &Path,
ssh_agent: Option<&std::ffi::OsStr>,
appimage: Option<&std::ffi::OsStr>,
) -> Vec<(String, std::ffi::OsString)> {
let mut env = vec![
("PATH".to_string(), PATH.into()),
("HOME".to_string(), home.as_os_str().to_os_string()),
];
if let Some(runtime) = runtime_dir() {
if let Some(bus) = session_bus(&runtime) {
env.push(("DBUS_SESSION_BUS_ADDRESS".into(), bus.into()));
}
env.push(("XDG_RUNTIME_DIR".into(), runtime.into_os_string()));
}
if let Some(agent) = own_agent(ssh_agent) {
env.push(("SSH_AUTH_SOCK".into(), agent));
}
if let Some(image) = own_appimage(appimage) {
env.push(("APPIMAGE".into(), image));
}
env
}
/// Whether this process sits in a unit under this account's user manager
/// (an app's scope or service, as uwsm makes for every app), whose stop
/// would take the host with it; going by the unified cgroup line of
/// `/proc/self/cgroup`. The manager's own `init.scope` is not such a
/// unit.
pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
let manager = format!("/user@{uid}.service/");
cgroup.lines().any(|line| {
line.strip_prefix("0::").is_some_and(|path| {
path.split_once(manager.as_str())
.is_some_and(|(_, unit)| !unit.is_empty() && unit != "init.scope")
})
})
}
/// systemd-run, if this process is in an app scope and the user manager
/// can be reached (INVARIANT 3).
fn systemd_run() -> Option<&'static str> {
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
if !in_app_scope(&cgroup, own_uid()) {
return None;
}
// systemd-run reaches the user manager through the runtime folder,
// which host_env gives it even when the opener's environment didn't.
runtime_dir()?;
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
}
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
pub fn start_host(
root: Option<&Path>,
knobs: &Knobs,
server_flags: &[String],
) -> Result<(), String> {
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
// A staged update is applied here and nowhere while books are open:
// no host runs now, so no call is cut off (update.rs INVARIANT 9). A
// failure leaves the program as it was.
let _ = crate::update::apply_staged(&found, crate::update::program_version());
let exe = this_executable()?;
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
// An explicit root goes on as found (absolute): the host starts in /.
if root.is_some() {
args.push("--root".into());
args.push(found.path().as_os_str().to_os_string());
}
args.extend(knobs.args().into_iter().map(Into::into));
// What the server this host starts is told (P9a): the same flags
// `serve` was given.
args.extend(server_flags.iter().map(Into::into));
let log = || {
let path = run.join(HOST_LOG);
// A large log is moved aside first (to `.1`), never from under a
// host writing it.
if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT)
&& !crate::opener::host_lock_held(&run)
{
let _ = std::fs::rename(&path, path.with_extension("log.1"));
}
std::fs::OpenOptions::new()
.create(true)
.read(true)
.append(true)
.mode(0o600)
.open(&path)
.map_err(|e| format!("can't open the host's log: {}", e.kind()))
};
// Through systemd-run (`Some`), or directly.
let command = |through: Option<&str>, log: std::fs::File| -> Result<Command, String> {
let mut c = match through {
Some(systemd_run) => {
let mut c = Command::new(systemd_run);
c.args(["--user", "--scope", "--quiet", "--collect", "--"])
.arg(&exe);
c
}
None => Command::new(&exe),
};
c.args(&args)
.env_clear()
.envs(host_env(
&home,
std::env::var_os("SSH_AUTH_SOCK").as_deref(),
std::env::var_os("APPIMAGE").as_deref(),
))
.current_dir("/")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(log);
// SAFETY: setsid and umask are async-signal-safe and touch only
// the child.
unsafe {
c.pre_exec(|| {
libc::setsid();
libc::umask(0o077);
Ok(())
});
}
Ok(c)
};
if let Some(systemd_run) = systemd_run() {
// The log as this start opened it, and where it stands now: what
// the host writes after this is read back from that same file,
// even if another opener moves the log aside meanwhile.
let file = log()?;
let from = file.metadata().map_or(0, |m| m.len());
let watch = file
.try_clone()
.map_err(|e| format!("can't open the host's log: {}", e.kind()))?;
if let Ok(mut child) = command(Some(systemd_run), file)?.spawn()
&& scope_made(&mut child, &watch, from)
{
reap(child);
return Ok(());
}
}
let child = command(None, log()?)?
.spawn()
.map_err(|e| format!("can't start the books' host: {e}"))?;
reap(child);
Ok(())
}
/// How long systemd-run has to make its scope and run the host.
const SCOPE_WAIT: Duration = Duration::from_secs(2);
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
/// host's first line is in its log after `from`, or systemd-run is still
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
/// failing with no host line means it didn't: then the host is started
/// directly. (systemd-run's own complaints go to the same log, so the
/// log growing proves nothing; the host's line does.)
fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -> bool {
let host_ran = || {
use std::os::unix::fs::FileExt;
let len = log.metadata().map_or(0, |m| m.len());
let mut tail = vec![0u8; len.saturating_sub(from).min(64 * 1024) as usize];
log.read_exact_at(&mut tail, from).is_ok()
&& String::from_utf8_lossy(&tail).contains(crate::host::STARTED)
};
let deadline = Instant::now() + SCOPE_WAIT;
loop {
if host_ran() {
return true;
}
match child.try_wait() {
Ok(Some(status)) => return status.success() || host_ran(),
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
_ => return true,
}
}
}
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
fn reap(mut child: std::process::Child) {
let _ = std::thread::Builder::new()
.name("reap-host".into())
.spawn(move || {
let _ = child.wait();
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn an_app_scope_is_known_by_its_unified_cgroup() {
let uwsm = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-Hyprland-kitty-1234.scope\n";
assert!(in_app_scope(uwsm, 1000));
assert!(!in_app_scope(uwsm, 1001), "another account's manager");
// An app run as a service unit (uwsm's app-…@….service) too.
assert!(in_app_scope(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foo@x.service\n",
1000
));
for not in [
"0::/user.slice/user-1000.slice/session-2.scope\n",
"0::/user.slice/user-1000.slice/user@1000.service/init.scope\n",
"0::/user.slice/user-1000.slice/user@1000.service/\n",
"0::/\n",
"1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n",
"",
] {
assert!(!in_app_scope(not, 1000), "{not}");
}
}
#[test]
fn only_our_own_private_runtime_folder_passes() {
let dir = tempfile::tempdir().unwrap();
let rt = dir.path().join("rt");
std::fs::create_dir(&rt).unwrap();
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(own_runtime_dir(Some(rt.as_os_str())), Some(rt.clone()));
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o750)).unwrap();
assert_eq!(
own_runtime_dir(Some(rt.as_os_str())),
None,
"group can enter"
);
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&rt, &link).unwrap();
assert_eq!(own_runtime_dir(Some(link.as_os_str())), None, "a link");
assert_eq!(own_runtime_dir(Some("relative/rt".as_ref())), None);
assert_eq!(
own_runtime_dir(Some(dir.path().join("none").as_os_str())),
None
);
assert_eq!(own_runtime_dir(None), None);
// Owned by root, not us.
if own_uid() != 0 {
assert_eq!(own_runtime_dir(Some("/".as_ref())), None);
}
}
#[test]
fn only_the_bus_in_our_runtime_folder_passes() {
let rt = Path::new("/run/user/1000");
assert_eq!(
own_bus(Some("unix:path=/run/user/1000/bus".as_ref()), rt).as_deref(),
Some("unix:path=/run/user/1000/bus")
);
assert!(own_bus(Some("unix:path=/run/user/1000/bus,guid=ab".as_ref()), rt).is_some());
for bad in [
"unix:path=/tmp/evil/bus",
"unix:path=/run/user/1000/bus2",
"unix:path=/run/user/1000/busx,guid=1",
"tcp:host=evil.example,port=1",
"unix:abstract=/run/user/1000/bus",
] {
assert_eq!(own_bus(Some(bad.as_ref()), rt), None, "{bad}");
}
assert_eq!(own_bus(None, rt), None);
}
#[test]
fn the_host_environment_carries_nothing_else() {
let env = host_env(Path::new("/home/someone"), None, None);
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(&names[..2], ["PATH", "HOME"]);
for n in &names[2..] {
assert!(
[
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"SSH_AUTH_SOCK",
"APPIMAGE"
]
.contains(n),
"{n}"
);
}
assert_eq!(env[0].1, PATH);
assert_eq!(env[1].1, "/home/someone");
}
#[test]
fn only_an_absolute_file_passes_as_the_appimage() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("W.AppImage");
std::fs::write(&image, b"x").unwrap();
let home = Path::new("/home/someone");
let has = |v: Option<&std::ffi::OsStr>| {
host_env(home, None, v).iter().any(|(n, _)| n == "APPIMAGE")
};
assert!(has(Some(image.as_os_str())));
assert!(!has(None));
assert!(!has(Some("W.AppImage".as_ref())), "relative");
assert!(!has(Some(dir.path().as_os_str())), "a folder");
assert!(!has(Some(dir.path().join("gone").as_os_str())));
}
#[test]
fn only_an_own_ssh_agent_socket_passes() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("agent.sock");
let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
assert_eq!(
own_agent(Some(sock.as_os_str())),
Some(sock.clone().into_os_string())
);
// It reaches the host's environment, once.
let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()), None);
let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].1, sock.as_os_str());
// A link to it is not it (whoever made the link chose where it
// points).
let link = dir.path().join("link.sock");
std::os::unix::fs::symlink(&sock, &link).unwrap();
assert_eq!(own_agent(Some(link.as_os_str())), None);
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()), None);
assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK"));
// Named relative to here: not taken, even where it leads to one.
let here = std::env::current_dir().unwrap();
let up = here.components().count() - 1;
let relative: std::path::PathBuf = std::iter::repeat_n("..", up)
.collect::<std::path::PathBuf>()
.join(sock.strip_prefix("/").unwrap());
assert!(relative.exists() && relative.is_relative(), "{relative:?}");
assert_eq!(own_agent(Some(relative.as_os_str())), None);
// Another account's socket (the system's bus, owned by root, where
// there is one): not ours.
let system = Path::new("/run/dbus/system_bus_socket");
if system.exists() && own_uid() != 0 {
assert_eq!(own_agent(Some(system.as_os_str())), None);
}
let file = dir.path().join("plain");
std::fs::write(&file, "x").unwrap();
for bad in [
file.as_os_str(),
"agent.sock".as_ref(),
"/nonexistent/agent.sock".as_ref(),
"".as_ref(),
] {
assert_eq!(own_agent(Some(bad)), None, "{bad:?}");
}
assert_eq!(own_agent(None), None);
}
#[test]
fn this_executable_is_a_file_on_disk() {
let exe = this_executable().unwrap();
assert!(exe.is_file(), "{exe:?}");
}
}